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:
@@ -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>
|
||||
|
||||
@@ -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 "{editTenant?.name}"</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user