feat(dsgvo): Art. 17 Anonymisierung von Mitarbeiterkonten

Statt Hard-Delete (kollidiert mit Lohn-/Steuer-Aufbewahrung) werden bei
DSGVO-Löschanfrage die personenbezogenen Felder der bestehenden User-Zeile
überschrieben; referenzierte Daten (Zeit/Abwesenheit/Auszahlung/AuditLog)
bleiben entkoppelt erhalten.

- users.anonymized_at (Migration 0040); UserOut.anonymized_at.
- user_service.anonymize_user: überschreibt Name/E-Mail (eindeutig anon-<id>@deleted.local),
  entfernt Auth-Material (Passwort/PIN/QR/NFC/TOTP/LDAP-DN), is_active=False, löscht Sessions,
  AuditLog 'user_anonymized'. Personalnummer bleibt reserviert (kein Personenbezug mehr).
  Selbst-Anonymisierung (400), Doppelanonymisierung/Reaktivierung anonymisierter Konten (409).
- POST /users/{id}/anonymize (COMPANY_ADMIN/SUPER_ADMIN, mandantengeprüft).
- UsersPage: "Anonymisieren"-Button mit Sicherheitsabfrage + "Anonymisiert"-Badge.

193/193 Tests grün. Deployed 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:31:14 +02:00
co-authored by Claude Opus 4.8
parent 3034c6c55a
commit c122ab6fd7
7 changed files with 153 additions and 2 deletions
+31 -2
View File
@@ -20,6 +20,7 @@ interface UserOut {
can_manual_time_entry: boolean
entry_date: string | null
exit_date: string | null
anonymized_at: string | null
last_login: string | null
created_at: string
}
@@ -221,6 +222,21 @@ export function UsersPage() {
}
}
async function handleAnonymize(user: UserOut) {
if (!confirm(
`DSGVO-Anonymisierung von ${user.full_name}?\n\n` +
`Name und E-Mail werden unwiderruflich entfernt, das Konto wird deaktiviert. ` +
`Zeit-, Abwesenheits- und Auszahlungsdaten bleiben (gesetzliche Aufbewahrung), ` +
`aber ohne Personenbezug. Nicht umkehrbar.`
)) return
try {
await api.post(`/users/${user.id}/anonymize`, {})
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
async function handleEditRole() {
if (!editUser) return
setEditLoading(true)
@@ -415,14 +431,19 @@ export function UsersPage() {
</td>
<td className='px-4 py-3'>
<Badge
text={u.is_active ? 'Aktiv' : 'Inaktiv'}
color={u.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}
text={u.anonymized_at ? 'Anonymisiert' : u.is_active ? 'Aktiv' : 'Inaktiv'}
color={u.anonymized_at ? 'bg-gray-200 text-gray-500' : u.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}
/>
</td>
<td className='px-4 py-3 text-gray-500'>
{u.last_login ? new Date(u.last_login).toLocaleDateString('de-DE') : '—'}
</td>
<td className='px-4 py-3'>
{u.anonymized_at ? (
<div className='flex justify-end text-xs text-gray-400 italic'>
anonymisiert am {new Date(u.anonymized_at).toLocaleDateString('de-DE')}
</div>
) : (
<div className='flex gap-2 justify-end'>
<button
onClick={() => {
@@ -450,7 +471,15 @@ export function UsersPage() {
Reaktivieren
</button>
)}
<button
onClick={() => handleAnonymize(u)}
className='text-xs text-gray-400 hover:text-red-600 hover:underline'
title='DSGVO Art. 17 Personenbezug entfernen (nicht umkehrbar)'
>
Anonymisieren
</button>
</div>
)}
</td>
</tr>
))}