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
+38
View File
@@ -4,6 +4,7 @@ from govoplan_admin.backend.db import models as admin_models # noqa: F401 - pop
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
MigrationSpec, MigrationSpec,
@@ -170,6 +171,42 @@ manifest = ModuleManifest(
], ],
}, },
), ),
DocumentationTopic(
id="admin.data-subject-request-workspace",
title="Review data-subject requests",
summary="The privacy workspace makes provider coverage, exports, retained evidence, and selected erasure actions explicit.",
body=(
"The data-subject request section is visible only with the Access privacy read permission. Create and search operations require privacy management authority; exports and erasure execution have separate permissions. Subject selectors support account, membership, identity, email, and namespaced source-system references. The workspace never treats a module without a provider as searched. It lists provider coverage and failures next to collected records. An erasure plan separates executable provider actions from retained or manual-review items. Destructive execution uses the current resource revision, selected actions, and an exact confirmation phrase."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("privacy_officer", "tenant_owner", "operator"),
related_modules=("access", "audit", "policy"),
conditions=(
DocumentationCondition(
required_modules=("admin", "access"),
any_scopes=(
"access:privacy:read",
"access:privacy:manage",
"access:privacy:export",
"access:privacy:erase",
),
),
),
translations={
"de": {
"title": "Betroffenenanfragen prüfen",
"summary": "Der Datenschutz-Arbeitsbereich weist Anbieterabdeckung, Exporte, aufbewahrte Nachweise und ausgewählte Löschaktionen explizit aus.",
"body": "Der Bereich ist nur mit dem Leserecht für Datenschutzanfragen sichtbar. Anlage und Suche, Export sowie Löschausführung besitzen getrennte Rechte. Betroffene Personen können über Konto, Mitgliedschaft, Identität, E-Mail-Adresse und namensraumgebundene Quellsystemreferenzen gesucht werden. Module ohne Anbieter werden nicht als durchsucht dargestellt. Ein Löschplan trennt ausführbare Aktionen von aufzubewahrenden oder manuell zu prüfenden Einträgen. Die Ausführung verlangt die aktuelle Revision, eine Aktionsauswahl und eine exakte Bestätigung.",
}
},
metadata={
"kind": "workflow",
"help_contexts": ["admin.privacy.data-subject-requests"],
"route": "/admin?section=tenant-data-subject-requests",
"api_path": "/api/v1/admin/privacy/data-subject-requests",
},
),
), ),
frontend=FrontendModule( frontend=FrontendModule(
module_id="admin", module_id="admin",
@@ -183,6 +220,7 @@ manifest = ModuleManifest(
ViewSurface(id="admin.section.system-groups", module_id="admin", kind="section", label="Group templates", order=50), ViewSurface(id="admin.section.system-groups", module_id="admin", kind="section", label="Group templates", order=50),
ViewSurface(id="admin.section.system-modules", module_id="admin", kind="section", label="Modules", order=85), ViewSurface(id="admin.section.system-modules", module_id="admin", kind="section", label="Modules", order=85),
ViewSurface(id="admin.section.system-tenant-modules", module_id="admin", kind="section", label="Tenant modules", order=86), ViewSurface(id="admin.section.system-tenant-modules", module_id="admin", kind="section", label="Tenant modules", order=86),
ViewSurface(id="admin.section.tenant-data-subject-requests", module_id="admin", kind="section", label="Data-subject requests", order=55),
ViewSurface(id="admin.section.tenant-modules", module_id="admin", kind="section", label="Modules", order=60), ViewSurface(id="admin.section.tenant-modules", module_id="admin", kind="section", label="Modules", order=60),
), ),
), ),
+159 -1
View File
@@ -1,5 +1,5 @@
import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy } from "@govoplan/core-webui"; import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy } from "@govoplan/core-webui";
import { apiFetch, apiPath, apiQuery } from "@govoplan/core-webui"; import { apiDownload, apiFetch, apiPath, apiQuery } from "@govoplan/core-webui";
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui"; export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
export type { export type {
AdminOverview, AdminOverview,
@@ -565,6 +565,102 @@ export type GovernanceTemplateItem = {
updated_at: string; updated_at: string;
}; };
export type DataSubjectSelector = {
account_id?: string | null;
identity_id?: string | null;
membership_id?: string | null;
email?: string | null;
external_references?: Record<string, string>;
};
export type DataSubjectRequestSummary = {
id: string;
tenant_id: string;
reference: string;
request_kind: "access" | "erasure" | "access_and_erasure";
status: string;
subject: DataSubjectSelector;
purpose: string;
legal_basis?: string | null;
due_at?: string | null;
requested_by_account_id: string;
record_count: number;
executable_action_count: number;
coverage: {
covered_modules?: string[];
modules_without_provider?: string[];
provider_discovery_failures?: Array<Record<string, unknown>>;
};
evidence_sha256?: string | null;
resource_revision: number;
etag: string;
created_at: string;
updated_at: string;
completed_at?: string | null;
notes?: string | null;
};
export type DataSubjectRecord = {
provider_id: string;
module_id: string;
resource_type: string;
resource_id: string;
category: string;
title: string;
data: Record<string, unknown>;
observed_at?: string | null;
immutable_evidence: boolean;
retention_reason?: string | null;
source_path?: string | null;
};
export type DataSubjectErasureAction = {
action_id: string;
provider_id: string;
module_id: string;
kind: string;
resource_type: string;
resource_id: string;
title: string;
rationale: string;
executable: boolean;
irreversible: boolean;
metadata: Record<string, unknown>;
};
export type DataSubjectRequestDetail = {
request: DataSubjectRequestSummary;
search: {
records?: DataSubjectRecord[];
provider_runs?: Array<Record<string, unknown>>;
searched_at?: string;
record_count?: number;
};
erasure_plan: {
actions?: DataSubjectErasureAction[];
provider_runs?: Array<Record<string, unknown>>;
planned_at?: string;
executable_count?: number;
retained_count?: number;
};
execution: {
results?: Array<{ action_id: string; status: string; summary: string; evidence?: Record<string, unknown> }>;
executed_at?: string;
successful_count?: number;
failed_count?: number;
};
};
export type DataSubjectRequestCreatePayload = {
reference: string;
request_kind: DataSubjectRequestSummary["request_kind"];
subject: DataSubjectSelector;
purpose: string;
legal_basis?: string | null;
due_at?: string | null;
notes?: string | null;
};
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> { export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings"); return apiFetch(settings, "/api/v1/admin/system/settings");
} }
@@ -713,6 +809,68 @@ export function deleteGovernanceTemplate(settings: ApiSettings, templateId: stri
return apiFetch(settings, apiPath(`/api/v1/admin/system/governance-templates/${templateId}`, { change_request_id: changeRequestId }), { method: "DELETE" }); return apiFetch(settings, apiPath(`/api/v1/admin/system/governance-templates/${templateId}`, { change_request_id: changeRequestId }), { method: "DELETE" });
} }
export async function fetchDataSubjectRequests(settings: ApiSettings): Promise<DataSubjectRequestSummary[]> {
const response = await apiFetch<{ items: DataSubjectRequestSummary[] }>(settings, "/api/v1/admin/privacy/data-subject-requests");
return response.items;
}
export function fetchDataSubjectRequest(settings: ApiSettings, requestId: string): Promise<DataSubjectRequestDetail> {
return apiFetch(settings, `/api/v1/admin/privacy/data-subject-requests/${encodeURIComponent(requestId)}`);
}
export function createDataSubjectRequest(settings: ApiSettings, payload: DataSubjectRequestCreatePayload): Promise<DataSubjectRequestDetail> {
return apiFetch(settings, "/api/v1/admin/privacy/data-subject-requests", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function searchDataSubjectRequest(settings: ApiSettings, request: DataSubjectRequestSummary): Promise<DataSubjectRequestDetail> {
return mutateDataSubjectRequest(settings, request, "search", {});
}
export function planDataSubjectErasure(settings: ApiSettings, request: DataSubjectRequestSummary): Promise<DataSubjectRequestDetail> {
return mutateDataSubjectRequest(settings, request, "erasure-plan", {});
}
export function executeDataSubjectErasure(
settings: ApiSettings,
request: DataSubjectRequestSummary,
actionIds: string[],
confirmation: string
): Promise<DataSubjectRequestDetail> {
return mutateDataSubjectRequest(settings, request, "execute", {
action_ids: actionIds,
confirmation
});
}
export function exportDataSubjectRequest(settings: ApiSettings, request: DataSubjectRequestSummary): Promise<void> {
const safeReference = request.reference.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
return apiDownload(
settings,
`/api/v1/admin/privacy/data-subject-requests/${encodeURIComponent(request.id)}/export`,
`dsar-${safeReference || request.id}.json`
);
}
function mutateDataSubjectRequest(
settings: ApiSettings,
request: DataSubjectRequestSummary,
operation: string,
payload: Record<string, unknown>
): Promise<DataSubjectRequestDetail> {
return apiFetch(
settings,
`/api/v1/admin/privacy/data-subject-requests/${encodeURIComponent(request.id)}/${operation}`,
{
method: "POST",
headers: { "If-Match": request.etag },
body: JSON.stringify({ base_revision: request.resource_revision, ...payload })
}
);
}
export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> { export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> {
return apiFetch(settings, "/api/v1/admin/configuration-changes"); return apiFetch(settings, "/api/v1/admin/configuration-changes");
} }
@@ -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]] : [];
}));
}
+122
View File
@@ -2,6 +2,67 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-admin.data_subject_requests.ds001": "Data-subject requests",
"i18n:govoplan-admin.data_subject_requests.ds002": "Search, export, and govern erasure requests with explicit provider coverage and retained evidence.",
"i18n:govoplan-admin.data_subject_requests.ds003": "Create request",
"i18n:govoplan-admin.data_subject_requests.ds004": "Reload",
"i18n:govoplan-admin.data_subject_requests.ds005": "Reference",
"i18n:govoplan-admin.data_subject_requests.ds006": "Request type",
"i18n:govoplan-admin.data_subject_requests.ds007": "Status",
"i18n:govoplan-admin.data_subject_requests.ds008": "Data subject",
"i18n:govoplan-admin.data_subject_requests.ds009": "Due",
"i18n:govoplan-admin.data_subject_requests.ds010": "Actions",
"i18n:govoplan-admin.data_subject_requests.ds011": "Inspect request",
"i18n:govoplan-admin.data_subject_requests.ds012": "No data-subject requests have been recorded.",
"i18n:govoplan-admin.data_subject_requests.ds013": "Records",
"i18n:govoplan-admin.data_subject_requests.ds014": "Executable actions",
"i18n:govoplan-admin.data_subject_requests.ds015": "Modules without provider",
"i18n:govoplan-admin.data_subject_requests.ds016": "Run search",
"i18n:govoplan-admin.data_subject_requests.ds017": "Create erasure plan",
"i18n:govoplan-admin.data_subject_requests.ds018": "Export JSON",
"i18n:govoplan-admin.data_subject_requests.ds019": "Execute selected actions",
"i18n:govoplan-admin.data_subject_requests.ds020": "Collected records",
"i18n:govoplan-admin.data_subject_requests.ds021": "Erasure plan",
"i18n:govoplan-admin.data_subject_requests.ds022": "Provider coverage",
"i18n:govoplan-admin.data_subject_requests.ds023": "Select a request to inspect its collected records and decisions.",
"i18n:govoplan-admin.data_subject_requests.ds024": "Provider",
"i18n:govoplan-admin.data_subject_requests.ds025": "Category",
"i18n:govoplan-admin.data_subject_requests.ds026": "Evidence handling",
"i18n:govoplan-admin.data_subject_requests.ds027": "Decision",
"i18n:govoplan-admin.data_subject_requests.ds028": "Rationale",
"i18n:govoplan-admin.data_subject_requests.ds029": "Select",
"i18n:govoplan-admin.data_subject_requests.ds030": "Create data-subject request",
"i18n:govoplan-admin.data_subject_requests.ds031": "Purpose",
"i18n:govoplan-admin.data_subject_requests.ds032": "Legal basis",
"i18n:govoplan-admin.data_subject_requests.ds033": "Email",
"i18n:govoplan-admin.data_subject_requests.ds034": "Account ID",
"i18n:govoplan-admin.data_subject_requests.ds035": "Membership ID",
"i18n:govoplan-admin.data_subject_requests.ds036": "Identity ID",
"i18n:govoplan-admin.data_subject_requests.ds037": "Notes",
"i18n:govoplan-admin.data_subject_requests.ds038": "Save",
"i18n:govoplan-admin.data_subject_requests.ds039": "Cancel",
"i18n:govoplan-admin.data_subject_requests.ds040": "Execute erasure actions",
"i18n:govoplan-admin.data_subject_requests.ds041": "These actions may be irreversible. Review retained evidence and provider coverage before continuing.",
"i18n:govoplan-admin.data_subject_requests.ds042": "Confirmation",
"i18n:govoplan-admin.data_subject_requests.ds043": "Enter the exact confirmation phrase",
"i18n:govoplan-admin.data_subject_requests.ds044": "Provide at least one subject selector.",
"i18n:govoplan-admin.data_subject_requests.ds045": "The export is not complete across every enabled module.",
"i18n:govoplan-admin.data_subject_requests.ds046": "Data-subject request created.",
"i18n:govoplan-admin.data_subject_requests.ds047": "Provider search completed.",
"i18n:govoplan-admin.data_subject_requests.ds048": "Erasure plan created.",
"i18n:govoplan-admin.data_subject_requests.ds049": "Selected erasure actions were processed.",
"i18n:govoplan-admin.data_subject_requests.ds050": "Data-subject export downloaded.",
"i18n:govoplan-admin.data_subject_requests.ds051": "Access",
"i18n:govoplan-admin.data_subject_requests.ds052": "Erasure",
"i18n:govoplan-admin.data_subject_requests.ds053": "Access and erasure",
"i18n:govoplan-admin.data_subject_requests.ds054": "Mutable",
"i18n:govoplan-admin.data_subject_requests.ds055": "Retained evidence",
"i18n:govoplan-admin.data_subject_requests.ds056": "Irreversible",
"i18n:govoplan-admin.data_subject_requests.ds057": "Executable",
"i18n:govoplan-admin.data_subject_requests.ds058": "Retained / review",
"i18n:govoplan-admin.data_subject_requests.ds059": "External references",
"i18n:govoplan-admin.data_subject_requests.ds060": "Enter one namespace=reference pair per line for identifiers owned by source systems.",
"i18n:govoplan-admin.data_subject_requests.ds061": "Each external reference must use namespace=reference on its own line.",
"i18n:govoplan-admin.search_module_directory.7c8f1001": "Search module directory", "i18n:govoplan-admin.search_module_directory.7c8f1001": "Search module directory",
"i18n:govoplan-admin.module_name_package_or_tag.7c8f1002": "Module name, package, or tag", "i18n:govoplan-admin.module_name_package_or_tag.7c8f1002": "Module name, package, or tag",
"i18n:govoplan-admin.filter_module_directory.7c8f1003": "Filter module directory", "i18n:govoplan-admin.filter_module_directory.7c8f1003": "Filter module directory",
@@ -454,6 +515,67 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.working.049ac820": "Working..." "i18n:govoplan-admin.working.049ac820": "Working..."
}, },
"de": { "de": {
"i18n:govoplan-admin.data_subject_requests.ds001": "Betroffenenanfragen",
"i18n:govoplan-admin.data_subject_requests.ds002": "Auskunfts- und Löschanfragen mit expliziter Anbieterabdeckung und Aufbewahrungsnachweisen suchen, exportieren und steuern.",
"i18n:govoplan-admin.data_subject_requests.ds003": "Anfrage anlegen",
"i18n:govoplan-admin.data_subject_requests.ds004": "Neu laden",
"i18n:govoplan-admin.data_subject_requests.ds005": "Aktenzeichen",
"i18n:govoplan-admin.data_subject_requests.ds006": "Anfrageart",
"i18n:govoplan-admin.data_subject_requests.ds007": "Status",
"i18n:govoplan-admin.data_subject_requests.ds008": "Betroffene Person",
"i18n:govoplan-admin.data_subject_requests.ds009": "Fällig",
"i18n:govoplan-admin.data_subject_requests.ds010": "Aktionen",
"i18n:govoplan-admin.data_subject_requests.ds011": "Anfrage prüfen",
"i18n:govoplan-admin.data_subject_requests.ds012": "Es wurden noch keine Betroffenenanfragen erfasst.",
"i18n:govoplan-admin.data_subject_requests.ds013": "Datensätze",
"i18n:govoplan-admin.data_subject_requests.ds014": "Ausführbare Aktionen",
"i18n:govoplan-admin.data_subject_requests.ds015": "Module ohne Anbieter",
"i18n:govoplan-admin.data_subject_requests.ds016": "Suche ausführen",
"i18n:govoplan-admin.data_subject_requests.ds017": "Löschplan erstellen",
"i18n:govoplan-admin.data_subject_requests.ds018": "JSON exportieren",
"i18n:govoplan-admin.data_subject_requests.ds019": "Ausgewählte Aktionen ausführen",
"i18n:govoplan-admin.data_subject_requests.ds020": "Gefundene Datensätze",
"i18n:govoplan-admin.data_subject_requests.ds021": "Löschplan",
"i18n:govoplan-admin.data_subject_requests.ds022": "Anbieterabdeckung",
"i18n:govoplan-admin.data_subject_requests.ds023": "Wählen Sie eine Anfrage aus, um Datensätze und Entscheidungen zu prüfen.",
"i18n:govoplan-admin.data_subject_requests.ds024": "Anbieter",
"i18n:govoplan-admin.data_subject_requests.ds025": "Kategorie",
"i18n:govoplan-admin.data_subject_requests.ds026": "Nachweisbehandlung",
"i18n:govoplan-admin.data_subject_requests.ds027": "Entscheidung",
"i18n:govoplan-admin.data_subject_requests.ds028": "Begründung",
"i18n:govoplan-admin.data_subject_requests.ds029": "Auswählen",
"i18n:govoplan-admin.data_subject_requests.ds030": "Betroffenenanfrage anlegen",
"i18n:govoplan-admin.data_subject_requests.ds031": "Zweck",
"i18n:govoplan-admin.data_subject_requests.ds032": "Rechtsgrundlage",
"i18n:govoplan-admin.data_subject_requests.ds033": "E-Mail",
"i18n:govoplan-admin.data_subject_requests.ds034": "Konto-ID",
"i18n:govoplan-admin.data_subject_requests.ds035": "Mitgliedschafts-ID",
"i18n:govoplan-admin.data_subject_requests.ds036": "Identitäts-ID",
"i18n:govoplan-admin.data_subject_requests.ds037": "Notizen",
"i18n:govoplan-admin.data_subject_requests.ds038": "Speichern",
"i18n:govoplan-admin.data_subject_requests.ds039": "Abbrechen",
"i18n:govoplan-admin.data_subject_requests.ds040": "Löschaktionen ausführen",
"i18n:govoplan-admin.data_subject_requests.ds041": "Diese Aktionen können unumkehrbar sein. Prüfen Sie Aufbewahrungsnachweise und Anbieterabdeckung, bevor Sie fortfahren.",
"i18n:govoplan-admin.data_subject_requests.ds042": "Bestätigung",
"i18n:govoplan-admin.data_subject_requests.ds043": "Geben Sie die Bestätigungsphrase exakt ein",
"i18n:govoplan-admin.data_subject_requests.ds044": "Geben Sie mindestens ein Merkmal der betroffenen Person an.",
"i18n:govoplan-admin.data_subject_requests.ds045": "Der Export deckt nicht alle aktivierten Module vollständig ab.",
"i18n:govoplan-admin.data_subject_requests.ds046": "Betroffenenanfrage wurde angelegt.",
"i18n:govoplan-admin.data_subject_requests.ds047": "Anbietersuche wurde abgeschlossen.",
"i18n:govoplan-admin.data_subject_requests.ds048": "Löschplan wurde erstellt.",
"i18n:govoplan-admin.data_subject_requests.ds049": "Ausgewählte Löschaktionen wurden verarbeitet.",
"i18n:govoplan-admin.data_subject_requests.ds050": "Betroffenenauskunft wurde heruntergeladen.",
"i18n:govoplan-admin.data_subject_requests.ds051": "Auskunft",
"i18n:govoplan-admin.data_subject_requests.ds052": "Löschung",
"i18n:govoplan-admin.data_subject_requests.ds053": "Auskunft und Löschung",
"i18n:govoplan-admin.data_subject_requests.ds054": "Veränderbar",
"i18n:govoplan-admin.data_subject_requests.ds055": "Aufzubewahrender Nachweis",
"i18n:govoplan-admin.data_subject_requests.ds056": "Nicht umkehrbar",
"i18n:govoplan-admin.data_subject_requests.ds057": "Ausführbar",
"i18n:govoplan-admin.data_subject_requests.ds058": "Aufbewahren / prüfen",
"i18n:govoplan-admin.data_subject_requests.ds059": "Externe Referenzen",
"i18n:govoplan-admin.data_subject_requests.ds060": "Je Zeile ein Paar Namensraum=Referenz für Kennungen aus Quellsystemen eingeben.",
"i18n:govoplan-admin.data_subject_requests.ds061": "Jede externe Referenz muss in einer eigenen Zeile als Namensraum=Referenz angegeben werden.",
"i18n:govoplan-admin.search_module_directory.7c8f1001": "Modulverzeichnis durchsuchen", "i18n:govoplan-admin.search_module_directory.7c8f1001": "Modulverzeichnis durchsuchen",
"i18n:govoplan-admin.module_name_package_or_tag.7c8f1002": "Modulname, Paket oder Schlagwort", "i18n:govoplan-admin.module_name_package_or_tag.7c8f1002": "Modulname, Paket oder Schlagwort",
"i18n:govoplan-admin.filter_module_directory.7c8f1003": "Modulverzeichnis filtern", "i18n:govoplan-admin.filter_module_directory.7c8f1003": "Modulverzeichnis filtern",
+18
View File
@@ -11,6 +11,7 @@ const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceT
const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel")); const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel"));
const TenantModuleManagementPanel = lazy(() => import("./features/admin/TenantModuleManagementPanel")); const TenantModuleManagementPanel = lazy(() => import("./features/admin/TenantModuleManagementPanel"));
const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel")); const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel"));
const DataSubjectRequestsPanel = lazy(() => import("./features/admin/DataSubjectRequestsPanel"));
const translations = { const translations = {
en: generatedTranslations.en, en: generatedTranslations.en,
@@ -124,6 +125,22 @@ const adminSections: AdminSectionsUiCapability = {
canWrite: hasScope(auth, "system:settings:write") canWrite: hasScope(auth, "system:settings:write")
}) })
}, },
{
id: "tenant-data-subject-requests",
moduleId: "admin",
kind: "management",
surfaceId: "admin.section.tenant-data-subject-requests",
label: "i18n:govoplan-admin.data_subject_requests.ds001",
group: "TENANT",
order: 55,
allOf: ["access:privacy:read"],
render: ({ settings, auth }) => createElement(DataSubjectRequestsPanel, {
settings,
canManage: hasScope(auth, "access:privacy:manage"),
canExport: hasScope(auth, "access:privacy:export"),
canErase: hasScope(auth, "access:privacy:erase")
})
},
{ {
id: "tenant-modules", id: "tenant-modules",
moduleId: "admin", moduleId: "admin",
@@ -173,6 +190,7 @@ export const adminModule: PlatformWebModule = {
{ id: "admin.section.system-groups", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.central_groups.5c9b5b66", order: 50 }, { id: "admin.section.system-groups", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.central_groups.5c9b5b66", order: 50 },
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.modules.04e9462c", order: 85 }, { id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.modules.04e9462c", order: 85 },
{ id: "admin.section.system-tenant-modules", moduleId: "admin", kind: "section", label: "Tenant modules", order: 86 }, { id: "admin.section.system-tenant-modules", moduleId: "admin", kind: "section", label: "Tenant modules", order: 86 },
{ id: "admin.section.tenant-data-subject-requests", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.data_subject_requests.ds001", order: 55 },
{ id: "admin.section.tenant-modules", moduleId: "admin", kind: "section", label: "Modules", order: 60 } { id: "admin.section.tenant-modules", moduleId: "admin", kind: "section", label: "Modules", order: 60 }
], ],
uiCapabilities: { uiCapabilities: {