chore: consolidate platform split checks
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import Card from "../components/Card";
|
||||
|
||||
export default function PlaceholderPage({ title }: { title: string }) {
|
||||
export default function PlaceholderPage({ title }: {title: string;}) {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>{title}</h1>
|
||||
<p>This module is prepared but not implemented yet.</p>
|
||||
<p>i18n:govoplan-core.this_module_is_prepared_but_not_implemented_yet.6e6449ee</p>
|
||||
</div>
|
||||
<Card>
|
||||
<p className="muted">Next passes will add functionality here.</p>
|
||||
<p className="muted">i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -11,15 +11,15 @@ export default function LoginModal({
|
||||
settings,
|
||||
onClose,
|
||||
onLogin,
|
||||
title = "Sign in",
|
||||
title = "i18n:govoplan-core.sign_in.ada2e9e9",
|
||||
message
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
onClose: () => void;
|
||||
onLogin: (response: LoginResponse) => void;
|
||||
title?: string;
|
||||
message?: string;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;onClose: () => void;onLogin: (response: LoginResponse) => void;title?: string;message?: string;}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -46,23 +46,23 @@ export default function LoginModal({
|
||||
open
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" form={formId} variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" onClick={onClose}>i18n:govoplan-core.cancel.77dfd213</Button>
|
||||
<Button type="submit" form={formId} variant="primary" disabled={busy}>{busy ? "i18n:govoplan-core.signing_in.c66b2adc" : "i18n:govoplan-core.sign_in.ada2e9e9"}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
}>
|
||||
|
||||
<form id={formId} className="form-grid" onSubmit={submit}>
|
||||
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<FormField label="Email">
|
||||
<FormField label="i18n:govoplan-core.email.84add5b2">
|
||||
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
||||
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
|
||||
</FormField>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
@@ -7,48 +7,48 @@ export default function PublicLandingPage({
|
||||
settings,
|
||||
maintenanceMode,
|
||||
onLogin
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
maintenanceMode?: { enabled: boolean; message?: string | null };
|
||||
onLogin: (response: LoginResponse) => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;maintenanceMode?: {enabled: boolean;message?: string | null;};onLogin: (response: LoginResponse) => void;}) {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="public-landing">
|
||||
<section className="public-card">
|
||||
<div className="public-kicker">GovOPlaN</div>
|
||||
<h1>Modular planning tools with controlled access.</h1>
|
||||
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
|
||||
<h1>i18n:govoplan-core.modular_planning_tools_with_controlled_access.1d6b0743</h1>
|
||||
<p>
|
||||
Sign in to open the modules available to your tenant and role.
|
||||
i18n:govoplan-core.sign_in_to_open_the_modules_available_to_your_te.8bb7dab4
|
||||
</p>
|
||||
|
||||
{maintenanceMode?.enabled && (
|
||||
<div className="public-maintenance alert warning">
|
||||
<strong>Maintenance mode is active.</strong>
|
||||
<span>{maintenanceMode.message || "Only users with maintenance access can use the system right now."}</span>
|
||||
{maintenanceMode?.enabled &&
|
||||
<div className="public-maintenance alert warning">
|
||||
<strong>i18n:govoplan-core.maintenance_mode_is_active.70f918d9</strong>
|
||||
<span>{maintenanceMode.message || "i18n:govoplan-core.only_users_with_maintenance_access_can_use_the_s.5566264f"}</span>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
<div className="public-actions">
|
||||
<Button variant="primary" onClick={() => setLoginOpen(true)}>Sign in</Button>
|
||||
<Button variant="primary" onClick={() => setLoginOpen(true)}>i18n:govoplan-core.sign_in.ada2e9e9</Button>
|
||||
</div>
|
||||
|
||||
<div className="public-footnote">
|
||||
Access is restricted. Sign in to open available modules and administration.
|
||||
i18n:govoplan-core.access_is_restricted_sign_in_to_open_available_m.2a47a549
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loginOpen && (
|
||||
<LoginModal
|
||||
settings={settings}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onLogin={(response) => {
|
||||
onLogin(response);
|
||||
setLoginOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{loginOpen &&
|
||||
<LoginModal
|
||||
settings={settings}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onLogin={(response) => {
|
||||
onLogin(response);
|
||||
setLoginOpen(false);
|
||||
}} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,149 +1,46 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { apiFetch, isApiError } from "../../api/client";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import { usePlatformModules } from "../../platform/ModuleContext";
|
||||
import type { ApiSettings } from "../../types";
|
||||
|
||||
type DashboardCampaign = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
external_id?: string | null;
|
||||
status?: string | null;
|
||||
updated_at?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
type CampaignListResponse = DashboardCampaign[] | { campaigns?: DashboardCampaign[]; items?: DashboardCampaign[]; results?: DashboardCampaign[] };
|
||||
|
||||
export default function DashboardPage({ settings }: { settings: ApiSettings }) {
|
||||
export default function DashboardPage() {
|
||||
const modules = usePlatformModules();
|
||||
const campaignsInstalled = modules.some((module) => module.id === "campaigns");
|
||||
const [campaigns, setCampaigns] = useState<DashboardCampaign[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignsInstalled) {
|
||||
setCampaigns([]);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns")
|
||||
.then((response) => {
|
||||
if (!cancelled) setCampaigns(campaignsFromResponse(response));
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (isApiError(reason, 403, 404)) {
|
||||
setCampaigns([]);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignsInstalled, settings]);
|
||||
|
||||
const statusCounts = useMemo(() => countByStatus(campaigns), [campaigns]);
|
||||
const recentCampaigns = useMemo(
|
||||
() => [...campaigns].sort((left, right) => timestamp(right) - timestamp(left)).slice(0, 5),
|
||||
[campaigns],
|
||||
);
|
||||
const activeCampaigns = (statusCounts.active ?? 0) + (statusCounts.draft ?? 0);
|
||||
const completedCampaigns = (statusCounts.completed ?? 0) + (statusCounts.sent ?? 0);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Tenant overview across installed modules and accessible campaigns.</p>
|
||||
<PageTitle>i18n:govoplan-core.dashboard.d87f47b4</PageTitle>
|
||||
<p>Install and enable the dashboard module to make this page configurable.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="warning" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Installed modules" value={modules.length} tone="info" detail={modules.length ? moduleLabels(modules).join(", ") : "Core only"} />
|
||||
<MetricCard label="Campaigns" value={campaignsInstalled ? campaigns.length : "—"} tone="neutral" detail={campaignsInstalled ? "Accessible to you" : "Campaign module not installed"} />
|
||||
<MetricCard label="Active drafts" value={campaignsInstalled ? activeCampaigns : "—"} tone="warning" detail="Draft or active" />
|
||||
<MetricCard label="Completed" value={campaignsInstalled ? completedCampaigns : "—"} tone="good" detail="Completed or sent" />
|
||||
<MetricCard label="i18n:govoplan-core.installed_modules.32b3e799" value={modules.length} tone="info" detail={modules.length ? moduleLabels(modules).join(", ") : "i18n:govoplan-core.core_only.d46ce7d9"} />
|
||||
<MetricCard label="Dashboard module" value="not installed" tone="neutral" detail="Core fallback is active." />
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Recent campaigns" collapsible>
|
||||
<LoadingFrame loading={loading} label="Loading campaigns...">
|
||||
{!campaignsInstalled && <p className="muted">Install the Campaign module to show campaign activity here.</p>}
|
||||
{campaignsInstalled && recentCampaigns.length === 0 && <p className="muted">No accessible campaigns found.</p>}
|
||||
{recentCampaigns.length > 0 && (
|
||||
<dl className="detail-list">
|
||||
{recentCampaigns.map((campaign) => (
|
||||
<div key={campaign.id}>
|
||||
<dt><StatusBadge status={campaign.status || "draft"} /></dt>
|
||||
<dd>
|
||||
<strong>{campaign.name || campaign.external_id || campaign.id}</strong>
|
||||
<span className="muted"> · {formatDashboardDate(campaign.updated_at ?? campaign.updatedAt)}</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
|
||||
<Card title="Installed modules" collapsible>
|
||||
{modules.length === 0 ? <p className="muted">No feature modules are active.</p> : (
|
||||
<dl className="detail-list">
|
||||
{modules.map((module) => (
|
||||
<div key={module.id}>
|
||||
<Card title="i18n:govoplan-core.installed_modules.32b3e799">
|
||||
{modules.length === 0 ? <p className="muted">i18n:govoplan-core.no_feature_modules_are_active.e9c2d936</p> :
|
||||
<dl className="detail-list">
|
||||
{modules.map((module) =>
|
||||
<div key={module.id}>
|
||||
<dt>{module.id}</dt>
|
||||
<dd><strong>{module.label}</strong><span className="muted"> · v{module.version}</span></dd>
|
||||
<dd><strong>{module.label}</strong><span className="muted"> i18n:govoplan-core.v.26c12f1e{module.version}</span></dd>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
}
|
||||
</Card>
|
||||
<Card title="Home">
|
||||
<p className="muted">This minimal core home is only shown while no dashboard WebUI module is available. Feature modules own their pages and can expose dashboard widgets once the dashboard module is installed.</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function campaignsFromResponse(response: CampaignListResponse): DashboardCampaign[] {
|
||||
if (Array.isArray(response)) return response;
|
||||
return response.campaigns ?? response.items ?? response.results ?? [];
|
||||
}
|
||||
|
||||
function countByStatus(campaigns: DashboardCampaign[]): Record<string, number> {
|
||||
return campaigns.reduce<Record<string, number>>((counts, campaign) => {
|
||||
const status = String(campaign.status || "draft").toLowerCase();
|
||||
counts[status] = (counts[status] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function timestamp(campaign: DashboardCampaign): number {
|
||||
const value = campaign.updated_at ?? campaign.updatedAt ?? "";
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatDashboardDate(value?: string | null): string {
|
||||
if (!value) return "not updated";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
|
||||
}
|
||||
|
||||
function moduleLabels(modules: Array<{ label: string }>): string[] {
|
||||
function moduleLabels(modules: Array<{label: string;}>): string[] {
|
||||
return modules.map((module) => module.label).slice(0, 4);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { i18nMessage } from "../../i18n/LanguageContext";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
@@ -8,15 +9,21 @@ import {
|
||||
type PrivacyRetentionPolicy,
|
||||
type PrivacyRetentionPolicyFieldKey,
|
||||
type PrivacyRetentionPolicyPatch,
|
||||
type PrivacyRetentionPolicyScope
|
||||
} from "../../api/admin";
|
||||
type PrivacyRetentionPolicyScope } from
|
||||
"../../api/admin";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { PolicyRow, PolicySection, PolicyTable } from "../../components/PolicyTable";
|
||||
import type { PolicySourcePathItem } from "../../components/PolicySourcePath";
|
||||
import FieldLabel from "../../components/help/FieldLabel";
|
||||
import FormField from "../../components/FormField";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||
import {
|
||||
privacyRetentionLocalAllowsLowerLevel,
|
||||
privacyRetentionParentAllowsField } from
|
||||
"./policyLogic";
|
||||
|
||||
export type RetentionPolicyTargetOption = {
|
||||
id: string;
|
||||
@@ -47,11 +54,11 @@ type RetentionPolicyEditorProps = {
|
||||
};
|
||||
|
||||
type DayKey =
|
||||
| "raw_campaign_json_retention_days"
|
||||
| "generated_eml_retention_days"
|
||||
| "stored_report_detail_retention_days"
|
||||
| "mock_mailbox_retention_days"
|
||||
| "audit_detail_retention_days";
|
||||
"raw_campaign_json_retention_days" |
|
||||
"generated_eml_retention_days" |
|
||||
"stored_report_detail_retention_days" |
|
||||
"mock_mailbox_retention_days" |
|
||||
"audit_detail_retention_days";
|
||||
|
||||
type FieldDefinition = {
|
||||
key: PrivacyRetentionPolicyFieldKey;
|
||||
@@ -87,22 +94,22 @@ const defaultPrivacyPolicy: PrivacyRetentionPolicy = {
|
||||
};
|
||||
|
||||
const fieldDefinitions: FieldDefinition[] = [
|
||||
{ key: "store_raw_campaign_json", label: "Raw campaign JSON", kind: "raw-json" },
|
||||
{ key: "raw_campaign_json_retention_days", label: "Raw campaign JSON days", kind: "days" },
|
||||
{ key: "generated_eml_retention_days", label: "Generated EML days", kind: "days" },
|
||||
{ key: "stored_report_detail_retention_days", label: "Stored report detail days", kind: "days" },
|
||||
{ key: "mock_mailbox_retention_days", label: "Mock mailbox days", kind: "days", systemOnly: true },
|
||||
{ key: "audit_detail_retention_days", label: "Audit detail days", kind: "days" },
|
||||
{ key: "audit_detail_level", label: "Audit detail level", kind: "audit" }
|
||||
];
|
||||
{ key: "store_raw_campaign_json", label: "i18n:govoplan-core.raw_campaign_json.4ca2265b", kind: "raw-json" },
|
||||
{ key: "raw_campaign_json_retention_days", label: "i18n:govoplan-core.raw_campaign_json_days.48805a59", kind: "days" },
|
||||
{ key: "generated_eml_retention_days", label: "i18n:govoplan-core.generated_eml_days.bcb273d9", kind: "days" },
|
||||
{ key: "stored_report_detail_retention_days", label: "i18n:govoplan-core.stored_report_detail_days.296f6dab", kind: "days" },
|
||||
{ key: "mock_mailbox_retention_days", label: "i18n:govoplan-core.mock_mailbox_days.a0cf3209", kind: "days", systemOnly: true },
|
||||
{ key: "audit_detail_retention_days", label: "i18n:govoplan-core.audit_detail_days.21c84dd1", kind: "days" },
|
||||
{ key: "audit_detail_level", label: "i18n:govoplan-core.audit_detail_level.b0565260", kind: "audit" }];
|
||||
|
||||
|
||||
export function RetentionPolicyScopeManager({
|
||||
settings,
|
||||
scopeType,
|
||||
scopeId = null,
|
||||
targetOptions = [],
|
||||
targetLabel = "Target",
|
||||
title = "Retention policy",
|
||||
targetLabel = "i18n:govoplan-core.target.61ad50a9",
|
||||
title = "i18n:govoplan-core.retention_policy.962fb418",
|
||||
description,
|
||||
canWrite,
|
||||
locked = false
|
||||
@@ -112,10 +119,10 @@ export function RetentionPolicyScopeManager({
|
||||
const targetSelectionRequired = requiresTarget && !scopeId;
|
||||
const hasSelectableTarget = targetOptions.length > 0;
|
||||
const activeScopeId = scopeId || selectedTargetId || null;
|
||||
const defaultDescription = scopeType === "system"
|
||||
? "System retention defaults and the fields lower levels may override."
|
||||
: "Local retention limits for this scope. Values inherit from the parent unless explicitly narrowed.";
|
||||
const targetEmptyText = `No ${pluralizeLabel(targetLabel.toLowerCase())} available`;
|
||||
const defaultDescription = scopeType === "system" ?
|
||||
"i18n:govoplan-core.system_retention_defaults_and_the_fields_lower_l.90a9e923" :
|
||||
"i18n:govoplan-core.local_retention_limits_for_this_scope_values_inh.6114dade";
|
||||
const targetEmptyText = i18nMessage("i18n:govoplan-core.no_value_available", { value0: targetPluralLabel(scopeType, targetLabel) });
|
||||
|
||||
useEffect(() => {
|
||||
if (scopeId) {
|
||||
@@ -131,44 +138,45 @@ export function RetentionPolicyScopeManager({
|
||||
|
||||
return (
|
||||
<div className="retention-policy-manager">
|
||||
{targetSelectionRequired && (
|
||||
<Card title={`${targetLabel} scope`}>
|
||||
{targetSelectionRequired &&
|
||||
<Card title={i18nMessage("i18n:govoplan-core.value_scope", { value0: targetLabel })}>
|
||||
<div className="retention-policy-target-row">
|
||||
<FormField label={targetLabel}>
|
||||
<select value={selectedTargetId} disabled={!hasSelectableTarget} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
{!hasSelectableTarget && <option value="">{targetEmptyText}</option>}
|
||||
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
|
||||
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? i18nMessage("i18n:govoplan-core.value_value.c189e8bc", { value0: option.label, value1: option.secondary }) : option.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{(!requiresTarget || Boolean(activeScopeId)) && (
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
scopeId={activeScopeId}
|
||||
title={title}
|
||||
description={description ?? defaultDescription}
|
||||
canWrite={canWrite}
|
||||
locked={locked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{(!requiresTarget || Boolean(activeScopeId)) &&
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
scopeId={activeScopeId}
|
||||
title={title}
|
||||
description={description ?? defaultDescription}
|
||||
canWrite={canWrite}
|
||||
locked={locked} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function pluralizeLabel(label: string): string {
|
||||
if (label.endsWith("s")) return label;
|
||||
if (label.endsWith("y")) return `${label.slice(0, -1)}ies`;
|
||||
return `${label}s`;
|
||||
function targetPluralLabel(scopeType: PrivacyRetentionPolicyScope, fallback: string): string {
|
||||
if (scopeType === "user") return "i18n:govoplan-core.users";
|
||||
if (scopeType === "group") return "i18n:govoplan-core.groups";
|
||||
if (scopeType === "campaign") return "i18n:govoplan-core.campaigns.01a23a28";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function RetentionPolicyEditor({
|
||||
settings,
|
||||
scopeType,
|
||||
scopeId = null,
|
||||
title = "Retention policy",
|
||||
title = "i18n:govoplan-core.retention_policy.962fb418",
|
||||
description,
|
||||
canWrite,
|
||||
locked = false
|
||||
@@ -177,6 +185,7 @@ export function RetentionPolicyEditor({
|
||||
const [effectivePolicy, setEffectivePolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||
const [parentPolicy, setParentPolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||
const [effectivePolicySources, setEffectivePolicySources] = useState<PolicySourcePathItem[]>([]);
|
||||
const [savedPolicyKey, setSavedPolicyKey] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -191,8 +200,18 @@ export function RetentionPolicyEditor({
|
||||
const activeParentPolicy = parentPolicy ? normalizeFullPolicy(parentPolicy) : null;
|
||||
const activeFullPolicy = normalizeFullPolicy({ ...defaultPrivacyPolicy, ...policy, allow_lower_level_limits: { ...defaultAllowLowerLevelLimits, ...(policy.allow_lower_level_limits ?? {}) } });
|
||||
const visibleFields = useMemo(() => fieldDefinitions.filter((field) => isSystem || !field.systemOnly), [isSystem]);
|
||||
const policyDirty = scopeReady && Boolean(savedPolicyKey) && policyDraftKey(policy, isSystem) !== savedPolicyKey;
|
||||
|
||||
useEffect(() => { void loadPolicy(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId]);
|
||||
useUnsavedDraftGuard({
|
||||
dirty: policyDirty,
|
||||
onSave: savePolicy,
|
||||
onDiscard: () => {
|
||||
if (!savedPolicyKey) return;
|
||||
setPolicy(JSON.parse(savedPolicyKey) as PrivacyRetentionPolicyPatch);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {void loadPolicy();}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId]);
|
||||
|
||||
async function loadPolicy() {
|
||||
setError("");
|
||||
@@ -207,12 +226,15 @@ export function RetentionPolicyEditor({
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getPrivacyRetentionPolicy(settings, scopeType, scopeId);
|
||||
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
||||
const loadedPolicy = isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy);
|
||||
setPolicy(loadedPolicy);
|
||||
setSavedPolicyKey(policyDraftKey(loadedPolicy, isSystem));
|
||||
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
||||
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
||||
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||
} catch (err) {
|
||||
setPolicy({});
|
||||
setSavedPolicyKey("");
|
||||
setEffectivePolicy(null);
|
||||
setParentPolicy(null);
|
||||
setEffectivePolicySources([]);
|
||||
@@ -222,21 +244,25 @@ export function RetentionPolicyEditor({
|
||||
}
|
||||
}
|
||||
|
||||
async function savePolicy() {
|
||||
if (!scopeReady) return;
|
||||
async function savePolicy(): Promise<boolean> {
|
||||
if (!scopeReady) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const payload = isSystem ? normalizeFullPolicy(policy) : normalizePatchForSave(policy, activeParentPolicy, scopeType);
|
||||
const response = await updatePrivacyRetentionPolicy(settings, scopeType, payload, scopeId);
|
||||
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
||||
const savedPolicy = isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy);
|
||||
setPolicy(savedPolicy);
|
||||
setSavedPolicyKey(policyDraftKey(savedPolicy, isSystem));
|
||||
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
||||
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
||||
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||
setSuccess("Retention policy saved.");
|
||||
setSuccess("i18n:govoplan-core.retention_policy_saved.eb577758");
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -260,7 +286,7 @@ export function RetentionPolicyEditor({
|
||||
|
||||
function setRetentionDays(key: DayKey, value: string) {
|
||||
const trimmed = value.trim();
|
||||
patchPolicy({ [key]: trimmed === "" ? (isSystem ? null : undefined) : Math.max(0, Number(trimmed) || 0) });
|
||||
patchPolicy({ [key]: trimmed === "" ? isSystem ? null : undefined : Math.max(0, Number(trimmed) || 0) });
|
||||
}
|
||||
|
||||
function setAuditDetail(value: AuditDetailValue) {
|
||||
@@ -272,84 +298,89 @@ export function RetentionPolicyEditor({
|
||||
}
|
||||
|
||||
function parentAllows(key: PrivacyRetentionPolicyFieldKey): boolean {
|
||||
return !activeParentPolicy || activeParentPolicy.allow_lower_level_limits[key] !== false;
|
||||
return privacyRetentionParentAllowsField(activeParentPolicy, key);
|
||||
}
|
||||
|
||||
function localAllowsLower(key: PrivacyRetentionPolicyFieldKey): boolean {
|
||||
if (isSystem) return activeFullPolicy.allow_lower_level_limits[key] !== false;
|
||||
const localValue = policy.allow_lower_level_limits?.[key];
|
||||
if (localValue !== undefined) return localValue && parentAllows(key);
|
||||
return parentAllows(key);
|
||||
return privacyRetentionLocalAllowsLowerLevel(isSystem ? activeFullPolicy : policy, activeParentPolicy, key, isSystem);
|
||||
}
|
||||
|
||||
const localPolicySummary = useMemo(() => isSystem ? "System baseline" : localPolicyCount(policy), [isSystem, policy]);
|
||||
const defaultDescription = isSystem
|
||||
? "Set concrete system retention values. Blank day fields mean unlimited retention."
|
||||
: "Blank values inherit. Numeric values may only shorten the inherited retention period; audit detail may only become more restrictive.";
|
||||
const localPolicySummary = useMemo(() => isSystem ? "i18n:govoplan-core.system_baseline.219ce334" : localPolicyCount(policy), [isSystem, policy]);
|
||||
const defaultDescription = isSystem ?
|
||||
"i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627" :
|
||||
"i18n:govoplan-core.blank_values_inherit_numeric_values_may_only_sho.1a96ce70";
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={title}
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "Loading…" : "Reload"}</Button>
|
||||
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "Saving…" : "Save policy"}</Button>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "i18n:govoplan-core.loading.33ce4174" : "i18n:govoplan-core.reload.cce71553"}</Button>
|
||||
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_policy.77d67ce3"}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
}>
|
||||
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-core.loading_retention_policy.dcd30fb6">
|
||||
<div className="retention-policy-editor">
|
||||
<p className="muted small-note retention-policy-description">{description ?? defaultDescription}</p>
|
||||
{!isSystem && <p className="muted small-note retention-policy-description">Mock mailbox retention is currently managed at system level because mock mailbox records do not carry tenant or campaign ownership metadata.</p>}
|
||||
{!isSystem && <p className="muted small-note retention-policy-description">i18n:govoplan-core.mock_mailbox_retention_is_currently_managed_at_s.8c6a8ec7</p>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
|
||||
<section className="retention-policy-section">
|
||||
<div className="subsection-heading split">
|
||||
<h3>{isSystem ? "System policy" : "Local policy"}</h3>
|
||||
<span className="muted small-note">{localPolicySummary}</span>
|
||||
</div>
|
||||
<div className={`retention-policy-table policy-table${showEffectiveColumn ? " with-effective-column" : ""}${showAllowColumn ? " with-allow-column" : ""}`}>
|
||||
<div className="retention-policy-row policy-row retention-policy-row-header policy-row-header">
|
||||
<span>Field</span>
|
||||
<span>{isSystem ? "Value" : "Local setting"}</span>
|
||||
{showEffectiveColumn && <span>Effective policy</span>}
|
||||
{showAllowColumn && <span>Lower levels</span>}
|
||||
</div>
|
||||
<PolicySection
|
||||
className="retention-policy-section"
|
||||
title={isSystem ? "i18n:govoplan-core.system_policy.3e1aac4b" : "i18n:govoplan-core.local_policy.942f8665"}
|
||||
summary={<span className="muted small-note">{localPolicySummary}</span>}>
|
||||
<PolicyTable
|
||||
className="retention-policy-table"
|
||||
rowClassName="retention-policy-row"
|
||||
headerClassName="retention-policy-row-header"
|
||||
fieldLabel="i18n:govoplan-core.field.c326a466"
|
||||
settingLabel={isSystem ? "i18n:govoplan-core.value.8dce170d" : "i18n:govoplan-core.local_setting.967607a9"}
|
||||
effectiveLabel="i18n:govoplan-core.effective_policy.feedb950"
|
||||
lowerLevelLabel="i18n:govoplan-core.lower_levels.940821ee"
|
||||
showEffectiveColumn={showEffectiveColumn}
|
||||
showAllowColumn={showAllowColumn}>
|
||||
{visibleFields.map((field) => {
|
||||
const fieldLocked = !parentAllows(field.key);
|
||||
const fieldDisabled = disabled || fieldLocked;
|
||||
return (
|
||||
<div className="retention-policy-row policy-row" key={field.key}>
|
||||
<div className="retention-policy-field-label policy-field-label">
|
||||
<strong>{field.label}</strong>
|
||||
{fieldLocked && <small>Locked by parent policy</small>}
|
||||
{field.systemOnly && <small>System-level cleanup scope</small>}
|
||||
</div>
|
||||
<div className="policy-control">{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</div>
|
||||
{showEffectiveColumn && (
|
||||
<div className="retention-policy-effective-cell policy-effective-cell">
|
||||
<FieldLabel className="retention-policy-effective-value policy-effective-value" help={retentionPolicyPathHelp(field, effectivePolicySources, scopeType)}>
|
||||
{retentionFieldValue(field, effectivePolicy, loading)}
|
||||
</FieldLabel>
|
||||
</div>
|
||||
)}
|
||||
{showAllowColumn && (
|
||||
<ToggleSwitch
|
||||
checked={localAllowsLower(field.key)}
|
||||
disabled={disabled || fieldLocked}
|
||||
onChange={(checked) => setAllowLowerLevelLimit(field.key, checked)}
|
||||
label="Allow override"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
<PolicyRow
|
||||
key={field.key}
|
||||
className="retention-policy-row"
|
||||
labelClassName="retention-policy-field-label"
|
||||
effectiveCellClassName="retention-policy-effective-cell"
|
||||
effectiveClassName="retention-policy-effective-value"
|
||||
label={field.label}
|
||||
note={
|
||||
<>
|
||||
{fieldLocked && <small>i18n:govoplan-core.locked_by_parent_policy.66f587c1</small>}
|
||||
{field.systemOnly && <small>i18n:govoplan-core.system_level_cleanup_scope.2bce57b5</small>}
|
||||
</>
|
||||
}
|
||||
control={renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}
|
||||
effective={showEffectiveColumn ? retentionFieldValue(field, effectivePolicy, loading) : undefined}
|
||||
effectiveHelp={showEffectiveColumn ? retentionPolicyPathHelp(field, effectivePolicySources, scopeType) : undefined}
|
||||
allowControl={showAllowColumn ?
|
||||
<ToggleSwitch
|
||||
checked={localAllowsLower(field.key)}
|
||||
disabled={disabled || fieldLocked}
|
||||
onChange={(checked) => setAllowLowerLevelLimit(field.key, checked)}
|
||||
label="i18n:govoplan-core.allow_override.ffa6e9a0" /> :
|
||||
undefined} />);
|
||||
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</PolicyTable>
|
||||
</PolicySection>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
</LoadingFrame>
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
function policyDraftKey(policy: PrivacyRetentionPolicyPatch, isSystem: boolean): string {
|
||||
return JSON.stringify(isSystem ? normalizeFullPolicy(policy) : normalizePatch(policy));
|
||||
}
|
||||
|
||||
type PolicySourceItem = {
|
||||
@@ -362,16 +393,16 @@ function retentionPolicyPathHelp(field: FieldDefinition, sources: PolicySourcePa
|
||||
return <PolicyPathHelp lines={retentionPolicyPathLines(field, items)} />;
|
||||
}
|
||||
|
||||
function PolicyPathHelp({ lines }: { lines: string[] }) {
|
||||
function PolicyPathHelp({ lines }: {lines: string[];}) {
|
||||
return (
|
||||
<span className="policy-path-help">
|
||||
{lines.map((line, index) => <span className="policy-path-help-line" key={`${line}-${index}`}>{line}</span>)}
|
||||
</span>
|
||||
);
|
||||
</span>);
|
||||
|
||||
}
|
||||
|
||||
function retentionPolicyPathLines(field: FieldDefinition, items: PolicySourceItem[]): string[] {
|
||||
if (items.length === 0) return ["System: Default"];
|
||||
if (items.length === 0) return ["i18n:govoplan-core.system_default.1f06f3ed"];
|
||||
const lines: string[] = [];
|
||||
for (const [index, item] of items.entries()) {
|
||||
const sourcePolicy = asRecord(item.policy);
|
||||
@@ -384,18 +415,18 @@ function retentionPolicyPathLines(field: FieldDefinition, items: PolicySourceIte
|
||||
}
|
||||
|
||||
function retentionSourceValue(field: FieldDefinition, sourcePolicy: Record<string, unknown>, isSystem: boolean): string {
|
||||
if (Object.keys(sourcePolicy).length === 0) return isSystem ? "Default" : "Inherit";
|
||||
if (!isSystem && !(field.key in sourcePolicy)) return "Inherit";
|
||||
if (Object.keys(sourcePolicy).length === 0) return isSystem ? "i18n:govoplan-core.default.808d7dca" : "i18n:govoplan-core.inherit.18f99833";
|
||||
if (!isSystem && !(field.key in sourcePolicy)) return "i18n:govoplan-core.inherit.18f99833";
|
||||
if (field.kind === "raw-json") {
|
||||
const value = sourcePolicy.store_raw_campaign_json;
|
||||
return value === false ? "Disabled" : value === true ? "Stored" : "Inherit";
|
||||
return value === false ? "i18n:govoplan-core.disabled.f4f4473d" : value === true ? "i18n:govoplan-core.stored.b2b53ee4" : "i18n:govoplan-core.inherit.18f99833";
|
||||
}
|
||||
if (field.kind === "audit") {
|
||||
const value = sourcePolicy.audit_detail_level;
|
||||
return value === "full" || value === "redacted" || value === "minimal" ? auditDetailLabel(value) : "Inherit";
|
||||
return value === "full" || value === "redacted" || value === "minimal" ? auditDetailLabel(value) : "i18n:govoplan-core.inherit.18f99833";
|
||||
}
|
||||
const value = sourcePolicy[field.key];
|
||||
return typeof value === "number" ? daysLabel(value) : value === null && isSystem ? daysLabel(null) : "Inherit";
|
||||
return typeof value === "number" ? daysLabel(value) : value === null && isSystem ? daysLabel(null) : "i18n:govoplan-core.inherit.18f99833";
|
||||
}
|
||||
|
||||
function normalizePolicySourceItems(items: PolicySourcePathItem[]): PolicySourceItem[] {
|
||||
@@ -414,23 +445,23 @@ function policyPathPrefix(index: number): string {
|
||||
}
|
||||
|
||||
function retentionFieldValue(field: FieldDefinition, policy: PrivacyRetentionPolicy | null, loading: boolean): string {
|
||||
if (!policy) return loading ? "Loading..." : "Unavailable";
|
||||
if (field.kind === "raw-json") return policy.store_raw_campaign_json ? "Stored" : "Disabled";
|
||||
if (!policy) return loading ? "i18n:govoplan-core.loading.b04ba49f" : "i18n:govoplan-core.unavailable.2c9c1f79";
|
||||
if (field.kind === "raw-json") return policy.store_raw_campaign_json ? "i18n:govoplan-core.stored.b2b53ee4" : "i18n:govoplan-core.disabled.f4f4473d";
|
||||
if (field.kind === "audit") return auditDetailLabel(policy.audit_detail_level);
|
||||
return daysLabel(policy[field.key as DayKey]);
|
||||
}
|
||||
|
||||
function renderFieldControl(
|
||||
field: FieldDefinition,
|
||||
fullPolicy: PrivacyRetentionPolicy,
|
||||
patchPolicy: PrivacyRetentionPolicyPatch,
|
||||
isSystem: boolean,
|
||||
disabled: boolean,
|
||||
setRawJson: (value: RawJsonValue | "store" | "disabled") => void,
|
||||
setRetentionDays: (key: DayKey, value: string) => void,
|
||||
setAuditDetail: (value: AuditDetailValue) => void,
|
||||
parentPolicy: PrivacyRetentionPolicy | null
|
||||
) {
|
||||
field: FieldDefinition,
|
||||
fullPolicy: PrivacyRetentionPolicy,
|
||||
patchPolicy: PrivacyRetentionPolicyPatch,
|
||||
isSystem: boolean,
|
||||
disabled: boolean,
|
||||
setRawJson: (value: RawJsonValue | "store" | "disabled") => void,
|
||||
setRetentionDays: (key: DayKey, value: string) => void,
|
||||
setAuditDetail: (value: AuditDetailValue) => void,
|
||||
parentPolicy: PrivacyRetentionPolicy | null)
|
||||
{
|
||||
if (field.kind === "raw-json") {
|
||||
if (isSystem) {
|
||||
return <RawJsonSystemSelect value={fullPolicy.store_raw_campaign_json ? "store" : "disabled"} disabled={disabled} onChange={setRawJson} />;
|
||||
@@ -445,33 +476,33 @@ function renderFieldControl(
|
||||
<RetentionDaysField
|
||||
value={isSystem ? fullPolicy[field.key as DayKey] : patchPolicy[field.key as DayKey]}
|
||||
disabled={disabled}
|
||||
placeholder={isSystem ? "Unlimited" : "Inherit"}
|
||||
placeholder={isSystem ? "i18n:govoplan-core.unlimited.b8bef37b" : "i18n:govoplan-core.inherit.18f99833"}
|
||||
max={parentDayLimit}
|
||||
onChange={(value) => setRetentionDays(field.key as DayKey, value)}
|
||||
/>
|
||||
);
|
||||
onChange={(value) => setRetentionDays(field.key as DayKey, value)} />);
|
||||
|
||||
|
||||
}
|
||||
|
||||
function RawJsonSystemSelect({ value, disabled, onChange }: { value: "store" | "disabled"; disabled: boolean; onChange: (value: "store" | "disabled") => void }) {
|
||||
function RawJsonSystemSelect({ value, disabled, onChange }: {value: "store" | "disabled";disabled: boolean;onChange: (value: "store" | "disabled") => void;}) {
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as "store" | "disabled")}>
|
||||
<option value="store">Store</option>
|
||||
<option value="disabled">Disable storage</option>
|
||||
</select>
|
||||
);
|
||||
<option value="store">i18n:govoplan-core.store.0d8a7046</option>
|
||||
<option value="disabled">i18n:govoplan-core.disable_storage.07c44d8b</option>
|
||||
</select>);
|
||||
|
||||
}
|
||||
|
||||
function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }: { value: RawJsonValue; parentStoresRawJson: boolean; disabled: boolean; onChange: (value: RawJsonValue) => void }) {
|
||||
function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }: {value: RawJsonValue;parentStoresRawJson: boolean;disabled: boolean;onChange: (value: RawJsonValue) => void;}) {
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as RawJsonValue)}>
|
||||
<option value="inherit">Inherit</option>
|
||||
<option value="keep" disabled={!parentStoresRawJson}>Store if parent allows</option>
|
||||
<option value="disable">Disable storage</option>
|
||||
</select>
|
||||
);
|
||||
<option value="inherit">i18n:govoplan-core.inherit.18f99833</option>
|
||||
<option value="keep" disabled={!parentStoresRawJson}>i18n:govoplan-core.store_if_parent_allows.36dcb07b</option>
|
||||
<option value="disable">i18n:govoplan-core.disable_storage.07c44d8b</option>
|
||||
</select>);
|
||||
|
||||
}
|
||||
|
||||
function RetentionDaysField({ value, disabled, placeholder, max, onChange }: { value?: number | null; disabled: boolean; placeholder: string; max?: number | null; onChange: (value: string) => void }) {
|
||||
function RetentionDaysField({ value, disabled, placeholder, max, onChange }: {value?: number | null;disabled: boolean;placeholder: string;max?: number | null;onChange: (value: string) => void;}) {
|
||||
function handleChange(nextValue: string) {
|
||||
const trimmed = nextValue.trim();
|
||||
if (trimmed === "" || max === null || max === undefined) {
|
||||
@@ -485,7 +516,7 @@ function RetentionDaysField({ value, disabled, placeholder, max, onChange }: { v
|
||||
return <input type="number" min={0} max={max ?? undefined} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => handleChange(event.target.value)} />;
|
||||
}
|
||||
|
||||
function AuditDetailSelect({ value, includeInherit, parentValue, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; parentValue?: PrivacyRetentionPolicy["audit_detail_level"] | null; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
|
||||
function AuditDetailSelect({ value, includeInherit, parentValue, disabled, onChange }: {value: AuditDetailValue;includeInherit: boolean;parentValue?: PrivacyRetentionPolicy["audit_detail_level"] | null;disabled: boolean;onChange: (value: AuditDetailValue) => void;}) {
|
||||
const minimumOrder = parentValue ? auditDetailOrder[parentValue] : 0;
|
||||
function optionDisabled(option: PrivacyRetentionPolicy["audit_detail_level"]): boolean {
|
||||
return auditDetailOrder[option] < minimumOrder;
|
||||
@@ -493,12 +524,12 @@ function AuditDetailSelect({ value, includeInherit, parentValue, disabled, onCha
|
||||
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as AuditDetailValue)}>
|
||||
{includeInherit && <option value="inherit">Inherit</option>}
|
||||
<option value="full" disabled={optionDisabled("full")}>Full</option>
|
||||
<option value="redacted" disabled={optionDisabled("redacted")}>Redacted</option>
|
||||
<option value="minimal" disabled={optionDisabled("minimal")}>Minimal</option>
|
||||
</select>
|
||||
);
|
||||
{includeInherit && <option value="inherit">i18n:govoplan-core.inherit.18f99833</option>}
|
||||
<option value="full" disabled={optionDisabled("full")}>i18n:govoplan-core.full.10b28a8c</option>
|
||||
<option value="redacted" disabled={optionDisabled("redacted")}>i18n:govoplan-core.redacted.8780785a</option>
|
||||
<option value="minimal" disabled={optionDisabled("minimal")}>i18n:govoplan-core.minimal.a711cca9</option>
|
||||
</select>);
|
||||
|
||||
}
|
||||
|
||||
function rawJsonValue(value: boolean | undefined): RawJsonValue {
|
||||
@@ -511,13 +542,13 @@ function auditDetailValue(value: PrivacyRetentionPolicyPatch["audit_detail_level
|
||||
}
|
||||
|
||||
function daysLabel(value?: number | null): string {
|
||||
return value === null || value === undefined ? "Unlimited" : `${value} day${value === 1 ? "" : "s"}`;
|
||||
return value === null || value === undefined ? "i18n:govoplan-core.unlimited.b8bef37b" : `${value} day${value === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function auditDetailLabel(value: PrivacyRetentionPolicy["audit_detail_level"]): string {
|
||||
if (value === "full") return "Full";
|
||||
if (value === "redacted") return "Redacted";
|
||||
return "Minimal";
|
||||
if (value === "full") return "i18n:govoplan-core.full.10b28a8c";
|
||||
if (value === "redacted") return "i18n:govoplan-core.redacted.8780785a";
|
||||
return "i18n:govoplan-core.minimal.a711cca9";
|
||||
}
|
||||
|
||||
function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
|
||||
@@ -528,15 +559,15 @@ function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
|
||||
}
|
||||
const allowCount = Object.keys(policy.allow_lower_level_limits ?? {}).length;
|
||||
count += allowCount;
|
||||
return count === 0 ? "Fully inherited" : `${count} local override${count === 1 ? "" : "s"}`;
|
||||
return count === 0 ? "i18n:govoplan-core.fully_inherited.5e48132a" : `${count} local override${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function retentionPolicySourcePath(scopeType: PrivacyRetentionPolicyScope): string[] {
|
||||
if (scopeType === "system") return ["System"];
|
||||
if (scopeType === "tenant") return ["System", "Tenant"];
|
||||
if (scopeType === "user") return ["System", "Tenant", "User"];
|
||||
if (scopeType === "group") return ["System", "Tenant", "Group"];
|
||||
return ["System", "Tenant", "Owner policy", "Campaign"];
|
||||
if (scopeType === "system") return ["i18n:govoplan-core.system.bc0792d8"];
|
||||
if (scopeType === "tenant") return ["i18n:govoplan-core.system.bc0792d8", "i18n:govoplan-core.tenant.3ca93c78"];
|
||||
if (scopeType === "user") return ["i18n:govoplan-core.system.bc0792d8", "i18n:govoplan-core.tenant.3ca93c78", "i18n:govoplan-core.user.9f8a2389"];
|
||||
if (scopeType === "group") return ["i18n:govoplan-core.system.bc0792d8", "i18n:govoplan-core.tenant.3ca93c78", "i18n:govoplan-core.group.171a0606"];
|
||||
return ["i18n:govoplan-core.system.bc0792d8", "i18n:govoplan-core.tenant.3ca93c78", "i18n:govoplan-core.owner_policy.1e8df143", "i18n:govoplan-core.campaign.69390e16"];
|
||||
}
|
||||
|
||||
function normalizeAllowLimits(value: PrivacyRetentionLimitPermissionPatch | PrivacyRetentionLimitPermissions | null | undefined): PrivacyRetentionLimitPermissions {
|
||||
@@ -581,12 +612,12 @@ function normalizePatchForSave(policy: PrivacyRetentionPolicyPatch, parentPolicy
|
||||
delete allowPatch[field.key];
|
||||
}
|
||||
}
|
||||
if (Object.keys(allowPatch).length > 0) normalized.allow_lower_level_limits = allowPatch;
|
||||
else delete normalized.allow_lower_level_limits;
|
||||
if (Object.keys(allowPatch).length > 0) normalized.allow_lower_level_limits = allowPatch;else
|
||||
delete normalized.allow_lower_level_limits;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return "Request failed";
|
||||
return "i18n:govoplan-core.request_failed.9fcda32c";
|
||||
}
|
||||
|
||||
29
webui/src/features/privacy/policyLogic.ts
Normal file
29
webui/src/features/privacy/policyLogic.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type PrivacyRetentionPolicyFieldKey =
|
||||
| "store_raw_campaign_json"
|
||||
| "raw_campaign_json_retention_days"
|
||||
| "generated_eml_retention_days"
|
||||
| "stored_report_detail_retention_days"
|
||||
| "mock_mailbox_retention_days"
|
||||
| "audit_detail_retention_days"
|
||||
| "audit_detail_level";
|
||||
|
||||
export type RetentionPolicyLimitCarrier = {
|
||||
allow_lower_level_limits?: Partial<Record<PrivacyRetentionPolicyFieldKey, boolean>> | null;
|
||||
};
|
||||
|
||||
export function privacyRetentionParentAllowsField(parentPolicy: RetentionPolicyLimitCarrier | null, key: PrivacyRetentionPolicyFieldKey): boolean {
|
||||
return !parentPolicy || parentPolicy.allow_lower_level_limits?.[key] !== false;
|
||||
}
|
||||
|
||||
export function privacyRetentionLocalAllowsLowerLevel(
|
||||
policy: RetentionPolicyLimitCarrier,
|
||||
parentPolicy: RetentionPolicyLimitCarrier | null,
|
||||
key: PrivacyRetentionPolicyFieldKey,
|
||||
isSystem: boolean
|
||||
): boolean {
|
||||
const localLimits = policy.allow_lower_level_limits ?? {};
|
||||
if (isSystem) return localLimits[key] !== false;
|
||||
const localValue = localLimits[key];
|
||||
if (localValue !== undefined) return localValue && privacyRetentionParentAllowsField(parentPolicy, key);
|
||||
return privacyRetentionParentAllowsField(parentPolicy, key);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo, MailProfilesUiCapability } from "../../types";
|
||||
import type { ApiSettings, AuthInfo, FilesConnectorsUiCapability, MailProfilesUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
@@ -11,30 +11,47 @@ import { apiFetch } from "../../api/client";
|
||||
import { updateProfile } from "../../api/auth";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type SettingsSection = "profile" | "mail-profiles" | "interface" | "workspace" | "local-connection" | "notifications";
|
||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | "notifications";
|
||||
|
||||
function settingsGroups(canUseMailProfiles: boolean): ModuleSubnavGroup<SettingsSection>[] {
|
||||
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
||||
compact_tables: false,
|
||||
show_inline_help_hints: true,
|
||||
reduce_motion: false,
|
||||
sticky_section_sidebars: true,
|
||||
theme: "system"
|
||||
};
|
||||
|
||||
const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
|
||||
{ value: "system", label: "i18n:govoplan-core.system_default.1f06f3ed" },
|
||||
{ value: "light", label: "i18n:govoplan-core.light_theme.7878f1fa" },
|
||||
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||
];
|
||||
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean): ModuleSubnavGroup<SettingsSection>[] {
|
||||
return [
|
||||
{
|
||||
title: "ACCOUNT",
|
||||
items: [
|
||||
{ id: "profile", label: "My profile" },
|
||||
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "Mail profiles" }] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "UI SETTINGS",
|
||||
items: [
|
||||
{ id: "interface", label: "Interface" },
|
||||
{ id: "workspace", label: "Workspace" },
|
||||
{ id: "local-connection", label: "Local connection" },
|
||||
{ id: "notifications", label: "Notifications" }
|
||||
]
|
||||
}
|
||||
];
|
||||
{
|
||||
title: "i18n:govoplan-core.account.f967543b",
|
||||
items: [
|
||||
{ id: "profile", label: "i18n:govoplan-core.my_profile.2f3df0a9" },
|
||||
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "i18n:govoplan-core.mail_profiles.8a8018b7" }] : []),
|
||||
...(canUseFileConnectors ? [{ id: "file-connectors" as const, label: "i18n:govoplan-core.file_connections.1e362326" }] : [])]
|
||||
|
||||
},
|
||||
{
|
||||
title: "i18n:govoplan-core.ui_settings.9e9cc5ea",
|
||||
items: [
|
||||
{ id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" },
|
||||
{ id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" },
|
||||
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" },
|
||||
{ id: "notifications", label: "i18n:govoplan-core.notifications.753a22b2" }]
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
export default function SettingsPage({
|
||||
@@ -42,49 +59,101 @@ export default function SettingsPage({
|
||||
auth,
|
||||
onSettingsChange,
|
||||
onAuthChange
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onSettingsChange: (settings: ApiSettings) => void;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles), [canUseMailProfiles]);
|
||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors), [canUseFileConnectors, canUseMailProfiles]);
|
||||
const requestedSection = searchParams.get("section") as SettingsSection | null;
|
||||
const [active, setActive] = useState<SettingsSection>(settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface");
|
||||
const active: SettingsSection = settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface";
|
||||
const currentUiPreferences = normalizeUiPreferences(auth.user.ui_preferences);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState("");
|
||||
const [compactTables, setCompactTables] = useState(false);
|
||||
const [showHelpHints, setShowHelpHints] = useState(true);
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
const [stickySections, setStickySections] = useState(true);
|
||||
const [testResultTone, setTestResultTone] = useState<"success" | "warning">("success");
|
||||
const [compactTables, setCompactTables] = useState(currentUiPreferences.compact_tables);
|
||||
const [showHelpHints, setShowHelpHints] = useState(currentUiPreferences.show_inline_help_hints);
|
||||
const [reduceMotion, setReduceMotion] = useState(currentUiPreferences.reduce_motion);
|
||||
const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars);
|
||||
const [theme, setTheme] = useState<UserUiTheme>(currentUiPreferences.theme);
|
||||
const [uiBusy, setUiBusy] = useState(false);
|
||||
const [uiResult, setUiResult] = useState("");
|
||||
const [uiResultTone, setUiResultTone] = useState<"success" | "warning">("success");
|
||||
const [profileName, setProfileName] = useState(auth.user.display_name || "");
|
||||
const [tenantProfileName, setTenantProfileName] = useState(auth.user.tenant_display_name || "");
|
||||
const [profileBusy, setProfileBusy] = useState(false);
|
||||
const [profileResult, setProfileResult] = useState("");
|
||||
const [profileResultTone, setProfileResultTone] = useState<"success" | "warning">("success");
|
||||
const profileDirty = profileName !== (auth.user.display_name || "") || tenantProfileName !== (auth.user.tenant_display_name || "");
|
||||
const uiDirty =
|
||||
compactTables !== currentUiPreferences.compact_tables ||
|
||||
showHelpHints !== currentUiPreferences.show_inline_help_hints ||
|
||||
reduceMotion !== currentUiPreferences.reduce_motion ||
|
||||
stickySections !== currentUiPreferences.sticky_section_sidebars ||
|
||||
theme !== currentUiPreferences.theme;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: active === "profile" && profileDirty,
|
||||
onSave: saveProfile,
|
||||
onDiscard: () => {
|
||||
setProfileName(auth.user.display_name || "");
|
||||
setTenantProfileName(auth.user.tenant_display_name || "");
|
||||
}
|
||||
});
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: (active === "interface" || active === "workspace") && uiDirty,
|
||||
onSave: saveUiPreferences,
|
||||
onDiscard: resetUiPreferences
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsSectionAvailable(settingsSubnav, requestedSection)) {
|
||||
setActive(requestedSection);
|
||||
} else if (!settingsSectionAvailable(settingsSubnav, active)) {
|
||||
setActive("interface");
|
||||
if (requestedSection && !settingsSectionAvailable(settingsSubnav, requestedSection)) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", "interface");
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
}, [active, requestedSection, settingsSubnav]);
|
||||
}, [requestedSection, searchParams, setSearchParams, settingsSubnav]);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileName(auth.user.display_name || "");
|
||||
setTenantProfileName(auth.user.tenant_display_name || "");
|
||||
}, [auth.user.display_name, auth.user.tenant_display_name]);
|
||||
|
||||
useEffect(() => {
|
||||
setCompactTables(currentUiPreferences.compact_tables);
|
||||
setShowHelpHints(currentUiPreferences.show_inline_help_hints);
|
||||
setReduceMotion(currentUiPreferences.reduce_motion);
|
||||
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
||||
setTheme(currentUiPreferences.theme);
|
||||
}, [
|
||||
currentUiPreferences.compact_tables,
|
||||
currentUiPreferences.show_inline_help_hints,
|
||||
currentUiPreferences.reduce_motion,
|
||||
currentUiPreferences.sticky_section_sidebars,
|
||||
currentUiPreferences.theme
|
||||
]);
|
||||
|
||||
function selectSection(section: SettingsSection) {
|
||||
setActive(section);
|
||||
setSearchParams(section === "interface" ? {} : { section });
|
||||
if (section === active) return;
|
||||
requestNavigation(() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", section);
|
||||
setSearchParams(next);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
async function saveProfile(): Promise<boolean> {
|
||||
setProfileBusy(true);
|
||||
setProfileResult("");
|
||||
try {
|
||||
@@ -93,21 +162,63 @@ export default function SettingsPage({
|
||||
tenant_display_name: tenantProfileName.trim() || null
|
||||
});
|
||||
onAuthChange(next);
|
||||
setProfileResult("Profile saved. The account menu has been updated.");
|
||||
setProfileResultTone("success");
|
||||
setProfileResult("i18n:govoplan-core.profile_saved_the_account_menu_has_been_updated.aee56076");
|
||||
return true;
|
||||
} catch (err) {
|
||||
setProfileResultTone("warning");
|
||||
setProfileResult(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setProfileBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetUiPreferences() {
|
||||
setCompactTables(currentUiPreferences.compact_tables);
|
||||
setShowHelpHints(currentUiPreferences.show_inline_help_hints);
|
||||
setReduceMotion(currentUiPreferences.reduce_motion);
|
||||
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
||||
setTheme(currentUiPreferences.theme);
|
||||
}
|
||||
|
||||
function uiPreferencePayload(): UserUiPreferences {
|
||||
return {
|
||||
compact_tables: compactTables,
|
||||
show_inline_help_hints: showHelpHints,
|
||||
reduce_motion: reduceMotion,
|
||||
sticky_section_sidebars: stickySections,
|
||||
theme
|
||||
};
|
||||
}
|
||||
|
||||
async function saveUiPreferences(): Promise<boolean> {
|
||||
setUiBusy(true);
|
||||
setUiResult("");
|
||||
try {
|
||||
const next = await updateProfile(settings, { ui_preferences: uiPreferencePayload() });
|
||||
onAuthChange(next);
|
||||
setUiResultTone("success");
|
||||
setUiResult("i18n:govoplan-core.preferences_saved.c8cd3501");
|
||||
return true;
|
||||
} catch (err) {
|
||||
setUiResultTone("warning");
|
||||
setUiResult(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setUiBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setTesting(true);
|
||||
setTestResult("");
|
||||
try {
|
||||
await apiFetch<unknown>(settings, "/health");
|
||||
setTestResult("Connection successful. The backend health endpoint responded.");
|
||||
setTestResultTone("success");
|
||||
setTestResult("i18n:govoplan-core.connection_successful_the_backend_health_endpoin.14eb9295");
|
||||
} catch (err) {
|
||||
setTestResultTone("warning");
|
||||
setTestResult(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
@@ -121,177 +232,228 @@ export default function SettingsPage({
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Settings</PageTitle>
|
||||
<p>Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.</p>
|
||||
<PageTitle>i18n:govoplan-core.settings.c7f73bb5</PageTitle>
|
||||
<p>i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active === "profile" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="My profile">
|
||||
{active === "profile" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.my_profile.2f3df0a9">
|
||||
<div className="form-grid">
|
||||
<FormField label="Account display name" help="This global name is shown in the title bar and follows you across tenant memberships.">
|
||||
<FormField label="i18n:govoplan-core.account_display_name.03ed8fc5" help="i18n:govoplan-core.this_global_name_is_shown_in_the_title_bar_and_f.1d46240e">
|
||||
<input value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder={auth.user.email} />
|
||||
</FormField>
|
||||
<FormField label="Tenant display name" help="Optional local alias for the active tenant. It does not replace the global account name in the title bar.">
|
||||
<input value={tenantProfileName} onChange={(event) => setTenantProfileName(event.target.value)} placeholder="Use account display name" />
|
||||
<FormField label="i18n:govoplan-core.tenant_display_name.9ae4495e" help="i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14">
|
||||
<input value={tenantProfileName} onChange={(event) => setTenantProfileName(event.target.value)} placeholder="i18n:govoplan-core.use_account_display_name.570926b0" />
|
||||
</FormField>
|
||||
<FormField label="Email">
|
||||
<FormField label="i18n:govoplan-core.email.84add5b2">
|
||||
<input value={auth.user.email} disabled />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => void saveProfile()} disabled={profileBusy}>{profileBusy ? "Saving…" : "Save profile"}</Button>
|
||||
<Button variant="primary" onClick={() => void saveProfile()} disabled={profileBusy}>{profileBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_profile.f597c0e8"}</Button>
|
||||
</div>
|
||||
{profileResult && <DismissibleAlert tone={profileResult.startsWith("Profile saved") ? "success" : "warning"} resetKey={profileResult} floating>{profileResult}</DismissibleAlert>}
|
||||
{profileResult && <DismissibleAlert tone={profileResultTone} resetKey={profileResult} floating>{profileResult}</DismissibleAlert>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Account context">
|
||||
<Card title="i18n:govoplan-core.account_context.5bb0a918">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Account ID</dt><dd>{auth.user.account_id}</dd></div>
|
||||
<div><dt>Active tenant</dt><dd>{auth.active_tenant?.name || auth.tenant.name}</dd></div>
|
||||
<div><dt>Tenant roles</dt><dd>{auth.roles.filter((role) => role.level !== "system").map((role) => role.name).join(", ") || "None"}</dd></div>
|
||||
<div><dt>System roles</dt><dd>{auth.roles.filter((role) => role.level === "system").map((role) => role.name).join(", ") || "None"}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.account_id.c5e0db28</dt><dd>{auth.user.account_id}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.active_tenant.39b16ee9</dt><dd>{auth.active_tenant?.name || auth.tenant.name}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.tenant_roles.51aca82d</dt><dd>{auth.roles.filter((role) => role.level !== "system").map((role) => role.name).join(", ") || "i18n:govoplan-core.none.6eef6648"}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.system_roles.a9461aa6</dt><dd>{auth.roles.filter((role) => role.level === "system").map((role) => role.name).join(", ") || "i18n:govoplan-core.none.6eef6648"}</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">Email changes and password management require dedicated identity-verification workflows and are intentionally not part of this first self-service profile editor.</p>
|
||||
<p className="muted small-note">i18n:govoplan-core.email_changes_and_password_management_require_de.f7443b69</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
{active === "mail-profiles" && MailProfileScopeManager && (
|
||||
<MailProfileScopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
scopeId={auth.user.id}
|
||||
profileTitle="My mail server profiles"
|
||||
policyTitle="My mail profile policy"
|
||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])}
|
||||
/>
|
||||
)}
|
||||
{active === "mail-profiles" && MailProfileScopeManager &&
|
||||
<MailProfileScopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
scopeId={auth.user.id}
|
||||
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
||||
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
||||
|
||||
{active === "interface" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Interface preferences">
|
||||
}
|
||||
|
||||
{active === "file-connectors" && FileConnectorScopeManager &&
|
||||
<FileConnectorScopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
scopeId={auth.user.id}
|
||||
title="i18n:govoplan-core.my_file_connections.00252f2a"
|
||||
canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />
|
||||
|
||||
}
|
||||
|
||||
{active === "interface" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.interface_preferences.b82b39a7">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Compact tables"
|
||||
help="Prepared UI preference for denser tables. The current table layout remains unchanged until this is wired globally."
|
||||
checked={compactTables}
|
||||
onChange={setCompactTables}
|
||||
/>
|
||||
label="i18n:govoplan-core.compact_tables.c755d9ee"
|
||||
help="i18n:govoplan-core.prepared_ui_preference_for_denser_tables_the_cur.45698d83"
|
||||
checked={compactTables}
|
||||
onChange={setCompactTables} />
|
||||
|
||||
<ToggleSwitch
|
||||
label="Show inline help hints"
|
||||
help="Controls contextual UI help markers once persisted user preferences are available."
|
||||
checked={showHelpHints}
|
||||
onChange={setShowHelpHints}
|
||||
/>
|
||||
label="i18n:govoplan-core.show_inline_help_hints.47cf5aaa"
|
||||
help="i18n:govoplan-core.controls_contextual_ui_help_markers_once_persist.0eaef9c4"
|
||||
checked={showHelpHints}
|
||||
onChange={setShowHelpHints} />
|
||||
|
||||
<ToggleSwitch
|
||||
label="Reduce motion"
|
||||
help="Prepared preference for users who prefer fewer animations."
|
||||
checked={reduceMotion}
|
||||
onChange={setReduceMotion}
|
||||
/>
|
||||
label="i18n:govoplan-core.reduce_motion.25a5aef5"
|
||||
help="i18n:govoplan-core.prepared_preference_for_users_who_prefer_fewer_a.b288e8ab"
|
||||
checked={reduceMotion}
|
||||
onChange={setReduceMotion} />
|
||||
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => void saveUiPreferences()} disabled={uiBusy || !uiDirty}>{uiBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_preferences.0f1a7e44"}</Button>
|
||||
</div>
|
||||
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Theme and language">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Theme</dt><dd>System default for now</dd></div>
|
||||
<div><dt>Accent color</dt><dd>Default brand accent</dd></div>
|
||||
<div><dt>Language</dt><dd>Browser/application default</dd></div>
|
||||
<div><dt>Density</dt><dd>{compactTables ? "Compact preview" : "Comfortable"}</dd></div>
|
||||
</dl>
|
||||
<Card title="i18n:govoplan-core.theme_and_language.60a8cf59">
|
||||
<div className="form-grid">
|
||||
<FormField label="i18n:govoplan-core.language.89b86ab0">
|
||||
<select value={language} onChange={(event) => setLanguage(event.target.value)} disabled={selectableLanguages.length <= 1}>
|
||||
{selectableLanguages.map((item) =>
|
||||
<option key={item.code} value={item.code}>{item.code.toUpperCase()} - {item.nativeLabel || item.label}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-core.theme.a797e309">
|
||||
<select value={theme} onChange={(event) => setTheme(event.target.value as UserUiTheme)}>
|
||||
{UI_THEME_OPTIONS.map((item) =>
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div>
|
||||
<div><dt>i18n:govoplan-core.language.89b86ab0</dt><dd>{languageLabel}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.enabled.df174a3f</dt><dd>{enabledLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.available.7c62a142</dt><dd>{availableLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.density.f9160c22</dt><dd>{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
{active === "workspace" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Campaign workspace">
|
||||
{active === "workspace" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.campaign_workspace.c345580f">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Sticky section sidebars"
|
||||
help="Keeps campaign and admin section navigation in view while scrolling."
|
||||
checked={stickySections}
|
||||
onChange={setStickySections}
|
||||
/>
|
||||
label="i18n:govoplan-core.sticky_section_sidebars.399c92d4"
|
||||
help="i18n:govoplan-core.keeps_campaign_and_admin_section_navigation_in_v.f3602938"
|
||||
checked={stickySections}
|
||||
onChange={setStickySections} />
|
||||
|
||||
<ToggleSwitch
|
||||
label="Keep page shell visible while loading"
|
||||
help="The current UI already keeps the section shell visible and overlays loading indicators during refresh."
|
||||
checked
|
||||
disabled
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
label="i18n:govoplan-core.keep_page_shell_visible_while_loading.17fc142a"
|
||||
help="i18n:govoplan-core.the_current_ui_already_keeps_the_section_shell_v.c9bbc227"
|
||||
checked
|
||||
disabled
|
||||
onChange={() => undefined} />
|
||||
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => void saveUiPreferences()} disabled={uiBusy || !uiDirty}>{uiBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_preferences.0f1a7e44"}</Button>
|
||||
</div>
|
||||
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Editor behavior">
|
||||
<Card title="i18n:govoplan-core.editor_behavior.99581bc1">
|
||||
<div className="placeholder-stack">
|
||||
<span>Manual save with unsaved-change guard</span>
|
||||
<span>Readable chooser fields for file/path selection</span>
|
||||
<span>Field-type-aware recipient data inputs</span>
|
||||
<span>Template placeholder chips and preview overlays</span>
|
||||
<span>i18n:govoplan-core.manual_save_with_unsaved_change_guard.ef42e803</span>
|
||||
<span>i18n:govoplan-core.readable_chooser_fields_for_file_path_selection.d3aee1a7</span>
|
||||
<span>i18n:govoplan-core.field_type_aware_recipient_data_inputs.2892f175</span>
|
||||
<span>i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
{active === "local-connection" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Local API connection">
|
||||
{active === "local-connection" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.local_api_connection.bfb02921">
|
||||
<div className="form-grid">
|
||||
<FormField label="API base URL" help="Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend.">
|
||||
<FormField label="i18n:govoplan-core.api_base_url.1358fba4" help="i18n:govoplan-core.leave_empty_to_use_the_same_origin_in_vite_dev_a.9a1c25d7">
|
||||
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
|
||||
</FormField>
|
||||
<FormField label="Automation API key" help="Used only when there is no browser session token. Browser login remains the preferred interactive mode.">
|
||||
<FormField label="i18n:govoplan-core.automation_api_key.5d4e2e6e" help="i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70">
|
||||
<PasswordField
|
||||
value={settings.apiKey}
|
||||
autoComplete="off"
|
||||
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })}
|
||||
/>
|
||||
value={settings.apiKey}
|
||||
autoComplete="off"
|
||||
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })} />
|
||||
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
|
||||
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "i18n:govoplan-core.testing.95c4564a" : "i18n:govoplan-core.test_connection.ccf66f07"}</Button>
|
||||
</div>
|
||||
{testResult && <DismissibleAlert tone={testResult.startsWith("Connection successful") ? "success" : "warning"} resetKey={testResult} floating>{testResult}</DismissibleAlert>}
|
||||
{testResult && <DismissibleAlert tone={testResultTone} resetKey={testResult} floating>{testResult}</DismissibleAlert>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Session state">
|
||||
<Card title="i18n:govoplan-core.session_state.b7a3d0f4">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Browser session</dt><dd>Active via HttpOnly cookie</dd></div>
|
||||
<div><dt>Automation key</dt><dd>{settings.apiKey ? "Configured" : "Not configured"}</dd></div>
|
||||
<div><dt>Backend mode</dt><dd>{settings.apiBaseUrl ? "Explicit API URL" : "Same-origin / proxied"}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.browser_session.cc021a31</dt><dd>i18n:govoplan-core.active_via_httponly_cookie.9d05e6ba</dd></div>
|
||||
<div><dt>i18n:govoplan-core.automation_key.78f1ddbc</dt><dd>{settings.apiKey ? "i18n:govoplan-core.configured.668c5fff" : "i18n:govoplan-core.not_configured.811931bb"}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.backend_mode.c863723a</dt><dd>{settings.apiBaseUrl ? "i18n:govoplan-core.explicit_api_url.b117b4b8" : "i18n:govoplan-core.same_origin_proxied.c39e6e2b"}</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">Tenant, user, mail-server and policy administration has moved to Admin. This page keeps browser-local configuration.</p>
|
||||
<p className="muted small-note">i18n:govoplan-core.tenant_user_mail_server_and_policy_administratio.2f551271</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
{active === "notifications" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Notification preferences">
|
||||
<p className="muted">Prepared for later personal notification preferences. Backend and browser notification wiring are not active yet.</p>
|
||||
{active === "notifications" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.notification_preferences.0ead6c12">
|
||||
<p className="muted">i18n:govoplan-core.prepared_for_later_personal_notification_prefere.3fe73f86</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>In-app completion notices</span>
|
||||
<span>Email summary preferences</span>
|
||||
<span>Failure and warning alerts</span>
|
||||
<span>i18n:govoplan-core.in_app_completion_notices.b68f2f4c</span>
|
||||
<span>i18n:govoplan-core.email_summary_preferences.b6c1dfb1</span>
|
||||
<span>i18n:govoplan-core.failure_and_warning_alerts.939d21c2</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Quiet UI mode">
|
||||
<Card title="i18n:govoplan-core.quiet_ui_mode.1b0bd558">
|
||||
<div className="placeholder-stack">
|
||||
<span>Mute non-critical banners</span>
|
||||
<span>Batch repetitive notices</span>
|
||||
<span>Keep validation and send warnings visible</span>
|
||||
<span>i18n:govoplan-core.mute_non_critical_banners.27b23a4a</span>
|
||||
<span>i18n:govoplan-core.batch_repetitive_notices.cc893559</span>
|
||||
<span>i18n:govoplan-core.keep_validation_and_send_warnings_visible.9d6e0cf4</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
|
||||
function settingsSectionAvailable(groups: ModuleSubnavGroup<SettingsSection>[], section: SettingsSection | null | undefined): section is SettingsSection {
|
||||
return Boolean(section && groups.some((group) => group.items.some((item) => "id" in item && item.id === section)));
|
||||
}
|
||||
|
||||
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
|
||||
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
|
||||
return {
|
||||
compact_tables: Boolean(value?.compact_tables ?? DEFAULT_UI_PREFERENCES.compact_tables),
|
||||
show_inline_help_hints: Boolean(value?.show_inline_help_hints ?? DEFAULT_UI_PREFERENCES.show_inline_help_hints),
|
||||
reduce_motion: Boolean(value?.reduce_motion ?? DEFAULT_UI_PREFERENCES.reduce_motion),
|
||||
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
|
||||
theme
|
||||
};
|
||||
}
|
||||
|
||||
function themeLabel(value: UserUiTheme): string {
|
||||
return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user