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 }
|
{ 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": {
|
"index": {
|
||||||
"seitewirdgeladen": "Seite wird geladen"
|
"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 ComplianceIncident,
|
||||||
type RetentionPolicyEntry,
|
type RetentionPolicyEntry,
|
||||||
type IncidentCreate,
|
type IncidentCreate,
|
||||||
|
submitDsarRequest,
|
||||||
|
downloadDsgvoExport,
|
||||||
|
type DsarRequestResponse,
|
||||||
|
type DsarType,
|
||||||
} from '../api/compliance';
|
} from '../api/compliance';
|
||||||
|
import { useUsers } from '../api/users';
|
||||||
|
|
||||||
type SubTab = 'registry' | 'incidents' | 'retention';
|
type SubTab = 'registry' | 'incidents' | 'retention' | 'dsar';
|
||||||
|
|
||||||
export function ComplianceTab() {
|
export function ComplianceTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -25,6 +30,7 @@ export function ComplianceTab() {
|
|||||||
{ key: 'registry', label: t('compliance.aiRegistry', 'AI-Register') },
|
{ key: 'registry', label: t('compliance.aiRegistry', 'AI-Register') },
|
||||||
{ key: 'incidents', label: t('compliance.incidents', 'Vorfälle') },
|
{ key: 'incidents', label: t('compliance.incidents', 'Vorfälle') },
|
||||||
{ key: 'retention', label: t('compliance.retention', 'Aufbewahrung') },
|
{ key: 'retention', label: t('compliance.retention', 'Aufbewahrung') },
|
||||||
|
{ key: 'dsar', label: t('compliance.dsar.tab', 'DSGVO-Anfragen') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,6 +59,7 @@ export function ComplianceTab() {
|
|||||||
{subTab === 'registry' && <AIRegistryPanel />}
|
{subTab === 'registry' && <AIRegistryPanel />}
|
||||||
{subTab === 'incidents' && <IncidentsPanel />}
|
{subTab === 'incidents' && <IncidentsPanel />}
|
||||||
{subTab === 'retention' && <RetentionPanel />}
|
{subTab === 'retention' && <RetentionPanel />}
|
||||||
|
{subTab === 'dsar' && <DsarPanel />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -461,3 +468,135 @@ function RetentionPanel() {
|
|||||||
</div>
|
</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