feat(PROJ-50): Admin-Tab "DSGVO-Anfragen" implementiert

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 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-06-30 19:34:25 +02:00
co-authored by Claude Sonnet 4.6
parent 0552ce49e2
commit 261a95437c
4 changed files with 647 additions and 0 deletions
+532
View File
@@ -0,0 +1,532 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import {
getDSGVORequests,
getDSGVORequest,
createDSGVORequest,
deleteDSGVODeletableMails,
exportDSGVORequestPDF,
type DSGVORequest,
type DSGVOStatus,
type DSGVOMailStatus,
type DSGVOMailResult,
} from "@/lib/api";
const STATUS_META: Record<
DSGVOStatus,
{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }
> = {
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 <Badge variant={m.variant}>{m.label}</Badge>;
}
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<DSGVORequest[]>([]);
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<DSGVORequest | null>(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 (
<div className="mt-4 space-y-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div>
<CardTitle>DSGVO-Löschanträge</CardTitle>
<CardDescription>
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.
</CardDescription>
</div>
{canManage && (
<Button size="sm" onClick={openCreate}>
Neuer Antrag
</Button>
)}
</CardHeader>
<CardContent>
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
{loading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : requests.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Noch keine DSGVO-Anträge erfasst.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>E-Mail-Adresse</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Treffer gesamt</TableHead>
<TableHead className="w-28"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((r) => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap">
{formatDate(r.created_at)}
</TableCell>
<TableCell className="break-all">
{r.requested_address}
</TableCell>
<TableCell>
<StatusBadge status={r.status} />
</TableCell>
<TableCell className="text-right">
{r.result_summary?.total ?? 0}
</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => openDetail(r.id)}
>
Details
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Neuer Antrag */}
<Dialog open={createOpen} onOpenChange={(o) => { if (!o) setCreateOpen(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuer DSGVO-Löschantrag</DialogTitle>
<DialogDescription>
Das System durchsucht das Archiv (Mandanten-Scope) nach der Adresse als
Absender, Empfänger oder CC und prüft pro Mail die Aufbewahrungspflicht.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="dsgvo-address">E-Mail-Adresse</Label>
<Input
id="dsgvo-address"
type="email"
placeholder="betroffene.person@example.com"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dsgvo-from">Datum von (optional)</Label>
<Input
id="dsgvo-from"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dsgvo-to">Datum bis (optional)</Label>
<Input
id="dsgvo-to"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Eingrenzung des Zeitraums empfohlen, wenn die Adresse in sehr vielen Mails
vorkommt (max. 10.000 Treffer pro Antrag).
</p>
{createError && (
<p className="text-sm text-destructive">{createError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Abbrechen
</Button>
<Button disabled={creating} onClick={handleCreate}>
{creating ? "Wird verarbeitet..." : "Antrag stellen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Detail */}
<Dialog
open={!!detail || detailLoading}
onOpenChange={(o) => {
if (!o) {
setDetail(null);
setDetailError("");
}
}}
>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>DSGVO-Antrag</DialogTitle>
{detail && (
<DialogDescription>
{detail.requested_address} · {formatDate(detail.created_at)} ·
bearbeitet von {detail.requested_by}
</DialogDescription>
)}
</DialogHeader>
{detailLoading ? (
<div className="space-y-2 py-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-32 w-full" />
</div>
) : detail ? (
<div className="space-y-4 py-2">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge status={detail.status} />
{detailSummary && (
<div className="flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">
Treffer gesamt: {detailSummary.total}
</Badge>
<Badge variant="destructive">
Abgelehnt: {detailSummary.rejected}
</Badge>
<Badge variant="outline">
Löschbar: {detailSummary.deletable}
</Badge>
{detailSummary.deleted > 0 && (
<Badge>Gelöscht: {detailSummary.deleted}</Badge>
)}
</div>
)}
</div>
{detailSummary?.truncated && (
<p className="rounded-md bg-amber-50 p-2 text-xs text-amber-700 dark:bg-amber-950 dark:text-amber-400">
Es wurden mehr als 10.000 Treffer gefunden das Ergebnis ist
gekürzt. Bitte den Zeitraum eingrenzen, um vollständige Ergebnisse
zu erhalten.
</p>
)}
{detailError && (
<p className="text-sm text-destructive">{detailError}</p>
)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={exporting}
onClick={() => handleExport(detail.id)}
>
{exporting ? "Erstelle PDF..." : "Als PDF herunterladen"}
</Button>
{canManage && deletableCount > 0 && (
<Button
size="sm"
variant="destructive"
onClick={() => setDeleteConfirm(true)}
>
Löschbare Mails jetzt löschen ({deletableCount})
</Button>
)}
</div>
{detailSummary && detailSummary.mails.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Betreff</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Status</TableHead>
<TableHead>Aufbewahrung bis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailSummary.mails.map((m: DSGVOMailResult) => {
const sm =
MAIL_STATUS_META[m.status] ?? {
label: m.status,
variant: "secondary" as const,
};
return (
<TableRow key={m.id}>
<TableCell className="max-w-xs break-words">
{m.subject || (
<span className="text-muted-foreground">
(kein Betreff)
</span>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDay(m.received_at)}
</TableCell>
<TableCell>
<Badge variant={sm.variant} title={m.reason}>
{sm.label}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDay(m.retain_until)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<p className="py-4 text-center text-sm text-muted-foreground">
Keine betroffenen E-Mails die Adresse kommt in keiner archivierten
Mail vor. Der Vorgang ist dennoch dokumentiert.
</p>
)}
</div>
) : (
detailError && <p className="text-sm text-destructive">{detailError}</p>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDetail(null);
setDetailError("");
}}
>
Schließen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Lösch-Bestätigung */}
<Dialog open={deleteConfirm} onOpenChange={(o) => { if (!o) setDeleteConfirm(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Löschbare Mails endgültig löschen?</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(false)}>
Abbrechen
</Button>
<Button variant="destructive" disabled={deleting} onClick={handleDelete}>
{deleting ? "Lösche..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}