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:
@@ -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
|
||||
@@ -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 6–10 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
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user