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
+23 -3
View File
@@ -385,22 +385,42 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
// Fetch user info before deletion for audit log and IMAP cleanup
target, err := s.users.GetByID(id)
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
if err := s.users.DeleteSafe(id); err != nil {
if err.Error() == "userstore: cannot delete last admin" {
writeError(w, http.StatusConflict, "cannot delete the last active admin")
return
}
writeError(w, http.StatusNotFound, err.Error())
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Remove all IMAP accounts that belonged to this user
imapDeleted := 0
if s.imapStore != nil {
if n, err := s.imapStore.DeleteByOwner(r.Context(), target.Username); err != nil {
s.logger.Warn("delete user: could not remove IMAP accounts", "user", target.Username, "err", err)
} else {
imapDeleted = n
}
}
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
IPAddress: remoteIP(r),
Detail: fmt.Sprintf("deleted user %d", id),
Success: true,
Detail: fmt.Sprintf(
"deleted user %d (%s, role=%s); %d IMAP account(s) removed; emails retained per GoBD",
id, target.Username, target.Role, imapDeleted,
),
Success: true,
})
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})