From 261a95437c89e5ea5ecf39442a2ae63394e37e6f Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 30 Jun 2026 19:34:25 +0200 Subject: [PATCH] feat(PROJ-50): Admin-Tab "DSGVO-Anfragen" implementiert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Tab im Admin-Bereich: Antrags-Liste, Formular (Adresse + Zeitraum), Detail-Dialog mit Mail-Tabelle, PDF-Export und Löschen mit Bestätigung. Rollen-Gating: admin/auditor sehen den Tab, nur admin kann anlegen/löschen. Co-Authored-By: Claude Sonnet 4.6 --- src/app/admin/page.tsx | 16 + src/components/admin/tabs/DSGVOTab.tsx | 532 +++++++++++++++++++++++++ src/lib/api/dsgvo.ts | 83 ++++ src/lib/api/index.ts | 16 + 4 files changed, 647 insertions(+) create mode 100644 src/components/admin/tabs/DSGVOTab.tsx create mode 100644 src/lib/api/dsgvo.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index fff9efc..5ec887e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -35,11 +35,20 @@ import { RetentionTab } from "@/components/admin/tabs/RetentionTab"; import { ArchivingRulesTab } from "@/components/admin/tabs/ArchivingRulesTab"; import { QuotaTab } from "@/components/admin/tabs/QuotaTab"; import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab"; +import { DSGVOTab } from "@/components/admin/tabs/DSGVOTab"; import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs"; export default function AdminPage() { const { user, loading: authLoading } = useAuth("domain_admin", "/admin/login"); const isSuperAdmin = user?.role === "superadmin"; + const canManageDSGVO = + user?.role === "superadmin" || + user?.role === "domain_admin" || + user?.role === "admin"; + const canViewDSGVO = + canManageDSGVO || + user?.role === "auditor" || + user?.role === "domain_auditor"; const dashboard = useAdminDashboard(!!user); const usersState = useAdminUsers(); @@ -144,6 +153,7 @@ export default function AdminPage() { {isSuperAdmin && Dienste} Benutzer Audit-Log + {canViewDSGVO && DSGVO-Anfragen} Import {isSuperAdmin && LDAP (Global)} {!isSuperAdmin && user?.role === "domain_admin" && ( @@ -234,6 +244,12 @@ export default function AdminPage() { /> + {canViewDSGVO && ( + + + + )} + = { + open: { label: "Offen", variant: "secondary" }, + partial: { label: "Teilweise abgelehnt", variant: "outline" }, + completed: { label: "Abgeschlossen", variant: "default" }, + failed: { label: "Fehlgeschlagen", variant: "destructive" }, +}; + +const MAIL_STATUS_META: Record< + DSGVOMailStatus, + { label: string; variant: "default" | "secondary" | "destructive" | "outline" } +> = { + rejected: { label: "Abgelehnt – Aufbewahrungspflicht", variant: "destructive" }, + deletable: { label: "Löschbar", variant: "outline" }, + deleted: { label: "Gelöscht", variant: "secondary" }, +}; + +function StatusBadge({ status }: { status: DSGVOStatus }) { + const m = STATUS_META[status] ?? { label: status, variant: "secondary" as const }; + return {m.label}; +} + +function formatDate(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString("de-DE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatDay(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleDateString("de-DE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} + +interface DSGVOTabProps { + /** true für admin/superadmin/domain_admin (Anlegen/Löschen), false für auditor (read-only). */ + canManage: boolean; +} + +export function DSGVOTab({ canManage }: DSGVOTabProps) { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + // Neuer Antrag + const [createOpen, setCreateOpen] = useState(false); + const [address, setAddress] = useState(""); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(""); + + // Detail + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(""); + + // Löschen + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [deleting, setDeleting] = useState(false); + + // Export + const [exporting, setExporting] = useState(false); + + const load = useCallback(() => { + setLoading(true); + setError(""); + getDSGVORequests() + .then(setRequests) + .catch(() => setError("DSGVO-Anträge konnten nicht geladen werden")) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const openCreate = () => { + setAddress(""); + setDateFrom(""); + setDateTo(""); + setCreateError(""); + setCreateOpen(true); + }; + + const handleCreate = async () => { + const addr = address.trim(); + if (!addr) { + setCreateError("E-Mail-Adresse darf nicht leer sein"); + return; + } + setCreating(true); + setCreateError(""); + try { + const created = await createDSGVORequest({ + address: addr, + date_from: dateFrom.trim() || undefined, + date_to: dateTo.trim() || undefined, + }); + setCreateOpen(false); + load(); + // Ergebnis direkt anzeigen + setDetail(created); + setDetailError(""); + } catch (e: unknown) { + setCreateError( + e instanceof Error ? e.message : "Antrag konnte nicht erstellt werden" + ); + } finally { + setCreating(false); + } + }; + + const openDetail = async (id: number) => { + setDetailLoading(true); + setDetailError(""); + setDetail(null); + try { + const d = await getDSGVORequest(id); + setDetail(d); + } catch { + setDetailError("Antrags-Detail konnte nicht geladen werden"); + } finally { + setDetailLoading(false); + } + }; + + const handleExport = async (id: number) => { + setExporting(true); + try { + const { blob, filename } = await exportDSGVORequestPDF(id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e: unknown) { + setDetailError(e instanceof Error ? e.message : "Export fehlgeschlagen"); + } finally { + setExporting(false); + } + }; + + const handleDelete = async () => { + if (!detail) return; + setDeleting(true); + try { + const updated = await deleteDSGVODeletableMails(detail.id); + setDetail(updated); + setDeleteConfirm(false); + load(); + } catch (e: unknown) { + setDetailError(e instanceof Error ? e.message : "Löschen fehlgeschlagen"); + setDeleteConfirm(false); + } finally { + setDeleting(false); + } + }; + + const detailSummary = detail?.result_summary ?? null; + const deletableCount = detailSummary?.deletable ?? 0; + + return ( +
+ + +
+ DSGVO-Löschanträge + + Erfasst und dokumentiert Löschersuchen (Art. 17 DSGVO) zu Mail-Inhalten. + Mails mit aktiver gesetzlicher Aufbewahrungspflicht (GoBD) werden mit + Begründung abgelehnt; nur Mails ohne Aufbewahrungspflicht sind löschbar. + +
+ {canManage && ( + + )} +
+ + {error &&

{error}

} + {loading ? ( +
+ + +
+ ) : requests.length === 0 ? ( +

+ Noch keine DSGVO-Anträge erfasst. +

+ ) : ( +
+ + + + Datum + E-Mail-Adresse + Status + Treffer gesamt + + + + + {requests.map((r) => ( + + + {formatDate(r.created_at)} + + + {r.requested_address} + + + + + + {r.result_summary?.total ?? 0} + + + + + + ))} + +
+
+ )} +
+
+ + {/* Neuer Antrag */} + { if (!o) setCreateOpen(false); }}> + + + Neuer DSGVO-Löschantrag + + Das System durchsucht das Archiv (Mandanten-Scope) nach der Adresse als + Absender, Empfänger oder CC und prüft pro Mail die Aufbewahrungspflicht. + + + +
+
+ + setAddress(e.target.value)} + /> +
+
+
+ + setDateFrom(e.target.value)} + /> +
+
+ + setDateTo(e.target.value)} + /> +
+
+

+ Eingrenzung des Zeitraums empfohlen, wenn die Adresse in sehr vielen Mails + vorkommt (max. 10.000 Treffer pro Antrag). +

+ {createError && ( +

{createError}

+ )} +
+ + + + + +
+
+ + {/* Detail */} + { + if (!o) { + setDetail(null); + setDetailError(""); + } + }} + > + + + DSGVO-Antrag + {detail && ( + + {detail.requested_address} · {formatDate(detail.created_at)} · + bearbeitet von {detail.requested_by} + + )} + + + {detailLoading ? ( +
+ + +
+ ) : detail ? ( +
+
+ + {detailSummary && ( +
+ + Treffer gesamt: {detailSummary.total} + + + Abgelehnt: {detailSummary.rejected} + + + Löschbar: {detailSummary.deletable} + + {detailSummary.deleted > 0 && ( + Gelöscht: {detailSummary.deleted} + )} +
+ )} +
+ + {detailSummary?.truncated && ( +

+ Es wurden mehr als 10.000 Treffer gefunden – das Ergebnis ist + gekürzt. Bitte den Zeitraum eingrenzen, um vollständige Ergebnisse + zu erhalten. +

+ )} + + {detailError && ( +

{detailError}

+ )} + +
+ + {canManage && deletableCount > 0 && ( + + )} +
+ + {detailSummary && detailSummary.mails.length > 0 ? ( +
+ + + + Betreff + Datum + Status + Aufbewahrung bis + + + + {detailSummary.mails.map((m: DSGVOMailResult) => { + const sm = + MAIL_STATUS_META[m.status] ?? { + label: m.status, + variant: "secondary" as const, + }; + return ( + + + {m.subject || ( + + (kein Betreff) + + )} + + + {formatDay(m.received_at)} + + + + {sm.label} + + + + {formatDay(m.retain_until)} + + + ); + })} + +
+
+ ) : ( +

+ Keine betroffenen E-Mails – die Adresse kommt in keiner archivierten + Mail vor. Der Vorgang ist dennoch dokumentiert. +

+ )} +
+ ) : ( + detailError &&

{detailError}

+ )} + + + + +
+
+ + {/* Lösch-Bestätigung */} + { if (!o) setDeleteConfirm(false); }}> + + + Löschbare Mails endgültig löschen? + + Es werden {deletableCount} Mail(s) ohne aktive Aufbewahrungspflicht + unwiderruflich aus dem Archiv und Index entfernt. Mails mit gesetzlicher + Aufbewahrungspflicht bleiben unberührt. Jede Löschung wird im Audit-Log + protokolliert. Dieser Schritt kann nicht rückgängig gemacht werden. + + + + + + + + +
+ ); +} diff --git a/src/lib/api/dsgvo.ts b/src/lib/api/dsgvo.ts new file mode 100644 index 0000000..e7e6322 --- /dev/null +++ b/src/lib/api/dsgvo.ts @@ -0,0 +1,83 @@ +import { API_BASE, request } from "./core"; + +// PROJ-50: DSGVO-Löschersuchen für Mail-Inhalte (GoBD-Vorrang). +// Endpoints unter /api/admin/dsgvo. Zugriff: admin/superadmin/domain_admin (RW), +// auditor/domain_auditor (read-only). + +export type DSGVOStatus = "open" | "partial" | "completed" | "failed"; + +export type DSGVOMailStatus = "rejected" | "deletable" | "deleted"; + +export interface DSGVOMailResult { + id: string; + subject: string; + received_at: string; + status: DSGVOMailStatus; + reason: string; + retain_until: string | null; +} + +export interface DSGVOResultSummary { + total: number; + rejected: number; + deletable: number; + deleted: number; + truncated: boolean; + mails: DSGVOMailResult[]; +} + +export interface DSGVORequest { + id: number; + tenant_id: number | null; + requested_address: string; + requested_by: string; + created_at: string; + status: DSGVOStatus; + result_summary: DSGVOResultSummary | null; +} + +export interface CreateDSGVORequestInput { + address: string; + date_from?: string; + date_to?: string; +} + +export async function getDSGVORequests(): Promise { + const data = await request("/api/admin/dsgvo"); + return data ?? []; +} + +export async function getDSGVORequest(id: number): Promise { + return request(`/api/admin/dsgvo/${id}`); +} + +export async function createDSGVORequest( + input: CreateDSGVORequestInput +): Promise { + return request("/api/admin/dsgvo", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export async function deleteDSGVODeletableMails( + id: number +): Promise { + return request(`/api/admin/dsgvo/${id}/delete`, { + method: "POST", + }); +} + +export async function exportDSGVORequestPDF( + id: number +): Promise<{ blob: Blob; filename: string }> { + const res = await fetch(`${API_BASE}/api/admin/dsgvo/${id}/export`, { + credentials: "include", + }); + if (!res.ok) throw new Error("PDF-Export fehlgeschlagen"); + const blob = await res.blob(); + const cd = res.headers.get("Content-Disposition") || ""; + const filename = + cd.match(/filename="([^"]+)"/)?.[1] || `dsgvo-antrag-${id}.pdf`; + return { blob, filename }; +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 4e2bfa6..9c833e7 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -184,3 +184,19 @@ export { createSavedSearch, deleteSavedSearch, } from "./saved_searches"; + +export type { + DSGVOStatus, + DSGVOMailStatus, + DSGVOMailResult, + DSGVOResultSummary, + DSGVORequest, + CreateDSGVORequestInput, +} from "./dsgvo"; +export { + getDSGVORequests, + getDSGVORequest, + createDSGVORequest, + deleteDSGVODeletableMails, + exportDSGVORequestPDF, +} from "./dsgvo";