feat: add DSAR administration
This commit is contained in:
@@ -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]] : [];
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user