Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 15794e920e
commit 02564047e9
73 changed files with 4073 additions and 478 deletions

View File

@@ -44,7 +44,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
{hasSystemArea(availableSections) && <Card title="System administration">
<div className="admin-overview-grid">
{availableSections.has("system-settings") && <AreaLink title="Settings" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level limiting permissions." onClick={() => onSelect("system-retention")} />}
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
{availableSections.has("system-groups") && <AreaLink title="Central groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings, AuthInfo } from "../../types";
import type { ApiSettings, AuthInfo, MailProfilesUiCapability } from "../../types";
import { fetchMe } from "../../api/auth";
import Card from "../../components/Card";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
@@ -19,6 +19,7 @@ import AdminAuditPanel from "./AdminAuditPanel";
import MailProfilesPanel from "./MailProfilesPanel";
import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
import PageTitle from "../../components/PageTitle";
import { usePlatformUiCapability } from "../../platform/ModuleContext";
type AdminSection =
| "overview"
@@ -53,12 +54,15 @@ export default function AdminPage({
auth: AuthInfo;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
}) {
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const mailProfilesAvailable = Boolean(mailProfilesUi);
const available = useMemo(() => {
const sections = new Set<AdminSection>(["overview"]);
if (hasScope(auth, "system:settings:read")) {
sections.add("system-settings");
sections.add("system-retention");
sections.add("system-mail-servers");
if (mailProfilesAvailable) sections.add("system-mail-servers");
}
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
@@ -70,7 +74,7 @@ export default function AdminPage({
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups");
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
if (hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
sections.add("tenant-mail-servers");
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-mail-servers");
@@ -83,7 +87,7 @@ export default function AdminPage({
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
if (hasScope(auth, "audit:read")) sections.add("tenant-audit");
return sections;
}, [auth]);
}, [auth, mailProfilesAvailable]);
const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section") as AdminSection | null;
const active: AdminSection = requestedSection && available.has(requestedSection) ? requestedSection : "overview";

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import type { ApiSettings } from "../../types";
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "../../types";
import { fetchGroups, fetchUsers } from "../../api/admin";
import type { MailProfileScope } from "@govoplan/mail-webui";
import { MailProfileScopeManager, type MailProfileTargetOption } from "@govoplan/mail-webui";
import Card from "../../components/Card";
import AdminPageLayout from "./components/AdminPageLayout";
import { adminErrorMessage } from "./adminUtils";
import { usePlatformUiCapability } from "../../platform/ModuleContext";
type Props = {
settings: ApiSettings;
@@ -44,11 +44,21 @@ const copy: Record<Props["scopeType"], { title: string; description: string; tar
};
export default function MailProfilesPanel({ settings, scopeType, canWriteProfiles, canManageCredentials, canWritePolicy }: Props) {
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const [targets, setTargets] = useState<MailProfileTargetOption[]>([]);
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
const [loadingTargets, setLoadingTargets] = useState(Boolean(MailProfileScopeManager) && (scopeType === "user" || scopeType === "group"));
const [targetError, setTargetError] = useState("");
useEffect(() => { void loadTargets(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType]);
useEffect(() => {
if (!MailProfileScopeManager) {
setTargets([]);
setLoadingTargets(false);
setTargetError("");
return;
}
void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager]);
async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") {
@@ -85,6 +95,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
const labels = copy[scopeType];
if (!MailProfileScopeManager) {
return (
<AdminPageLayout title={labels.title} description={labels.description}>
<Card title="Mail module unavailable">
<p className="muted">Install and enable the Mail module to manage mail server profiles and profile policies.</p>
</Card>
</AdminPageLayout>
);
}
return (
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError}>
<MailProfileScopeManager

View File

@@ -17,29 +17,29 @@ type Props = {
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
system: {
title: "System retention",
description: "Instance-wide privacy retention policy and lower-level limiting permissions.",
description: "Instance-wide privacy retention policy and lower-level override permissions.",
policyTitle: "System retention policy",
policyDescription: "Set concrete system retention values. The Allow limiting toggles decide which fields tenants, owners and campaigns may restrict further."
policyDescription: "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override."
},
tenant: {
title: "Tenant retention",
description: "Tenant-level privacy and retention limits for the active tenant.",
policyTitle: "Tenant retention policy",
policyDescription: "Tenant limits may only narrow the system policy. The Allow limiting toggles decide which fields users, groups and campaigns may restrict further."
policyDescription: "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override."
},
user: {
title: "User retention",
description: "User-scoped retention limits for campaigns owned by users in the active tenant.",
targetLabel: "User",
policyTitle: "User retention policy",
policyDescription: "User limits may only narrow inherited system and tenant policy. The Allow limiting toggles decide which fields user-owned campaigns may restrict further."
policyDescription: "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override."
},
group: {
title: "Group retention",
description: "Group-scoped retention limits for group-owned campaigns in the active tenant.",
targetLabel: "Group",
policyTitle: "Group retention policy",
policyDescription: "Group limits may only narrow inherited system and tenant policy. The Allow limiting toggles decide which fields group-owned campaigns may restrict further."
policyDescription: "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override."
}
};

View File

@@ -9,11 +9,15 @@ import DismissibleAlert from "../../components/DismissibleAlert";
export default function LoginModal({
settings,
onClose,
onLogin
onLogin,
title = "Sign in",
message
}: {
settings: ApiSettings;
onClose: () => void;
onLogin: (response: LoginResponse) => void;
title?: string;
message?: string;
}) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -39,10 +43,11 @@ export default function LoginModal({
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<form className="modal-panel" onSubmit={submit}>
<header className="modal-header">
<h2>Sign in</h2>
<h2>{title}</h2>
<button className="modal-close" type="button" onClick={onClose}>×</button>
</header>
<div className="modal-body form-grid">
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormField label="Email">
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />

View File

@@ -1,22 +1,149 @@
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 { 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 }) {
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);
export default function DashboardPage() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Dashboard</h1>
<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>
</div>
</div>
{error && <DismissibleAlert tone="warning" resetKey={error} floating>{error}</DismissibleAlert>}
<div className="metric-grid">
<MetricCard label="Campaigns" value="0" detail="Connect the API to load data" />
<MetricCard label="Queued" value="0" tone="info" />
<MetricCard label="Needs review" value="0" tone="warning" />
<MetricCard label="Failed" value="0" tone="danger" />
<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" />
</div>
<div className="dashboard-grid">
<Card title="Recommended next action"><p className="muted">Create or open a campaign to continue.</p></Card>
<Card title="System status"><p className="muted">API health and queue metrics will appear here.</p></Card>
<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}>
<dt>{module.id}</dt>
<dd><strong>{module.label}</strong><span className="muted"> · v{module.version}</span></dd>
</div>
))}
</dl>
)}
</Card>
</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[] {
return modules.map((module) => module.label).slice(0, 4);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { ApiSettings } from "../../types";
import {
getPrivacyRetentionPolicy,
@@ -13,8 +13,8 @@ import {
import Button from "../../components/Button";
import Card from "../../components/Card";
import DismissibleAlert from "../../components/DismissibleAlert";
import EffectivePolicyBlock, { EffectivePolicyValue } from "../../components/EffectivePolicyBlock";
import type { PolicySourcePathItem } from "../../components/PolicySourcePath";
import FieldLabel from "../../components/help/FieldLabel";
import FormField from "../../components/FormField";
import ToggleSwitch from "../../components/ToggleSwitch";
@@ -63,6 +63,8 @@ type FieldDefinition = {
type RawJsonValue = "inherit" | "keep" | "disable";
type AuditDetailValue = "inherit" | PrivacyRetentionPolicy["audit_detail_level"];
const auditDetailOrder: Record<PrivacyRetentionPolicy["audit_detail_level"], number> = { full: 0, redacted: 1, minimal: 2 };
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
store_raw_campaign_json: true,
raw_campaign_json_retention_days: true,
@@ -107,10 +109,13 @@ export function RetentionPolicyScopeManager({
}: RetentionPolicyScopeManagerProps) {
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
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 limit further."
? "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`;
useEffect(() => {
if (scopeId) {
@@ -119,35 +124,46 @@ export function RetentionPolicyScopeManager({
}
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
setSelectedTargetId(targetOptions[0].id);
return;
}
if (targetOptions.length === 0 && selectedTargetId) setSelectedTargetId("");
}, [scopeId, selectedTargetId, targetOptions]);
return (
<div className="retention-policy-manager">
{targetOptions.length > 0 && !scopeId && (
{targetSelectionRequired && (
<Card title={`${targetLabel} scope`}>
<div className="retention-policy-target-row">
<FormField label={targetLabel}>
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
<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>)}
</select>
</FormField>
</div>
</Card>
)}
<RetentionPolicyEditor
settings={settings}
scopeType={scopeType}
scopeId={activeScopeId}
title={title}
description={requiresTarget && !activeScopeId ? `Select a ${targetLabel.toLowerCase()} before editing retention.` : (description ?? defaultDescription)}
canWrite={canWrite}
locked={locked}
/>
{(!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`;
}
export function RetentionPolicyEditor({
settings,
scopeType,
@@ -171,6 +187,7 @@ export function RetentionPolicyEditor({
const scopeReady = !requiresTarget || Boolean(scopeId);
const disabled = locked || loading || busy || !canWrite || !scopeReady;
const showAllowColumn = scopeType !== "campaign";
const showEffectiveColumn = !isSystem && scopeReady;
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]);
@@ -291,29 +308,37 @@ export function RetentionPolicyEditor({
<h3>{isSystem ? "System policy" : "Local policy"}</h3>
<span className="muted small-note">{localPolicySummary}</span>
</div>
<div className={`retention-policy-table${showAllowColumn ? " with-allow-column" : ""}`}>
<div className="retention-policy-row retention-policy-row-header">
<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>Value</span>
<span>{isSystem ? "Value" : "Local setting"}</span>
{showEffectiveColumn && <span>Effective policy</span>}
{showAllowColumn && <span>Lower levels</span>}
</div>
{visibleFields.map((field) => {
const fieldLocked = !parentAllows(field.key);
const fieldDisabled = disabled || fieldLocked;
return (
<div className="retention-policy-row" key={field.key}>
<div className="retention-policy-field-label">
<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>{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</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 limiting"
label="Allow override"
/>
)}
</div>
@@ -322,28 +347,79 @@ export function RetentionPolicyEditor({
</div>
</section>
{effectivePolicy && (
<EffectivePolicyBlock
title="Effective policy"
sourcePath={effectivePolicySources.length > 0 ? effectivePolicySources : retentionPolicySourcePath(scopeType)}
className="retention-policy-section retention-policy-effective"
gridClassName="retention-policy-effective-grid"
>
<EffectivePolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
<EffectivePolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
<EffectivePolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
<EffectivePolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
<EffectivePolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
<EffectivePolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
<EffectivePolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
{showAllowColumn && <EffectivePolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
</EffectivePolicyBlock>
)}
</div>
</Card>
);
}
type PolicySourceItem = {
label: string;
policy?: Record<string, unknown> | null;
};
function retentionPolicyPathHelp(field: FieldDefinition, sources: PolicySourcePathItem[], scopeType: PrivacyRetentionPolicyScope): ReactNode {
const items = normalizePolicySourceItems(sources.length > 0 ? sources : retentionPolicySourcePath(scopeType));
return <PolicyPathHelp lines={retentionPolicyPathLines(field, items)} />;
}
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>
);
}
function retentionPolicyPathLines(field: FieldDefinition, items: PolicySourceItem[]): string[] {
if (items.length === 0) return ["System: Default"];
const lines: string[] = [];
for (const [index, item] of items.entries()) {
const sourcePolicy = asRecord(item.policy);
const value = retentionSourceValue(field, sourcePolicy, index === 0);
const locked = asRecord(sourcePolicy.allow_lower_level_limits)[field.key] === false;
lines.push(`${policyPathPrefix(index)}${item.label}: ${locked ? `${value} without override` : value}`);
if (locked) break;
}
return lines;
}
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 (field.kind === "raw-json") {
const value = sourcePolicy.store_raw_campaign_json;
return value === false ? "Disabled" : value === true ? "Stored" : "Inherit";
}
if (field.kind === "audit") {
const value = sourcePolicy.audit_detail_level;
return value === "full" || value === "redacted" || value === "minimal" ? auditDetailLabel(value) : "Inherit";
}
const value = sourcePolicy[field.key];
return typeof value === "number" ? daysLabel(value) : value === null && isSystem ? daysLabel(null) : "Inherit";
}
function normalizePolicySourceItems(items: PolicySourcePathItem[]): PolicySourceItem[] {
return items.map((item) => {
if (typeof item === "string") return { label: item };
return { label: item.label, policy: item.policy };
}).filter((item) => item.label.trim());
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function policyPathPrefix(index: number): string {
return index === 0 ? "" : `${" ".repeat(index - 1)}> `;
}
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 (field.kind === "audit") return auditDetailLabel(policy.audit_detail_level);
return daysLabel(policy[field.key as DayKey]);
}
function renderFieldControl(
field: FieldDefinition,
fullPolicy: PrivacyRetentionPolicy,
@@ -362,13 +438,15 @@ function renderFieldControl(
return <RawJsonScopedSelect value={rawJsonValue(patchPolicy.store_raw_campaign_json)} parentStoresRawJson={parentPolicy?.store_raw_campaign_json ?? true} disabled={disabled} onChange={setRawJson} />;
}
if (field.kind === "audit") {
return <AuditDetailSelect value={isSystem ? fullPolicy.audit_detail_level : auditDetailValue(patchPolicy.audit_detail_level)} includeInherit={!isSystem} disabled={disabled} onChange={setAuditDetail} />;
return <AuditDetailSelect value={isSystem ? fullPolicy.audit_detail_level : auditDetailValue(patchPolicy.audit_detail_level)} includeInherit={!isSystem} parentValue={isSystem ? null : parentPolicy?.audit_detail_level ?? null} disabled={disabled} onChange={setAuditDetail} />;
}
const parentDayLimit = !isSystem && parentPolicy ? parentPolicy[field.key as DayKey] : null;
return (
<RetentionDaysField
value={isSystem ? fullPolicy[field.key as DayKey] : patchPolicy[field.key as DayKey]}
disabled={disabled}
placeholder={isSystem ? "Unlimited" : "Inherit"}
max={parentDayLimit}
onChange={(value) => setRetentionDays(field.key as DayKey, value)}
/>
);
@@ -393,17 +471,32 @@ function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }:
);
}
function RetentionDaysField({ value, disabled, placeholder, onChange }: { value?: number | null; disabled: boolean; placeholder: string; onChange: (value: string) => void }) {
return <input type="number" min={0} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />;
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) {
onChange(nextValue);
return;
}
const parsed = Number(trimmed);
onChange(Number.isFinite(parsed) && parsed > max ? String(max) : nextValue);
}
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, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; 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;
}
return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as AuditDetailValue)}>
{includeInherit && <option value="inherit">Inherit</option>}
<option value="full">Full</option>
<option value="redacted">Redacted</option>
<option value="minimal">Minimal</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>
);
}
@@ -427,13 +520,6 @@ function auditDetailLabel(value: PrivacyRetentionPolicy["audit_detail_level"]):
return "Minimal";
}
function allowLowerSummary(allow: PrivacyRetentionLimitPermissions): string {
const count = fieldDefinitions.filter((field) => allow[field.key] !== false).length;
if (count === fieldDefinitions.length) return "Allowed";
if (count === 0) return "Locked";
return `${count}/${fieldDefinitions.length} allowed`;
}
function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
let count = 0;
for (const field of fieldDefinitions) {

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings, AuthInfo } from "../../types";
import type { ApiSettings, AuthInfo, MailProfilesUiCapability } from "../../types";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
@@ -11,7 +11,7 @@ import { apiFetch } from "../../api/client";
import { updateProfile } from "../../api/auth";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DismissibleAlert from "../../components/DismissibleAlert";
import { MailProfileScopeManager } from "@govoplan/mail-webui";
import { usePlatformUiCapability } from "../../platform/ModuleContext";
import { hasAnyScope, hasScope } from "../../utils/permissions";
type SettingsSection = "profile" | "mail-profiles" | "interface" | "workspace" | "local-connection" | "notifications";
@@ -49,7 +49,9 @@ export default function SettingsPage({
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
}) {
const [searchParams, setSearchParams] = useSearchParams();
const canUseMailProfiles = hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? 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 requestedSection = searchParams.get("section") as SettingsSection | null;
const [active, setActive] = useState<SettingsSection>(settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface");
@@ -155,7 +157,7 @@ export default function SettingsPage({
</div>
)}
{active === "mail-profiles" && (
{active === "mail-profiles" && MailProfileScopeManager && (
<MailProfileScopeManager
settings={settings}
scopeType="user"