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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user