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:
co-authored by
Claude Sonnet 4.6
parent
0552ce49e2
commit
261a95437c
@@ -35,11 +35,20 @@ import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
|
|||||||
import { ArchivingRulesTab } from "@/components/admin/tabs/ArchivingRulesTab";
|
import { ArchivingRulesTab } from "@/components/admin/tabs/ArchivingRulesTab";
|
||||||
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
|
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
|
||||||
import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab";
|
import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab";
|
||||||
|
import { DSGVOTab } from "@/components/admin/tabs/DSGVOTab";
|
||||||
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
|
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { user, loading: authLoading } = useAuth("domain_admin", "/admin/login");
|
const { user, loading: authLoading } = useAuth("domain_admin", "/admin/login");
|
||||||
const isSuperAdmin = user?.role === "superadmin";
|
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 dashboard = useAdminDashboard(!!user);
|
||||||
const usersState = useAdminUsers();
|
const usersState = useAdminUsers();
|
||||||
@@ -144,6 +153,7 @@ export default function AdminPage() {
|
|||||||
{isSuperAdmin && <TabsTrigger value="services">Dienste</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="services">Dienste</TabsTrigger>}
|
||||||
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
||||||
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
||||||
|
{canViewDSGVO && <TabsTrigger value="dsgvo">DSGVO-Anfragen</TabsTrigger>}
|
||||||
<TabsTrigger value="import">Import</TabsTrigger>
|
<TabsTrigger value="import">Import</TabsTrigger>
|
||||||
{isSuperAdmin && <TabsTrigger value="ldap" onClick={loadLDAP}>LDAP (Global)</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="ldap" onClick={loadLDAP}>LDAP (Global)</TabsTrigger>}
|
||||||
{!isSuperAdmin && user?.role === "domain_admin" && (
|
{!isSuperAdmin && user?.role === "domain_admin" && (
|
||||||
@@ -234,6 +244,12 @@ export default function AdminPage() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{canViewDSGVO && (
|
||||||
|
<TabsContent value="dsgvo">
|
||||||
|
<DSGVOTab canManage={canManageDSGVO} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="import">
|
<TabsContent value="import">
|
||||||
<ImportTab
|
<ImportTab
|
||||||
uploadDragging={upload.uploadDragging}
|
uploadDragging={upload.uploadDragging}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -184,3 +184,19 @@ export {
|
|||||||
createSavedSearch,
|
createSavedSearch,
|
||||||
deleteSavedSearch,
|
deleteSavedSearch,
|
||||||
} from "./saved_searches";
|
} from "./saved_searches";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DSGVOStatus,
|
||||||
|
DSGVOMailStatus,
|
||||||
|
DSGVOMailResult,
|
||||||
|
DSGVOResultSummary,
|
||||||
|
DSGVORequest,
|
||||||
|
CreateDSGVORequestInput,
|
||||||
|
} from "./dsgvo";
|
||||||
|
export {
|
||||||
|
getDSGVORequests,
|
||||||
|
getDSGVORequest,
|
||||||
|
createDSGVORequest,
|
||||||
|
deleteDSGVODeletableMails,
|
||||||
|
exportDSGVORequestPDF,
|
||||||
|
} from "./dsgvo";
|
||||||
|
|||||||
Reference in New Issue
Block a user