feat(PROJ-34): Retention-Tab + pro-Mandant Aufbewahrungsfristen

- tenantstore: retention_days Spalte, GetRetentionDays/SetRetentionDays
- storage.Save(): per-tenant retention überschreibt globale config
- API: GET /api/admin/retention, PUT /api/admin/tenant/{id}/retention
- Frontend: RetentionTab mit globaler Policy-Anzeige, Mandanten-Tabelle,
  Bearbeiten-Dialog und Purge-Button (superadmin only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 10:37:15 +02:00
parent 5f0c7a7e6d
commit 5bbf6d0ff3
8 changed files with 399 additions and 16 deletions
+8
View File
@@ -76,6 +76,7 @@ import { LabelsTab } from "@/components/admin/tabs/LabelsTab";
import { CertTab } from "@/components/admin/tabs/CertTab";
import { ModulesTab } from "@/components/admin/ModulesTab";
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
const AUDIT_PAGE_SIZE = 25;
@@ -807,6 +808,7 @@ export default function AdminPage() {
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
</TabsList>
@@ -1085,6 +1087,12 @@ export default function AdminPage() {
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="retention">
<RetentionTab />
</TabsContent>
)}
<TabsContent value="modules" className="mt-4">
<ModulesTab />
</TabsContent>
+279
View File
@@ -0,0 +1,279 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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";
interface TenantRetention {
id: number;
name: string;
slug: string;
retention_days: number;
}
interface RetentionData {
global_retention_days: number;
tenants: TenantRetention[];
}
async function fetchRetention(): Promise<RetentionData> {
const res = await fetch("/api/admin/retention", { credentials: "include" });
if (!res.ok) throw new Error("Fehler beim Laden");
return res.json();
}
async function setTenantRetention(tenantID: number, days: number): Promise<void> {
const res = await fetch(`/api/admin/tenant/${tenantID}/retention`, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ retention_days: days }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: string }).error ?? "Fehler beim Speichern");
}
}
async function triggerPurge(): Promise<number> {
const res = await fetch("/api/admin/purge", {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error("Purge fehlgeschlagen");
const data = await res.json();
return data.deleted ?? 0;
}
function retentionLabel(days: number, globalDays: number): React.ReactNode {
if (days === 0) {
return globalDays > 0 ? (
<span className="text-muted-foreground text-sm">
Global ({globalDays} Tage)
</span>
) : (
<Badge variant="secondary">Kein Lock</Badge>
);
}
return <Badge>{days} Tage</Badge>;
}
export function RetentionTab() {
const [data, setData] = useState<RetentionData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// edit state
const [editTenant, setEditTenant] = useState<TenantRetention | null>(null);
const [editDays, setEditDays] = useState("");
const [saving, setSaving] = useState(false);
// purge state
const [purgeOpen, setPurgeOpen] = useState(false);
const [purging, setPurging] = useState(false);
const [purgeResult, setPurgeResult] = useState<number | null>(null);
const load = useCallback(() => {
setLoading(true);
fetchRetention()
.then(setData)
.catch(() => setError("Retention-Daten konnten nicht geladen werden"))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const handleEditOpen = (t: TenantRetention) => {
setEditTenant(t);
setEditDays(t.retention_days > 0 ? String(t.retention_days) : "");
setSaving(false);
};
const handleEditSave = async () => {
if (!editTenant) return;
const days = editDays.trim() === "" ? 0 : parseInt(editDays, 10);
if (isNaN(days) || days < 0) return;
setSaving(true);
try {
await setTenantRetention(editTenant.id, days);
setEditTenant(null);
load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler");
} finally {
setSaving(false);
}
};
const handlePurge = async () => {
setPurging(true);
setPurgeResult(null);
try {
const deleted = await triggerPurge();
setPurgeResult(deleted);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Purge fehlgeschlagen");
setPurgeOpen(false);
} finally {
setPurging(false);
}
};
const globalDays = data?.global_retention_days ?? 0;
return (
<div className="mt-4 space-y-4">
{/* Global Policy */}
<Card>
<CardHeader>
<CardTitle>Globale Retention-Policy</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Globale Aufbewahrungsfrist:</span>
{globalDays > 0 ? (
<Badge>{globalDays} Tage ({Math.round(globalDays / 365)} Jahre)</Badge>
) : (
<Badge variant="secondary">Kein globaler Lock (config.yml)</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
Die globale Frist wird in <code className="text-xs bg-muted px-1 rounded">config.yml storage.retention_days</code> konfiguriert.
Mandanten-spezifische Einstellungen überschreiben diese, wenn gesetzt.
</p>
<Button
variant="destructive"
size="sm"
onClick={() => { setPurgeOpen(true); setPurgeResult(null); }}
>
Abgelaufene Mails jetzt löschen (Purge)
</Button>
{error && <p className="text-sm text-destructive">{error}</p>}
</CardContent>
</Card>
{/* Per-tenant table */}
<Card>
<CardHeader>
<CardTitle>Retention pro Mandant</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-sm text-muted-foreground">Lädt...</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Mandant</TableHead>
<TableHead>Aufbewahrungsfrist</TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(data?.tenants ?? []).map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>{retentionLabel(t.retention_days, globalDays)}</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => handleEditOpen(t)}>
Bearbeiten
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editTenant} onOpenChange={(o) => { if (!o) setEditTenant(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Retention für &quot;{editTenant?.name}&quot;</DialogTitle>
<DialogDescription>
Aufbewahrungsfrist in Tagen. <strong>0</strong> = globale Einstellung verwenden.
GoBD: mind. 10 Jahre = 3650 Tage.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
<label className="text-sm font-medium">Aufbewahrung (Tage)</label>
<Input
type="number"
min={0}
placeholder="0 = globale Einstellung"
value={editDays}
onChange={(e) => setEditDays(e.target.value)}
/>
{editDays && parseInt(editDays) > 0 && (
<p className="text-xs text-muted-foreground">
{(parseInt(editDays) / 365).toFixed(1)} Jahre
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditTenant(null)}>
Abbrechen
</Button>
<Button disabled={saving} onClick={handleEditSave}>
{saving ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Purge Dialog */}
<Dialog open={purgeOpen} onOpenChange={setPurgeOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Abgelaufene Mails löschen</DialogTitle>
<DialogDescription>
Alle Mails, deren Aufbewahrungsfrist abgelaufen ist, werden unwiderruflich gelöscht.
Mails innerhalb der Frist bleiben erhalten.
</DialogDescription>
</DialogHeader>
{purgeResult !== null ? (
<p className="text-sm font-medium py-2">
{purgeResult === 0
? "Keine abgelaufenen Mails gefunden."
: `${purgeResult} Mail(s) erfolgreich gelöscht.`}
</p>
) : (
<p className="text-sm text-muted-foreground py-2">
Dieser Vorgang kann nicht rückgängig gemacht werden.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPurgeOpen(false)}>
{purgeResult !== null ? "Schließen" : "Abbrechen"}
</Button>
{purgeResult === null && (
<Button variant="destructive" disabled={purging} onClick={handlePurge}>
{purging ? "Lösche..." : "Jetzt löschen"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}