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
+39
View File
@@ -0,0 +1,39 @@
# Feature Index
> Central tracking for all features. Updated by skills automatically.
## Status Legend
- **Planned** - Requirements written, ready for development
- **In Progress** - Currently being built
- **In Review** - QA testing in progress
- **Deployed** - Live in production
## Features
| ID | Feature | Status | Spec | Created |
|----|---------|--------|------|---------|
| PROJ-1 | Nutzer-Authentifizierung & Rollen (User/Admin) | Deployed | [PROJ-1](PROJ-1-authentifizierung-und-rollen.md) | 2026-03-12 |
| PROJ-2 | E-Mail-Import: EML/MBOX Upload | In Progress | [PROJ-2](PROJ-2-import-eml-mbox.md) | 2026-03-12 |
| PROJ-3 | E-Mail-Import: IMAP-Verbindung | Deployed | [PROJ-3](PROJ-3-import-imap.md) | 2026-03-12 |
| PROJ-4 | E-Mail-Import: SMTP-Eingang via BCC (primär) | Deployed | [PROJ-4](PROJ-4-import-smtp.md) | 2026-03-12 |
| PROJ-5 | E-Mail-Speicherung & Volltext-Indexierung | Deployed | [PROJ-5](PROJ-5-speicherung-und-indexierung.md) | 2026-03-12 |
| PROJ-6 | Volltext-Suche & Filterung | Deployed | [PROJ-6](PROJ-6-volltext-suche.md) | 2026-03-12 |
| PROJ-7 | E-Mail-Ansicht (Lesen & Anhänge) | Deployed | [PROJ-7](PROJ-7-email-ansicht.md) | 2026-03-12 |
| PROJ-8 | Automatischer IMAP-Sync (Cron-Job) | Deployed | [PROJ-8](PROJ-8-imap-auto-sync.md) | 2026-03-12 |
| PROJ-9 | Ordner- & Label-Verwaltung | In Progress | [PROJ-9](PROJ-9-ordner-und-labels.md) | 2026-03-12 |
| PROJ-10 | Admin-Bereich: Nutzer- & Postfachverwaltung | Deployed | [PROJ-10](PROJ-10-admin-bereich.md) | 2026-03-12 |
| PROJ-11 | Audit-Log & Compliance-Berichte | Deployed | [PROJ-11](PROJ-11-audit-log.md) | 2026-03-12 |
| PROJ-12 | E-Mail-Export (EML/PDF) | Deployed | [PROJ-12](PROJ-12-export.md) | 2026-03-12 |
| PROJ-13 | REST API für externe CRM-Anbindung | In Progress | [PROJ-13](PROJ-13-rest-api-crm.md) | 2026-03-13 |
| PROJ-14 | E-Mail-Import: POP3-Verbindung | In Progress | [PROJ-14](PROJ-14-import-pop3.md) | 2026-03-13 |
| PROJ-15 | CLI Import & Export (archivmail-User) | Deployed | [PROJ-15](PROJ-15-cli-import-export.md) | 2026-03-13 |
| PROJ-16 | LDAP / Active Directory Anbindung | In Progress | [PROJ-16](PROJ-16-ldap-active-directory.md) | 2026-03-13 |
| PROJ-17 | Admin Dashboard Systemauslastung & Archiv-Übersicht | Deployed | [PROJ-17](PROJ-17-system-dashboard.md) | 2026-03-14 |
| PROJ-18 | E-Mail Integritätsprüfung | Deployed | [PROJ-18](PROJ-18-integritaetspruefung.md) | 2026-03-14 |
| PROJ-19 | Mailpiler → archivmail Migrationstool | Deployed | [PROJ-19](PROJ-19-import-piler.md) | 2026-03-17 |
| PROJ-20 | Nutzer-Löschung & E-Mail-Verbleib (GoBD-konform) | Deployed | [PROJ-20](PROJ-20-nutzer-loeschung.md) | 2026-03-17 |
<!-- Add features above this line -->
## Next Available ID: PROJ-21
+58
View File
@@ -0,0 +1,58 @@
---
id: PROJ-20
title: Nutzer-Löschung & E-Mail-Verbleib (GoBD-konform)
status: Deployed
created: 2026-03-17
---
## Ziel
Definiert und implementiert das Verhalten beim Löschen eines Benutzerkontos in Bezug auf archivierte E-Mails, IMAP-Verbindungen und Compliance-Anforderungen.
## Hintergrund
E-Mails im Archiv sind Unternehmenseigentum und nicht an einen Benutzeraccount gebunden. Die GoBD schreibt eine Aufbewahrungspflicht von 610 Jahren vor, die auch nach Ausscheiden eines Mitarbeiters gilt. Daher dürfen E-Mails bei einer Konto-Löschung **nie automatisch mitgelöscht** werden.
## Verhalten
### E-Mails
- E-Mails haben **keinen Fremdschlüssel auf `users`** — sie sind global im Archiv gespeichert.
- Bei jeder Art von Konto-Entfernung bleiben alle E-Mails vollständig erhalten.
- Keine automatische Löschung, kein Cascade.
### IMAP-Verbindungen
- Beim **endgültigen Löschen** eines Nutzers werden alle IMAP-Konten (`imap_accounts WHERE owner = username`) mitgelöscht.
- Sync-Jobs laufen nicht mehr ins Leere.
### Audit-Log
- Jede Löschung wird im Audit-Log erfasst mit: `user_id`, `username`, `role`, Anzahl gelöschter IMAP-Konten und dem Hinweis `emails retained per GoBD`.
## Zwei Aktionen im Admin-UI
| Aktion | Effekt | Rückgängig |
|--------|--------|------------|
| **Deaktivieren** (empfohlen) | `active = false`, Login gesperrt, Konto + IMAP erhalten | Ja — jederzeit reaktivierbar |
| **Endgültig löschen** | Account + IMAP-Konten entfernt, E-Mails bleiben | Nein |
## Implementierung
### Backend (`internal/api/server.go`)
- `handleDeleteUser`: Lädt Nutzername vor Löschung, ruft `imapStore.DeleteByOwner()`, schreibt erweitertes Audit-Log.
### IMAP Store (`internal/imap/store.go`)
- Neue Methode: `DeleteByOwner(ctx, username) (int, error)` — löscht alle IMAP-Konten eines Nutzers.
### Frontend (`src/app/admin/page.tsx`)
- Löschen-Button öffnet Dialog statt `confirm()`.
- Dialog zeigt: GoBD-Hinweis, Erklärung beider Optionen, Deaktivieren-Button (primär), Löschen-Button (destruktiv).
- Deaktivieren ruft `PATCH /api/users/{id}` mit `{ active: false }`.
- Löschen ruft `DELETE /api/users/{id}`.
## Akzeptanzkriterien
- [x] Löschen eines Nutzers entfernt keine E-Mails
- [x] Löschen eines Nutzers entfernt dessen IMAP-Konten
- [x] Audit-Log enthält Nutzername, Rolle und IMAP-Anzahl
- [x] Admin-UI zeigt Warn-Dialog mit GoBD-Hinweis
- [x] Deaktivieren als separate, bevorzugte Aktion verfügbar
- [x] Bereits deaktivierte Nutzer: Deaktivieren-Button inaktiv
+22 -2
View File
@@ -385,21 +385,41 @@ 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),
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,
})
+10
View File
@@ -238,6 +238,16 @@ func (s *Store) Delete(ctx context.Context, id int64) error {
return nil
}
// DeleteByOwner removes all IMAP accounts belonging to the given username.
// Returns the number of accounts deleted.
func (s *Store) DeleteByOwner(ctx context.Context, username string) (int, error) {
tag, err := s.pool.Exec(ctx, `DELETE FROM imap_accounts WHERE owner = $1`, username)
if err != nil {
return 0, fmt.Errorf("imap store: delete by owner: %w", err)
}
return int(tag.RowsAffected()), nil
}
// UpdateExcluded sets the list of excluded folders for an account.
func (s *Store) UpdateExcluded(ctx context.Context, id int64, excluded []string) error {
_, err := s.pool.Exec(ctx, `UPDATE imap_accounts SET excluded_folders = $1 WHERE id = $2`, excluded, id)
+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>
);
}