feat: add DSAR administration

This commit is contained in:
2026-08-07 14:53:28 +02:00
parent bc589a3d4f
commit 176c1d826f
5 changed files with 818 additions and 1 deletions
@@ -0,0 +1,481 @@
import { useEffect, useMemo, useState } from "react";
import { Download, Eye, Play, Plus, RefreshCw, Search, ShieldCheck } from "lucide-react";
import type { ApiSettings, DataGridColumn } from "@govoplan/core-webui";
import {
AdminIconButton,
AdminPageLayout,
Button,
Card,
DataGrid,
Dialog,
DismissibleAlert,
FormField,
MetricCard,
StatusBadge,
TableActionGroup,
adminErrorMessage,
formatDateTime
} from "@govoplan/core-webui";
import {
createDataSubjectRequest,
executeDataSubjectErasure,
exportDataSubjectRequest,
fetchDataSubjectRequest,
fetchDataSubjectRequests,
planDataSubjectErasure,
searchDataSubjectRequest,
type DataSubjectErasureAction,
type DataSubjectRecord,
type DataSubjectRequestCreatePayload,
type DataSubjectRequestDetail,
type DataSubjectRequestSummary
} from "../../api/admin";
const I18N = {
title: "i18n:govoplan-admin.data_subject_requests.ds001",
description: "i18n:govoplan-admin.data_subject_requests.ds002",
create: "i18n:govoplan-admin.data_subject_requests.ds003",
reload: "i18n:govoplan-admin.data_subject_requests.ds004",
reference: "i18n:govoplan-admin.data_subject_requests.ds005",
kindLabel: "i18n:govoplan-admin.data_subject_requests.ds006",
statusLabel: "i18n:govoplan-admin.data_subject_requests.ds007",
subject: "i18n:govoplan-admin.data_subject_requests.ds008",
due: "i18n:govoplan-admin.data_subject_requests.ds009",
actions: "i18n:govoplan-admin.data_subject_requests.ds010",
inspect: "i18n:govoplan-admin.data_subject_requests.ds011",
noRequests: "i18n:govoplan-admin.data_subject_requests.ds012",
records: "i18n:govoplan-admin.data_subject_requests.ds013",
executable: "i18n:govoplan-admin.data_subject_requests.ds014",
uncovered: "i18n:govoplan-admin.data_subject_requests.ds015",
runSearch: "i18n:govoplan-admin.data_subject_requests.ds016",
plan: "i18n:govoplan-admin.data_subject_requests.ds017",
export: "i18n:govoplan-admin.data_subject_requests.ds018",
execute: "i18n:govoplan-admin.data_subject_requests.ds019",
collectedRecords: "i18n:govoplan-admin.data_subject_requests.ds020",
erasurePlan: "i18n:govoplan-admin.data_subject_requests.ds021",
coverage: "i18n:govoplan-admin.data_subject_requests.ds022",
noSelection: "i18n:govoplan-admin.data_subject_requests.ds023",
provider: "i18n:govoplan-admin.data_subject_requests.ds024",
category: "i18n:govoplan-admin.data_subject_requests.ds025",
evidence: "i18n:govoplan-admin.data_subject_requests.ds026",
decision: "i18n:govoplan-admin.data_subject_requests.ds027",
rationale: "i18n:govoplan-admin.data_subject_requests.ds028",
select: "i18n:govoplan-admin.data_subject_requests.ds029",
createTitle: "i18n:govoplan-admin.data_subject_requests.ds030",
purpose: "i18n:govoplan-admin.data_subject_requests.ds031",
legalBasis: "i18n:govoplan-admin.data_subject_requests.ds032",
email: "i18n:govoplan-admin.data_subject_requests.ds033",
accountIdLabel: "i18n:govoplan-admin.data_subject_requests.ds034",
membershipIdLabel: "i18n:govoplan-admin.data_subject_requests.ds035",
identityIdLabel: "i18n:govoplan-admin.data_subject_requests.ds036",
notes: "i18n:govoplan-admin.data_subject_requests.ds037",
save: "i18n:govoplan-admin.data_subject_requests.ds038",
cancel: "i18n:govoplan-admin.data_subject_requests.ds039",
executeTitle: "i18n:govoplan-admin.data_subject_requests.ds040",
executeWarning: "i18n:govoplan-admin.data_subject_requests.ds041",
confirmation: "i18n:govoplan-admin.data_subject_requests.ds042",
confirmationHelp: "i18n:govoplan-admin.data_subject_requests.ds043",
missingSelector: "i18n:govoplan-admin.data_subject_requests.ds044",
providerGap: "i18n:govoplan-admin.data_subject_requests.ds045",
created: "i18n:govoplan-admin.data_subject_requests.ds046",
searchComplete: "i18n:govoplan-admin.data_subject_requests.ds047",
planComplete: "i18n:govoplan-admin.data_subject_requests.ds048",
executionComplete: "i18n:govoplan-admin.data_subject_requests.ds049",
exportComplete: "i18n:govoplan-admin.data_subject_requests.ds050",
accessRequest: "i18n:govoplan-admin.data_subject_requests.ds051",
erasureRequest: "i18n:govoplan-admin.data_subject_requests.ds052",
combinedRequest: "i18n:govoplan-admin.data_subject_requests.ds053",
mutable: "i18n:govoplan-admin.data_subject_requests.ds054",
retainedEvidence: "i18n:govoplan-admin.data_subject_requests.ds055",
irreversible: "i18n:govoplan-admin.data_subject_requests.ds056",
executableState: "i18n:govoplan-admin.data_subject_requests.ds057",
retainedReview: "i18n:govoplan-admin.data_subject_requests.ds058",
externalReferences: "i18n:govoplan-admin.data_subject_requests.ds059",
externalReferencesHelp: "i18n:govoplan-admin.data_subject_requests.ds060",
invalidExternalReferences: "i18n:govoplan-admin.data_subject_requests.ds061"
} as const;
type Draft = {
reference: string;
requestKind: DataSubjectRequestSummary["request_kind"];
email: string;
accountId: string;
membershipId: string;
identityId: string;
externalReferences: string;
purpose: string;
legalBasis: string;
dueDate: string;
notes: string;
};
const emptyDraft: Draft = {
reference: "",
requestKind: "access_and_erasure",
email: "",
accountId: "",
membershipId: "",
identityId: "",
externalReferences: "",
purpose: "",
legalBasis: "",
dueDate: "",
notes: ""
};
export default function DataSubjectRequestsPanel({
settings,
canManage,
canExport,
canErase
}: {
settings: ApiSettings;
canManage: boolean;
canExport: boolean;
canErase: boolean;
}) {
const [items, setItems] = useState<DataSubjectRequestSummary[]>([]);
const [selected, setSelected] = useState<DataSubjectRequestDetail | null>(null);
const [creating, setCreating] = useState(false);
const [executing, setExecuting] = useState(false);
const [draft, setDraft] = useState<Draft>(emptyDraft);
const [selectedActions, setSelectedActions] = useState<string[]>([]);
const [confirmation, setConfirmation] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function load(preferredId?: string) {
setLoading(true);
setError("");
try {
const next = await fetchDataSubjectRequests(settings);
setItems(next);
const requestId = preferredId ?? selected?.request.id;
if (requestId && next.some((item) => item.id === requestId)) {
setSelected(await fetchDataSubjectRequest(settings, requestId));
} else if (selected && !next.some((item) => item.id === selected.request.id)) {
setSelected(null);
}
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, [settings.accessToken, settings.apiBaseUrl]);
async function inspect(item: DataSubjectRequestSummary) {
setBusy(true);
setError("");
try {
const detail = await fetchDataSubjectRequest(settings, item.id);
setSelected(detail);
setSelectedActions(defaultActionSelection(detail));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
async function create() {
if (![draft.email, draft.accountId, draft.membershipId, draft.identityId, draft.externalReferences].some((value) => value.trim())) {
setError(I18N.missingSelector);
return;
}
if (!validExternalReferenceLines(draft.externalReferences)) {
setError(I18N.invalidExternalReferences);
return;
}
setBusy(true);
setError("");
try {
const detail = await createDataSubjectRequest(settings, createPayload(draft));
setCreating(false);
setDraft(emptyDraft);
setSelected(detail);
setSuccess(I18N.created);
await load(detail.request.id);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
async function mutate(
operation: (request: DataSubjectRequestSummary) => Promise<DataSubjectRequestDetail>,
message: string
): Promise<boolean> {
if (!selected) return false;
setBusy(true);
setError("");
setSuccess("");
try {
const detail = await operation(selected.request);
setSelected(detail);
setSelectedActions(defaultActionSelection(detail));
setSuccess(message);
await load(detail.request.id);
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
async function download() {
if (!selected) return;
setBusy(true);
setError("");
try {
await exportDataSubjectRequest(settings, selected.request);
setSuccess(I18N.exportComplete);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
async function execute() {
if (!selected) return;
const succeeded = await mutate(
(request) => executeDataSubjectErasure(settings, request, selectedActions, confirmation),
I18N.executionComplete
);
if (succeeded) {
setExecuting(false);
setConfirmation("");
}
}
const requestColumns = useMemo<DataGridColumn<DataSubjectRequestSummary>[]>(() => [
{
id: "reference",
header: I18N.reference,
width: "minmax(180px, 1fr)",
minWidth: 160,
resizable: true,
sortable: true,
filterable: true,
value: (row) => `${row.reference} ${subjectLabel(row)}`,
render: (row) => <div><strong>{row.reference}</strong><span className="muted block">{subjectLabel(row)}</span></div>
},
{ id: "kind", header: I18N.kindLabel, width: 170, sortable: true, filterable: true, value: (row) => row.request_kind },
{ id: "status", header: I18N.statusLabel, width: 150, sortable: true, filterable: true, value: (row) => row.status, render: (row) => <StatusBadge status={statusTone(row.status)} label={row.status} /> },
{ id: "due", header: I18N.due, width: 170, sortable: true, value: (row) => row.due_at ?? "", render: (row) => row.due_at ? formatDateTime(row.due_at) : "-" },
{
id: "actions",
header: I18N.actions,
width: 72,
sticky: "end",
align: "right",
render: (row) => <TableActionGroup actions={[{
id: "inspect",
label: I18N.inspect,
icon: <Eye size={16} />,
disabled: busy,
onClick: () => void inspect(row)
}]} />
}
], [busy]);
const recordColumns = useMemo<DataGridColumn<DataSubjectRecord>[]>(() => [
{ id: "title", header: I18N.records, width: "minmax(220px, 1.3fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (row) => `${row.title} ${row.resource_type}`, render: (row) => <div><strong>{row.title}</strong><span className="muted block">{row.resource_type}</span></div> },
{ id: "provider", header: I18N.provider, width: 140, sortable: true, filterable: true, value: (row) => row.module_id },
{ id: "category", header: I18N.category, width: 170, sortable: true, filterable: true, value: (row) => row.category },
{ id: "evidence", header: I18N.evidence, width: 190, filterable: true, value: (row) => row.immutable_evidence ? row.retention_reason || "retained" : "mutable", render: (row) => row.immutable_evidence ? <StatusBadge status="warning" label={row.retention_reason || I18N.retainedEvidence} /> : <StatusBadge status="active" label={I18N.mutable} /> }
], []);
const actionColumns = useMemo<DataGridColumn<DataSubjectErasureAction>[]>(() => [
{
id: "select",
header: I18N.select,
width: 72,
render: (row) => <input
type="checkbox"
checked={selectedActions.includes(row.action_id)}
disabled={!row.executable || busy}
aria-label={`${I18N.select}: ${row.title}`}
onChange={(event) => setSelectedActions((current) => event.target.checked ? [...current, row.action_id] : current.filter((id) => id !== row.action_id))}
/>
},
{ id: "action", header: I18N.decision, width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (row) => `${row.title} ${row.kind}`, render: (row) => <div><strong>{row.title}</strong><span className="muted block">{row.kind}</span></div> },
{ id: "provider", header: I18N.provider, width: 140, sortable: true, filterable: true, value: (row) => row.module_id },
{ id: "rationale", header: I18N.rationale, width: "minmax(260px, 1.4fr)", minWidth: 220, resizable: true, value: (row) => row.rationale },
{ id: "state", header: I18N.statusLabel, width: 150, value: (row) => row.executable ? "executable" : row.kind, render: (row) => <StatusBadge status={row.executable ? (row.irreversible ? "warning" : "active") : "inactive"} label={row.executable ? (row.irreversible ? I18N.irreversible : I18N.executableState) : I18N.retainedReview} /> }
], [busy, selectedActions]);
const coverageGaps = selected?.request.coverage.modules_without_provider ?? [];
const actions = selected?.erasure_plan.actions ?? [];
const records = selected?.search.records ?? [];
return (
<>
<AdminPageLayout
title={I18N.title}
description={I18N.description}
loading={loading}
error={error}
success={success}
actions={<>
<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw size={16} /> {I18N.reload}</Button>
<AdminIconButton label={I18N.create} icon={<Plus />} variant="primary" onClick={() => setCreating(true)} disabled={!canManage || busy} />
</>}
>
<div className="admin-table-surface">
<DataGrid
id="admin-data-subject-requests"
rows={items}
columns={requestColumns}
initialFit="container"
getRowKey={(row) => row.id}
emptyText={I18N.noRequests}
/>
</div>
{selected ? <>
<div className="metric-grid">
<MetricCard label={I18N.records} value={selected.request.record_count} tone="info" />
<MetricCard label={I18N.executable} value={selected.request.executable_action_count} tone={selected.request.executable_action_count ? "warning" : "neutral"} />
<MetricCard label={I18N.uncovered} value={coverageGaps.length} tone={coverageGaps.length ? "warning" : "good"} />
</div>
<Card title={selected.request.reference}>
<dl className="admin-details-grid">
<div><dt>{I18N.subject}</dt><dd>{subjectLabel(selected.request)}</dd></div>
<div><dt>{I18N.kindLabel}</dt><dd>{selected.request.request_kind}</dd></div>
<div><dt>{I18N.statusLabel}</dt><dd><StatusBadge status={statusTone(selected.request.status)} label={selected.request.status} /></dd></div>
<div><dt>{I18N.due}</dt><dd>{selected.request.due_at ? formatDateTime(selected.request.due_at) : "-"}</dd></div>
<div><dt>{I18N.purpose}</dt><dd>{selected.request.purpose}</dd></div>
<div><dt>{I18N.legalBasis}</dt><dd>{selected.request.legal_basis || "-"}</dd></div>
</dl>
<div className="button-row">
<Button onClick={() => void mutate((request) => searchDataSubjectRequest(settings, request), I18N.searchComplete)} disabled={!canManage || busy}><Search size={16} /> {I18N.runSearch}</Button>
{selected.request.request_kind !== "access" && <Button onClick={() => void mutate((request) => planDataSubjectErasure(settings, request), I18N.planComplete)} disabled={!canManage || busy || !selected.search.searched_at}><ShieldCheck size={16} /> {I18N.plan}</Button>}
<Button onClick={() => void download()} disabled={!canExport || busy}><Download size={16} /> {I18N.export}</Button>
{actions.some((action) => action.executable) && <Button variant="danger" onClick={() => setExecuting(true)} disabled={!canErase || busy || !selectedActions.length}><Play size={16} /> {I18N.execute}</Button>}
</div>
</Card>
{coverageGaps.length > 0 && <DismissibleAlert tone="warning"><strong>{I18N.providerGap}</strong><br />{I18N.coverage}: {coverageGaps.join(", ")}</DismissibleAlert>}
<Card title={I18N.collectedRecords}>
<DataGrid id={`admin-dsar-records-${selected.request.id}`} rows={records} columns={recordColumns} initialFit="container" getRowKey={(row) => `${row.provider_id}:${row.resource_type}:${row.resource_id}`} emptyText={I18N.noSelection} />
</Card>
{actions.length > 0 && <Card title={I18N.erasurePlan}>
<DataGrid id={`admin-dsar-actions-${selected.request.id}`} rows={actions} columns={actionColumns} initialFit="container" getRowKey={(row) => row.action_id} emptyText={I18N.noSelection} />
</Card>}
</> : <DismissibleAlert tone="info">{I18N.noSelection}</DismissibleAlert>}
</AdminPageLayout>
<Dialog
open={creating}
title={I18N.createTitle}
className="admin-dialog admin-dialog-wide"
portal
closeDisabled={busy}
onClose={() => setCreating(false)}
footer={<><Button onClick={() => setCreating(false)} disabled={busy}>{I18N.cancel}</Button><Button variant="primary" onClick={() => void create()} disabled={busy || !draft.reference.trim() || !draft.purpose.trim()}>{I18N.save}</Button></>}
helpContextId="admin.privacy.data-subject-requests"
>
<div className="admin-form-grid two-columns">
<FormField label={I18N.reference}><input value={draft.reference} onChange={(event) => setDraft({ ...draft, reference: event.target.value })} /></FormField>
<FormField label={I18N.kindLabel}><select value={draft.requestKind} onChange={(event) => setDraft({ ...draft, requestKind: event.target.value as Draft["requestKind"] })}><option value="access">{I18N.accessRequest}</option><option value="erasure">{I18N.erasureRequest}</option><option value="access_and_erasure">{I18N.combinedRequest}</option></select></FormField>
<FormField label={I18N.email}><input type="email" value={draft.email} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label={I18N.accountIdLabel}><input value={draft.accountId} onChange={(event) => setDraft({ ...draft, accountId: event.target.value })} /></FormField>
<FormField label={I18N.membershipIdLabel}><input value={draft.membershipId} onChange={(event) => setDraft({ ...draft, membershipId: event.target.value })} /></FormField>
<FormField label={I18N.identityIdLabel}><input value={draft.identityId} onChange={(event) => setDraft({ ...draft, identityId: event.target.value })} /></FormField>
<FormField label={I18N.externalReferences} help={I18N.externalReferencesHelp}><textarea rows={3} value={draft.externalReferences} onChange={(event) => setDraft({ ...draft, externalReferences: event.target.value })} placeholder="source.namespace=reference" /></FormField>
<FormField label={I18N.due}><input type="date" value={draft.dueDate} onChange={(event) => setDraft({ ...draft, dueDate: event.target.value })} /></FormField>
<FormField label={I18N.legalBasis}><input value={draft.legalBasis} onChange={(event) => setDraft({ ...draft, legalBasis: event.target.value })} /></FormField>
<FormField label={I18N.purpose}><textarea rows={3} value={draft.purpose} onChange={(event) => setDraft({ ...draft, purpose: event.target.value })} /></FormField>
<FormField label={I18N.notes}><textarea rows={3} value={draft.notes} onChange={(event) => setDraft({ ...draft, notes: event.target.value })} /></FormField>
</div>
</Dialog>
<Dialog
open={executing && Boolean(selected)}
title={I18N.executeTitle}
className="admin-dialog"
portal
role="alertdialog"
closeDisabled={busy}
onClose={() => setExecuting(false)}
footer={<><Button onClick={() => setExecuting(false)} disabled={busy}>{I18N.cancel}</Button><Button variant="danger" onClick={() => void execute()} disabled={busy || !selected || confirmation !== `ERASE ${selected.request.id}`}>{I18N.execute}</Button></>}
>
<DismissibleAlert tone="warning">{I18N.executeWarning}</DismissibleAlert>
<p>{selectedActions.length} / {actions.filter((action) => action.executable).length}</p>
<FormField label={I18N.confirmation} help={selected ? `${I18N.confirmationHelp}: ERASE ${selected.request.id}` : I18N.confirmationHelp}>
<input value={confirmation} onChange={(event) => setConfirmation(event.target.value)} autoComplete="off" />
</FormField>
</Dialog>
</>
);
}
function createPayload(draft: Draft): DataSubjectRequestCreatePayload {
return {
reference: draft.reference.trim(),
request_kind: draft.requestKind,
subject: {
email: optional(draft.email),
account_id: optional(draft.accountId),
membership_id: optional(draft.membershipId),
identity_id: optional(draft.identityId),
external_references: externalReferenceLines(draft.externalReferences)
},
purpose: draft.purpose.trim(),
legal_basis: optional(draft.legalBasis),
due_at: draft.dueDate ? new Date(`${draft.dueDate}T23:59:59`).toISOString() : null,
notes: optional(draft.notes)
};
}
function defaultActionSelection(detail: DataSubjectRequestDetail): string[] {
return (detail.erasure_plan.actions ?? []).filter((action) => action.executable).map((action) => action.action_id);
}
function subjectLabel(request: DataSubjectRequestSummary): string {
const subject = request.subject;
return subject.email || subject.membership_id || subject.identity_id || subject.account_id || Object.values(subject.external_references ?? {})[0] || "-";
}
function statusTone(value: string): string {
if (value === "completed") return "success";
if (value.includes("partial")) return "warning";
if (value === "draft") return "inactive";
return "active";
}
function optional(value: string): string | null {
const normalized = value.trim();
return normalized || null;
}
function validExternalReferenceLines(value: string): boolean {
return value.split(/\r?\n/).every((line) => {
if (!line.trim()) return true;
const separator = line.indexOf("=");
return separator > 0 && Boolean(line.slice(separator + 1).trim());
});
}
function externalReferenceLines(value: string): Record<string, string> {
return Object.fromEntries(value.split(/\r?\n/).flatMap((line) => {
const separator = line.indexOf("=");
if (separator <= 0) return [];
const key = line.slice(0, separator).trim();
const reference = line.slice(separator + 1).trim();
return key && reference ? [[key, reference]] : [];
}));
}