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
+16
View File
@@ -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 && <TabsTrigger value="services">Dienste</TabsTrigger>}
<TabsTrigger value="users">Benutzer</TabsTrigger>
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
{canViewDSGVO && <TabsTrigger value="dsgvo">DSGVO-Anfragen</TabsTrigger>}
<TabsTrigger value="import">Import</TabsTrigger>
{isSuperAdmin && <TabsTrigger value="ldap" onClick={loadLDAP}>LDAP (Global)</TabsTrigger>}
{!isSuperAdmin && user?.role === "domain_admin" && (
@@ -234,6 +244,12 @@ export default function AdminPage() {
/>
</TabsContent>
{canViewDSGVO && (
<TabsContent value="dsgvo">
<DSGVOTab canManage={canManageDSGVO} />
</TabsContent>
)}
<TabsContent value="import">
<ImportTab
uploadDragging={upload.uploadDragging}
+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>
);
}
+83
View File
@@ -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<DSGVORequest[]> {
const data = await request<DSGVORequest[] | null>("/api/admin/dsgvo");
return data ?? [];
}
export async function getDSGVORequest(id: number): Promise<DSGVORequest> {
return request<DSGVORequest>(`/api/admin/dsgvo/${id}`);
}
export async function createDSGVORequest(
input: CreateDSGVORequestInput
): Promise<DSGVORequest> {
return request<DSGVORequest>("/api/admin/dsgvo", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function deleteDSGVODeletableMails(
id: number
): Promise<DSGVORequest> {
return request<DSGVORequest>(`/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 };
}
+16
View File
@@ -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";