feat(compliance): G1-b frontend DSAR status UI — 4th subtab in ComplianceTab: type selection (Art.15/17/16), person picker, direct GDPR export download, two-step deletion confirmation; uses existing system-settings DSAR endpoints

This commit is contained in:
Agent Zero
2026-08-27 01:49:55 +02:00
parent 4cb5298768
commit 05bc1e2543
3 changed files with 196 additions and 1 deletions
+38
View File
@@ -145,3 +145,41 @@ export async function updateRetentionPolicy(
{ days }
);
}
// ─── DSAR (GDPR Art. 15/17/20) ───
export type DsarType = 'access' | 'deletion' | 'rectification';
export interface DsarRequestResponse {
job_id: string;
status: string;
type: DsarType;
user_id: string;
}
/** Queue a DSAR job for a user. Admin only. */
export async function submitDsarRequest(
userId: string,
type: DsarType
): Promise<DsarRequestResponse> {
return apiPost<DsarRequestResponse>(`/system-settings/dsar/${userId}`, {
type,
});
}
/** Stream the full GDPR data export for a user and trigger a browser download. */
export async function downloadDsgvoExport(userId: string, userName?: string): Promise<void> {
const response = await apiGet<Blob>(`/system-settings/dsgvo-export/${userId}`, {
responseType: 'blob',
});
const blob = new Blob([response], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const safeName = (userName ?? userId).replace(/[^a-z0-9_-]/gi, '_');
link.download = `dsgvo_export_${safeName}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
+18
View File
@@ -2051,5 +2051,23 @@
},
"index": {
"seitewirdgeladen": "Seite wird geladen"
},
"compliance": {
"dsar": {
"tab": "DSGVO-Anfragen",
"description": "DSGVO-Anfrage für eine Person auslösen: Auskunft (Art. 15), Löschung (Art. 17) oder Berichtigung (Art. 16). Der Vorgang wird als Hintergrund-Job durch den Worker ausgeführt.",
"person": "Person",
"selectPerson": "-- Bitte wählen --",
"requestType": "Antragsart",
"typeAccess": "Auskunft (Art. 15)",
"typeDeletion": "Löschung (Art. 17)",
"typeRectification": "Berichtigung (Art. 16)",
"downloadExport": "Datenexport herunterladen",
"submit": "Anfrage stellen",
"confirmDeletion": "Wirklich löschen? Unwiderruflich!",
"chooseFirst": "Bitte zuerst eine Person wählen.",
"queued": "Anfrage eingereicht — Job",
"statusQueued": "(in Warteschlange). Die Bearbeitung erfolgt im Hintergrund."
}
}
}
+140 -1
View File
@@ -13,9 +13,14 @@ import {
type ComplianceIncident,
type RetentionPolicyEntry,
type IncidentCreate,
submitDsarRequest,
downloadDsgvoExport,
type DsarRequestResponse,
type DsarType,
} from '../api/compliance';
import { useUsers } from '../api/users';
type SubTab = 'registry' | 'incidents' | 'retention';
type SubTab = 'registry' | 'incidents' | 'retention' | 'dsar';
export function ComplianceTab() {
const { t } = useTranslation();
@@ -25,6 +30,7 @@ export function ComplianceTab() {
{ key: 'registry', label: t('compliance.aiRegistry', 'AI-Register') },
{ key: 'incidents', label: t('compliance.incidents', 'Vorfälle') },
{ key: 'retention', label: t('compliance.retention', 'Aufbewahrung') },
{ key: 'dsar', label: t('compliance.dsar.tab', 'DSGVO-Anfragen') },
];
return (
@@ -53,6 +59,7 @@ export function ComplianceTab() {
{subTab === 'registry' && <AIRegistryPanel />}
{subTab === 'incidents' && <IncidentsPanel />}
{subTab === 'retention' && <RetentionPanel />}
{subTab === 'dsar' && <DsarPanel />}
</div>
);
}
@@ -461,3 +468,135 @@ function RetentionPanel() {
</div>
);
}
// ─── DSAR Panel (GDPR Art. 15/17/20) ───
const DSAR_TYPES: { value: DsarType; labelKey: string; fallback: string }[] = [
{ value: 'access', labelKey: 'compliance.dsar.typeAccess', fallback: 'Auskunft (Art. 15)' },
{ value: 'deletion', labelKey: 'compliance.dsar.typeDeletion', fallback: 'Löschung (Art. 17)' },
{ value: 'rectification', labelKey: 'compliance.dsar.typeRectification', fallback: 'Berichtigung (Art. 16)' },
];
function DsarPanel() {
const { t } = useTranslation();
const [userId, setUserId] = useState('');
const [dsarType, setDsarType] = useState<DsarType>('access');
const [confirmDelete, setConfirmDelete] = useState(false);
const [lastJob, setLastJob] = useState<DsarRequestResponse | null>(null);
const [exportError, setExportError] = useState(false);
const { data: usersData, isLoading: usersLoading } = useUsers(1, 200);
const dsarMutation = useMutation({
mutationFn: () => submitDsarRequest(userId, dsarType),
onSuccess: (resp) => {
setLastJob(resp);
setConfirmDelete(false);
},
});
const selectedUser = usersData?.items.find((u) => u.id === userId);
const handleExportClick = async () => {
setExportError(false);
try {
await downloadDsgvoExport(userId, selectedUser?.name ?? undefined);
} catch {
setExportError(true);
}
};
const handleSubmit = () => {
if (!userId) return;
if (dsarType === 'deletion' && !confirmDelete) {
setConfirmDelete(true);
return;
}
dsarMutation.mutate();
};
return (
<div className="space-y-4 max-w-2xl">
<p className="text-sm text-secondary-600">{t('compliance.dsar.description', 'DSGVO-Anfrage für eine Person auslösen: Auskunft (Art. 15), Löschung (Art. 17) oder Berichtigung (Art. 16). Der Vorgang wird als Hintergrund-Job durch den Worker ausgeführt.')}</p>
<div>
<label htmlFor="dsar-user-select" className="block text-xs font-medium text-secondary-500 uppercase mb-1">{t('compliance.dsar.person', 'Person')}</label>
<select
id="dsar-user-select"
value={userId}
onChange={(e) => { setUserId(e.target.value); setConfirmDelete(false); }}
className="w-full max-w-md px-3 py-3 border border-secondary-300 rounded bg-white text-sm"
aria-label={t('compliance.dsar.person', 'Person')}
>
<option value="">{usersLoading ? t('common.loading', 'Laden...') : t('compliance.dsar.selectPerson', '-- Bitte wählen --')}</option>
{(usersData?.items ?? []).map((u) => (
<option key={u.id} value={u.id}>{u.name || u.email}</option>
))}
</select>
</div>
<fieldset>
<legend className="block text-xs font-medium text-secondary-500 uppercase mb-1">{t('compliance.dsar.requestType', 'Antragsart')}</legend>
<div className="space-y-1" role="radiogroup" aria-label={t('compliance.dsar.requestType', 'Antragsart')}>
{DSAR_TYPES.map(({ value, labelKey, fallback }) => (
<label
key={value}
className={`flex items-center gap-2 px-3 py-3 min-h-[44px] rounded cursor-pointer border ${
dsarType === value ? 'border-primary-500 bg-primary-50' : 'border-secondary-200 hover:bg-secondary-50'
}`}
>
<input
type="radio"
name="dsar-type"
checked={dsarType === value}
onChange={() => { setDsarType(value); setConfirmDelete(false); }}
aria-label={t(labelKey, fallback)}
/>
<span className="text-sm">{t(labelKey, fallback)}</span>
</label>
))}
</div>
</fieldset>
<div className="flex flex-wrap gap-2 items-center">
{dsarType === 'access' && (
<button
onClick={handleExportClick}
disabled={!userId}
className="px-4 py-3 min-h-[44px] text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded disabled:opacity-50"
aria-label={t('compliance.dsar.downloadExport', 'Datenexport herunterladen')}
>
{t('compliance.dsar.downloadExport', 'Datenexport herunterladen')}
</button>
)}
<button
onClick={handleSubmit}
disabled={!userId || dsarMutation.isPending}
className={`px-4 py-3 min-h-[44px] text-sm font-medium text-white rounded disabled:opacity-50 ${
dsarType === 'deletion' ? 'bg-red-600 hover:bg-red-700' : 'bg-primary-600 hover:bg-primary-700'
}`}
aria-label={t('compliance.dsar.submit', 'Anfrage stellen')}
>
{dsarType === 'deletion' && confirmDelete
? t('compliance.dsar.confirmDeletion', 'Wirklich löschen? Unwiderruflich!')
: t('compliance.dsar.submit', 'Anfrage stellen')}
</button>
{!userId && (
<span className="text-xs text-secondary-400">{t('compliance.dsar.chooseFirst', 'Bitte zuerst eine Person wählen.')}</span>
)}
</div>
{dsarMutation.isPending && <p className="text-sm text-secondary-500" aria-live="polite">{t('common.saving', 'Wird gesendet...')}</p>}
{dsarMutation.isError && <p className="text-sm text-red-600" aria-live="polite">{t('common.errorOccurred', 'Fehler beim Senden')}</p>}
{exportError && <p className="text-sm text-red-600" aria-live="polite">{t('common.errorOccurred', 'Fehler beim Erstellen des Exports')}</p>}
{lastJob && (
<div className="rounded border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800" role="status">
{t('compliance.dsar.queued', 'Anfrage eingereicht — Job')}{' '}
<code className="font-mono">{lastJob.job_id}</code>{' '}
{t('compliance.dsar.statusQueued', '(in Warteschlange). Die Bearbeitung erfolgt im Hintergrund.')}
</div>
)}
</div>
);
}