341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
import { DescriptionList } from "@govoplan/core-webui";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
AdminPageLayout,
|
|
adminErrorMessage,
|
|
Button,
|
|
Card,
|
|
ConfirmDialog,
|
|
DataGrid,
|
|
DocumentationHelpLink,
|
|
mergeDeltaRows,
|
|
RetentionPolicyScopeManager,
|
|
runRetentionPolicy,
|
|
StatusBadge,
|
|
useDeltaWatermarks,
|
|
type ApiSettings,
|
|
type DataGridColumn,
|
|
type DeltaDeletedItem,
|
|
type PrivacyRetentionPolicyScope,
|
|
type RetentionPolicyTargetOption,
|
|
type RetentionRunResponse
|
|
} from "@govoplan/core-webui";
|
|
import { RefreshCw } from "lucide-react";
|
|
import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets";
|
|
|
|
type Props = {
|
|
settings: ApiSettings;
|
|
scopeType: Extract<PrivacyRetentionPolicyScope, "system" | "tenant" | "user" | "group">;
|
|
canWrite: boolean;
|
|
};
|
|
|
|
type DeltaResponse = {
|
|
deleted: DeltaDeletedItem[];
|
|
watermark?: string | null;
|
|
has_more: boolean;
|
|
full: boolean;
|
|
};
|
|
|
|
interface RetentionCountTree {
|
|
[key: string]: number | RetentionCountTree;
|
|
}
|
|
|
|
type RetentionCountRow = {
|
|
id: string;
|
|
area: string;
|
|
measure: string;
|
|
count: number;
|
|
};
|
|
|
|
const RETENTION_DOCUMENTATION = {
|
|
contextId: "policy.retention",
|
|
documentationType: "admin" as const
|
|
};
|
|
|
|
const RETENTION_RESULT_COLUMNS: DataGridColumn<RetentionCountRow>[] = [
|
|
{ id: "area", header: "Area", value: (row) => row.area, width: "minmax(180px, 1fr)", filterType: "list", sortable: true },
|
|
{ id: "measure", header: "Outcome", value: (row) => row.measure, width: "minmax(220px, 1.4fr)", filterType: "text", sortable: true },
|
|
{ id: "count", header: "Records", value: (row) => row.count, width: "120px", align: "right", sortable: true }
|
|
];
|
|
|
|
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 override limits.",
|
|
policyTitle: "System retention policy",
|
|
policyDescription: "Set concrete system retention values. Override switches define whether lower levels may narrow each value."
|
|
},
|
|
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 where the parent policy allows overrides."
|
|
},
|
|
user: {
|
|
title: "User retention",
|
|
description: "User-scoped retention limits for campaigns owned by a user.",
|
|
targetLabel: "User",
|
|
policyTitle: "User retention policy",
|
|
policyDescription: "User limits may only narrow inherited system and tenant policy."
|
|
},
|
|
group: {
|
|
title: "Group retention",
|
|
description: "Group-scoped retention limits for campaigns owned by a group.",
|
|
targetLabel: "Group",
|
|
policyTitle: "Group retention policy",
|
|
policyDescription: "Group limits may only narrow inherited system and tenant policy."
|
|
}
|
|
};
|
|
|
|
export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
|
|
const [targets, setTargets] = useState<RetentionPolicyTargetOption[]>([]);
|
|
const usersRef = useRef<UserAdminItem[]>([]);
|
|
const groupsRef = useRef<GroupSummary[]>([]);
|
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
|
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
|
|
const [targetError, setTargetError] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [success, setSuccess] = useState("");
|
|
const [runError, setRunError] = useState("");
|
|
const [confirmRetentionRun, setConfirmRetentionRun] = useState(false);
|
|
const [retentionResult, setRetentionResult] = useState<RetentionRunResponse | null>(null);
|
|
|
|
useEffect(() => {
|
|
usersRef.current = [];
|
|
groupsRef.current = [];
|
|
resetDeltaWatermark();
|
|
void loadTargets();
|
|
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, resetDeltaWatermark]);
|
|
|
|
async function loadTargets() {
|
|
if (scopeType !== "user" && scopeType !== "group") {
|
|
setTargets([]);
|
|
setLoadingTargets(false);
|
|
setTargetError("");
|
|
return;
|
|
}
|
|
setLoadingTargets(true);
|
|
setTargetError("");
|
|
try {
|
|
if (scopeType === "user") {
|
|
const users = await loadDeltaRows<UserAdminItem, UserListDeltaResponse>(
|
|
usersRef.current,
|
|
"policy:retention-users",
|
|
getDeltaWatermark,
|
|
setDeltaWatermark,
|
|
(since) => fetchUsersDelta(settings, { since }),
|
|
(response) => response.users,
|
|
(user) => user.id,
|
|
"access_user",
|
|
sortUsers
|
|
);
|
|
usersRef.current = users;
|
|
setTargets(users.map((user) => ({
|
|
id: user.id,
|
|
label: user.display_name || user.email,
|
|
secondary: user.display_name ? user.email : null
|
|
})));
|
|
} else {
|
|
const groups = await loadDeltaRows<GroupSummary, GroupListDeltaResponse>(
|
|
groupsRef.current,
|
|
"policy:retention-groups",
|
|
getDeltaWatermark,
|
|
setDeltaWatermark,
|
|
(since) => fetchGroupsDelta(settings, { since }),
|
|
(response) => response.groups,
|
|
(group) => group.id,
|
|
"access_group",
|
|
sortGroups
|
|
);
|
|
groupsRef.current = groups;
|
|
setTargets(groups.map((group) => ({
|
|
id: group.id,
|
|
label: group.name,
|
|
secondary: group.slug
|
|
})));
|
|
}
|
|
} catch (err) {
|
|
setTargets([]);
|
|
setTargetError(adminErrorMessage(err));
|
|
} finally {
|
|
setLoadingTargets(false);
|
|
}
|
|
}
|
|
|
|
async function runRetention(dryRun: boolean) {
|
|
setBusy(true);
|
|
setRunError("");
|
|
setSuccess("");
|
|
try {
|
|
const response = await runRetentionPolicy(settings, dryRun);
|
|
setRetentionResult(response);
|
|
setSuccess(dryRun ? "Retention dry run completed." : "Retention policy applied.");
|
|
setConfirmRetentionRun(false);
|
|
} catch (err) {
|
|
setRunError(adminErrorMessage(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const labels = copy[scopeType];
|
|
const resultRows = flattenRetentionCounts(retentionResult?.result.counts);
|
|
const actionDisabledReason = busy
|
|
? "A retention operation is already running."
|
|
: !canWrite
|
|
? "Your account may inspect retention policy but cannot run retention operations."
|
|
: undefined;
|
|
|
|
return (
|
|
<>
|
|
<AdminPageLayout
|
|
title={labels.title}
|
|
description={labels.description}
|
|
helpContextId="policy.retention"
|
|
loading={loadingTargets}
|
|
error={targetError || runError}
|
|
success={success}
|
|
actions={
|
|
<>
|
|
{(scopeType === "user" || scopeType === "group") && (
|
|
<Button
|
|
title="Reload policy targets"
|
|
aria-label="Reload policy targets"
|
|
helpContextId="policy.retention.action.reload-targets"
|
|
onClick={() => void loadTargets()}
|
|
disabled={loadingTargets}
|
|
disabledReason={loadingTargets ? "Policy targets are already loading." : undefined}
|
|
>
|
|
<RefreshCw size={16} />
|
|
</Button>
|
|
)}
|
|
<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
|
|
</>
|
|
}
|
|
>
|
|
<RetentionPolicyScopeManager
|
|
settings={settings}
|
|
scopeType={scopeType}
|
|
targetOptions={targets}
|
|
targetLabel={labels.targetLabel}
|
|
title={labels.policyTitle}
|
|
description={labels.policyDescription}
|
|
canWrite={canWrite}
|
|
/>
|
|
|
|
{scopeType === "system" && (
|
|
<div className="retention-run-section">
|
|
<Card
|
|
title="Retention execution"
|
|
helpContextId="policy.retention.execution"
|
|
actions={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
|
|
>
|
|
<p className="muted small-note">Run the saved effective retention policy against retained platform data.</p>
|
|
<div className="button-row compact-actions subsection-bottom-actions">
|
|
<Button helpContextId="policy.retention.action.dry-run" onClick={() => void runRetention(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Dry run</Button>
|
|
<Button helpContextId="policy.retention.action.apply" variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Apply retention</Button>
|
|
</div>
|
|
</Card>
|
|
{retentionResult && (
|
|
<Card title="Latest retention outcome" helpContextId="policy.retention.outcome">
|
|
<DescriptionList variant="inline" density="compact">
|
|
<div>
|
|
<dt>Operation</dt>
|
|
<dd><StatusBadge status={retentionResult.result.dry_run ? "info" : "success"} label={retentionResult.result.dry_run ? "Dry run" : "Applied"} /></dd>
|
|
</div>
|
|
<div><dt>Policy scope</dt><dd>{humanize(retentionResult.result.effective_policy_scope || "system")}</dd></div>
|
|
<div><dt>Reported outcomes</dt><dd>{resultRows.length}</dd></div>
|
|
</DescriptionList>
|
|
<div className="admin-table-surface">
|
|
<DataGrid
|
|
id="policy-retention-outcomes"
|
|
rows={resultRows}
|
|
columns={RETENTION_RESULT_COLUMNS}
|
|
initialFit="container"
|
|
getRowKey={(row) => row.id}
|
|
emptyText="No retained records currently match the effective policy."
|
|
/>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
</AdminPageLayout>
|
|
<ConfirmDialog
|
|
open={confirmRetentionRun}
|
|
helpContextId="policy.retention.confirm-apply"
|
|
helpModuleId="policy"
|
|
title="Apply retention policy"
|
|
message="This will redact or delete eligible retained data according to the saved policy. The application cannot restore deleted content; the run and bounded outcome counts remain in audit evidence. Run a dry run first and verify recovery evidence before continuing."
|
|
confirmLabel="Apply retention"
|
|
tone="danger"
|
|
busy={busy}
|
|
onCancel={() => setConfirmRetentionRun(false)}
|
|
onConfirm={() => void runRetention(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function flattenRetentionCounts(
|
|
counts: RetentionRunResponse["result"]["counts"] | undefined
|
|
): RetentionCountRow[] {
|
|
const rows: RetentionCountRow[] = [];
|
|
const visit = (value: number | RetentionCountTree, path: string[]) => {
|
|
if (typeof value === "number") {
|
|
const [area = "retention", ...measureParts] = path;
|
|
rows.push({
|
|
id: path.join("."),
|
|
area: humanize(area),
|
|
measure: humanize(measureParts.join(" ") || "records"),
|
|
count: value
|
|
});
|
|
return;
|
|
}
|
|
for (const [key, child] of Object.entries(value)) visit(child, [...path, key]);
|
|
};
|
|
if (counts) visit(counts as unknown as RetentionCountTree, []);
|
|
return rows;
|
|
}
|
|
|
|
function humanize(value: string): string {
|
|
return value
|
|
.split(/[._\s-]+/)
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
async function loadDeltaRows<TItem, TResponse extends DeltaResponse>(
|
|
current: TItem[],
|
|
key: string,
|
|
getDeltaWatermark: (key: string) => string | null,
|
|
setDeltaWatermark: (key: string, watermark: string | null | undefined) => void,
|
|
fetchDelta: (since: string | null) => Promise<TResponse>,
|
|
rowsFromResponse: (response: TResponse) => TItem[],
|
|
getKey: (item: TItem) => string,
|
|
deletedResourceType: string,
|
|
sort?: (left: TItem, right: TItem) => number
|
|
): Promise<TItem[]> {
|
|
let nextWatermark = getDeltaWatermark(key);
|
|
let merged = current;
|
|
let hasMore = false;
|
|
do {
|
|
const response = await fetchDelta(nextWatermark);
|
|
const rows = rowsFromResponse(response);
|
|
merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
|
|
nextWatermark = response.watermark ?? null;
|
|
hasMore = response.has_more;
|
|
} while (hasMore);
|
|
setDeltaWatermark(key, nextWatermark);
|
|
return merged;
|
|
}
|
|
|
|
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
|
return left.email.localeCompare(right.email);
|
|
}
|
|
|
|
function sortGroups(left: GroupSummary, right: GroupSummary): number {
|
|
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
|
}
|