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
|
||||||
+23
-3
@@ -385,22 +385,42 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 := s.users.DeleteSafe(id); err != nil {
|
||||||
if err.Error() == "userstore: cannot delete last admin" {
|
if err.Error() == "userstore: cannot delete last admin" {
|
||||||
writeError(w, http.StatusConflict, "cannot delete the last active admin")
|
writeError(w, http.StatusConflict, "cannot delete the last active admin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusNotFound, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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())
|
sess := sessionFromCtx(r.Context())
|
||||||
s.audlog.Log(audit.Entry{
|
s.audlog.Log(audit.Entry{
|
||||||
EventType: audit.EventUserMgmt,
|
EventType: audit.EventUserMgmt,
|
||||||
Username: sess.Username,
|
Username: sess.Username,
|
||||||
IPAddress: remoteIP(r),
|
IPAddress: remoteIP(r),
|
||||||
Detail: fmt.Sprintf("deleted user %d", id),
|
Detail: fmt.Sprintf(
|
||||||
Success: true,
|
"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})
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
|||||||
@@ -238,6 +238,16 @@ func (s *Store) Delete(ctx context.Context, id int64) error {
|
|||||||
return nil
|
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.
|
// UpdateExcluded sets the list of excluded folders for an account.
|
||||||
func (s *Store) UpdateExcluded(ctx context.Context, id int64, excluded []string) error {
|
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)
|
_, 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 [resetPasswordError, setResetPasswordError] = useState("");
|
||||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
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
|
// Audit state
|
||||||
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
||||||
const [auditTotal, setAuditTotal] = useState(0);
|
const [auditTotal, setAuditTotal] = useState(0);
|
||||||
@@ -290,16 +295,33 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteUser(u: User) {
|
async function handleDeactivateConfirmed() {
|
||||||
if (!confirm(`Benutzer "${u.username}" wirklich löschen?`)) return;
|
if (!deleteDialogUser) return;
|
||||||
setUserActionLoading(u.id);
|
setDeleteActionLoading("deactivate");
|
||||||
|
setDeleteDialogError("");
|
||||||
try {
|
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();
|
loadUsers();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
alert(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
|
setDeleteDialogError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
|
||||||
} finally {
|
} finally {
|
||||||
setUserActionLoading(null);
|
setDeleteActionLoading(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,7 +996,7 @@ export default function AdminPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={userActionLoading === u.id}
|
disabled={userActionLoading === u.id}
|
||||||
onClick={() => handleDeleteUser(u)}
|
onClick={() => { setDeleteDialogUser(u); setDeleteDialogError(""); }}
|
||||||
>
|
>
|
||||||
Löschen
|
Löschen
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1203,6 +1225,61 @@ export default function AdminPage() {
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user