feat(PROJ-20): GoBD-konforme Nutzer-Löschung mit IMAP-Cleanup und Warn-Dialog

- imap/store.go: DeleteByOwner() – löscht alle IMAP-Konten eines Nutzers
- api/server.go: handleDeleteUser lädt Nutzername vor Löschung, ruft DeleteByOwner, schreibt erweitertes Audit-Log (username, role, IMAP-Count, GoBD-Hinweis)
- admin/page.tsx: confirm() ersetzt durch Dialog mit GoBD-Hinweis, Deaktivieren-Option (empfohlen) und endgültigem Löschen (destruktiv)
- features/PROJ-20-nutzer-loeschung.md: Feature-Spec angelegt
- features/INDEX.md: PROJ-20 eingetragen, Next ID → PROJ-21

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 13:44:32 +01:00
parent 2900e53753
commit e46b68b63f
5 changed files with 214 additions and 10 deletions
+84 -7
View File
@@ -108,6 +108,11 @@ export default function AdminPage() {
const [resetPasswordError, setResetPasswordError] = useState("");
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
// Delete confirmation dialog
const [deleteDialogUser, setDeleteDialogUser] = useState<User | null>(null);
const [deleteActionLoading, setDeleteActionLoading] = useState<"deactivate" | "delete" | null>(null);
const [deleteDialogError, setDeleteDialogError] = useState("");
// Audit state
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const [auditTotal, setAuditTotal] = useState(0);
@@ -290,16 +295,33 @@ export default function AdminPage() {
}
}
async function handleDeleteUser(u: User) {
if (!confirm(`Benutzer "${u.username}" wirklich löschen?`)) return;
setUserActionLoading(u.id);
async function handleDeactivateConfirmed() {
if (!deleteDialogUser) return;
setDeleteActionLoading("deactivate");
setDeleteDialogError("");
try {
await deleteUser(u.id);
await updateUser(deleteDialogUser.id, { active: false });
setDeleteDialogUser(null);
loadUsers();
} catch {
setDeleteDialogError("Deaktivierung fehlgeschlagen.");
} finally {
setDeleteActionLoading(null);
}
}
async function handleDeleteConfirmed() {
if (!deleteDialogUser) return;
setDeleteActionLoading("delete");
setDeleteDialogError("");
try {
await deleteUser(deleteDialogUser.id);
setDeleteDialogUser(null);
loadUsers();
} catch (err: unknown) {
alert(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
setDeleteDialogError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setUserActionLoading(null);
setDeleteActionLoading(null);
}
}
@@ -974,7 +996,7 @@ export default function AdminPage() {
size="sm"
variant="destructive"
disabled={userActionLoading === u.id}
onClick={() => handleDeleteUser(u)}
onClick={() => { setDeleteDialogUser(u); setDeleteDialogError(""); }}
>
Löschen
</Button>
@@ -1203,6 +1225,61 @@ export default function AdminPage() {
</form>
</DialogContent>
</Dialog>
{/* Nutzer-Löschung Dialog */}
<Dialog open={deleteDialogUser !== null} onOpenChange={(open) => { if (!open) setDeleteDialogUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Benutzer entfernen</DialogTitle>
<DialogDescription>
Was soll mit dem Konto <strong>{deleteDialogUser?.username}</strong> passieren?
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800">
<strong>Hinweis (GoBD):</strong> E-Mails bleiben unabhängig von dieser Aktion im Archiv erhalten. Die gesetzliche Aufbewahrungspflicht besteht auch nach Ausscheiden des Mitarbeiters.
</div>
<div className="space-y-3">
<div className="rounded-md border p-3">
<p className="font-medium text-sm">Konto deaktivieren (empfohlen)</p>
<p className="text-xs text-muted-foreground mt-1">
Login wird gesperrt. Konto und IMAP-Verbindungen bleiben erhalten und können reaktiviert werden.
</p>
</div>
<div className="rounded-md border border-destructive/30 p-3">
<p className="font-medium text-sm text-destructive">Konto endgültig löschen</p>
<p className="text-xs text-muted-foreground mt-1">
Account und alle IMAP-Verbindungen werden dauerhaft entfernt. Nicht rückgängig zu machen.
</p>
</div>
</div>
{deleteDialogError && (
<p className="text-sm text-destructive">{deleteDialogError}</p>
)}
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => setDeleteDialogUser(null)} disabled={deleteActionLoading !== null}>
Abbrechen
</Button>
<Button
variant="outline"
onClick={handleDeactivateConfirmed}
disabled={deleteActionLoading !== null || deleteDialogUser?.active === false}
>
{deleteActionLoading === "deactivate" ? "Wird deaktiviert..." : "Deaktivieren"}
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirmed}
disabled={deleteActionLoading !== null}
>
{deleteActionLoading === "delete" ? "Wird gelöscht..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}