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
+1
View File
@@ -177,6 +177,7 @@ func main() {
} }
srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger) srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger)
srv.SetVersion(AppVersion, Modules) srv.SetVersion(AppVersion, Modules)
srv.SetGlobalRetentionDays(cfg.Storage.RetentionDays)
bind := cfg.API.Bind bind := cfg.API.Bind
if bind == "" { if bind == "" {
+3 -3
View File
@@ -12,17 +12,17 @@ const AppVersion = "0.9.1"
// MAJOR: Interface-Änderungen, Breaking changes innerhalb des Moduls // MAJOR: Interface-Änderungen, Breaking changes innerhalb des Moduls
// MINOR: Neue Funktionen, Bugfixes, Security-Patches // MINOR: Neue Funktionen, Bugfixes, Security-Patches
var Modules = map[string]string{ var Modules = map[string]string{
"storage": "1.7", // PROJ-33 MailWithUID, GetMailsWithUID, GetMailsByRecipient "storage": "1.8", // PROJ-34 per-tenant retention lookup in Save()
"smtpd": "1.3", // PROJ-28 FQDN-Fallback für EHLO-Banner "smtpd": "1.3", // PROJ-28 FQDN-Fallback für EHLO-Banner
"imapserver": "1.3", // PROJ-28 FQDN-Greeting (RFC 3501) "imapserver": "1.3", // PROJ-28 FQDN-Greeting (RFC 3501)
"auth": "1.3", // JWT, bcrypt cost 12, TOTP "auth": "1.3", // JWT, bcrypt cost 12, TOTP
"audit": "1.1", // PostgreSQL append-only, QueryFilter "audit": "1.1", // PostgreSQL append-only, QueryFilter
"index": "1.0", // Xapian-Wrapper, Async-Worker, Tenant-Index "index": "1.0", // Xapian-Wrapper, Async-Worker, Tenant-Index
"api": "1.7", // PROJ-33 IMAP-Modus-Einstellungen "api": "1.8", // PROJ-34 Retention-API + pro-Mandant Endpoints
"userstore": "1.3", // domain_auditor Rolle, bcrypt, LDAP-Auth "userstore": "1.3", // domain_auditor Rolle, bcrypt, LDAP-Auth
"imap": "1.2", // IMAP-Sync, Scheduler, POP3 "imap": "1.2", // IMAP-Sync, Scheduler, POP3
"labelstore": "1.0", // Labels, Tenant-Isolation "labelstore": "1.0", // Labels, Tenant-Isolation
"tenantstore": "1.2", // PROJ-33 imap_mode, GetIMAPMode, SetIMAPMode "tenantstore": "1.3", // PROJ-34 retention_days, GetRetentionDays, SetRetentionDays
"ldapconfig": "1.1", // Pro-Mandant LDAP, TLS "ldapconfig": "1.1", // Pro-Mandant LDAP, TLS
"mailparser": "1.1", // RFC-2822, MIME, MessageID-Extraktion "mailparser": "1.1", // RFC-2822, MIME, MessageID-Extraktion
} }
+50
View File
@@ -1,7 +1,9 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv"
) )
// handlePurge deletes all mails whose retention period has expired. // handlePurge deletes all mails whose retention period has expired.
@@ -16,3 +18,51 @@ func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) {
"deleted": deleted, "deleted": deleted,
}) })
} }
// handleGetRetention returns the global retention config and per-tenant overrides.
// GET /api/admin/retention — superadmin only.
func (s *Server) handleGetRetention(w http.ResponseWriter, r *http.Request) {
tenants, err := s.tenantStore.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"global_retention_days": s.globalRetentionDays,
"tenants": tenants,
})
}
// handleSetTenantRetention sets retention_days for a specific tenant.
// PUT /api/admin/tenant/{id}/retention — superadmin only.
func (s *Server) handleSetTenantRetention(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
tenantID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
var body struct {
RetentionDays int `json:"retention_days"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if err := s.tenantStore.SetRetentionDays(r.Context(), tenantID, body.RetentionDays); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
sess := getSession(r)
if s.audlog != nil {
_ = s.audlog.Log(r.Context(), sess.UserID, "tenant_retention_changed", map[string]interface{}{
"tenant_id": tenantID,
"retention_days": body.RetentionDays,
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
+9 -1
View File
@@ -80,6 +80,7 @@ type Server struct {
idxMgr *index.TenantIndexManager idxMgr *index.TenantIndexManager
appVersion string appVersion string
moduleVersions map[string]string moduleVersions map[string]string
globalRetentionDays int // from storage config (PROJ-34)
} }
// SetSMTPDaemon wires the SMTP daemon into the API server after construction. // SetSMTPDaemon wires the SMTP daemon into the API server after construction.
@@ -111,6 +112,11 @@ func (s *Server) SetVersion(appVersion string, modules map[string]string) {
s.moduleVersions = modules s.moduleVersions = modules
} }
// SetGlobalRetentionDays wires the global retention_days from storage config into the API server.
func (s *Server) SetGlobalRetentionDays(days int) {
s.globalRetentionDays = days
}
// New creates and wires up a new API server. // New creates and wires up a new API server.
func New( func New(
cfg config.APIConfig, cfg config.APIConfig,
@@ -170,8 +176,10 @@ func (s *Server) routes() {
// SEC-17: Security fix actions require superadmin, not just domain_admin. // SEC-17: Security fix actions require superadmin, not just domain_admin.
s.mux.HandleFunc("POST /api/admin/security/fix", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSecurityFix))) s.mux.HandleFunc("POST /api/admin/security/fix", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSecurityFix)))
// PROJ-34: Retention purge — superadmin only // PROJ-34: Retention — superadmin only
s.mux.HandleFunc("POST /api/admin/purge", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handlePurge))) s.mux.HandleFunc("POST /api/admin/purge", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handlePurge)))
s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention)))
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention)))
// PROJ-33: IMAP mode settings — domain_admin only // PROJ-33: IMAP mode settings — domain_admin only
s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode)) s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode))
+10 -3
View File
@@ -324,9 +324,16 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
} else { } else {
s.insertMetaMinimal(ctx, id, len(raw), tenantID) s.insertMetaMinimal(ctx, id, len(raw), tenantID)
} }
// PROJ-34: Set retention lock if configured // PROJ-34: Set retention lock — prefer per-tenant retention, fall back to global.
if s.retentionDays > 0 { effectiveRetention := s.retentionDays
until := time.Now().AddDate(0, 0, s.retentionDays) if tenantID != nil {
var tenantDays int
if err := s.db.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id=$1`, *tenantID).Scan(&tenantDays); err == nil && tenantDays > 0 {
effectiveRetention = tenantDays
}
}
if effectiveRetention > 0 {
until := time.Now().AddDate(0, 0, effectiveRetention)
_, _ = s.db.Exec(ctx, `UPDATE emails SET retain_until=$1 WHERE id=$2 AND retain_until IS NULL`, until, id) _, _ = s.db.Exec(ctx, `UPDATE emails SET retain_until=$1 WHERE id=$2 AND retain_until IS NULL`, until, id)
} }
} }
+32 -2
View File
@@ -25,6 +25,7 @@ type Tenant struct {
LDAPEnabled *bool `json:"ldap_enabled,omitempty"` LDAPEnabled *bool `json:"ldap_enabled,omitempty"`
LDAPURL string `json:"ldap_url,omitempty"` LDAPURL string `json:"ldap_url,omitempty"`
HasLogo bool `json:"has_logo,omitempty"` HasLogo bool `json:"has_logo,omitempty"`
RetentionDays int `json:"retention_days"` // 0 = use global config
} }
// TenantDomain is an e-mail domain assigned to a tenant. // TenantDomain is an e-mail domain assigned to a tenant.
@@ -75,6 +76,7 @@ ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenan
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_data BYTEA; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_data BYTEA;
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_content_type VARCHAR(100) NOT NULL DEFAULT ''; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_content_type VARCHAR(100) NOT NULL DEFAULT '';
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS imap_mode TEXT NOT NULL DEFAULT 'personal'; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS imap_mode TEXT NOT NULL DEFAULT 'personal';
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS retention_days INT NOT NULL DEFAULT 0;
` `
// New connects to PostgreSQL and initialises the tenant schema. // New connects to PostgreSQL and initialises the tenant schema.
@@ -125,7 +127,8 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
COUNT(DISTINCT u.id) AS user_count, COUNT(DISTINCT u.id) AS user_count,
tl.enabled AS ldap_enabled, tl.enabled AS ldap_enabled,
tl.url AS ldap_url, tl.url AS ldap_url,
(t.logo_data IS NOT NULL) AS has_logo (t.logo_data IS NOT NULL) AS has_logo,
t.retention_days
FROM tenants t FROM tenants t
LEFT JOIN tenant_domains td ON td.tenant_id = t.id LEFT JOIN tenant_domains td ON td.tenant_id = t.id
LEFT JOIN users u ON u.tenant_id = t.id LEFT JOIN users u ON u.tenant_id = t.id
@@ -141,7 +144,7 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
var tenants []Tenant var tenants []Tenant
for rows.Next() { for rows.Next() {
var t Tenant var t Tenant
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo); err != nil { if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo, &t.RetentionDays); err != nil {
return nil, fmt.Errorf("tenantstore: scan: %w", err) return nil, fmt.Errorf("tenantstore: scan: %w", err)
} }
tenants = append(tenants, t) tenants = append(tenants, t)
@@ -348,3 +351,30 @@ func (s *Store) SetIMAPMode(ctx context.Context, tenantID int64, mode string) er
_, err := s.pool.Exec(ctx, `UPDATE tenants SET imap_mode = $1 WHERE id = $2`, mode, tenantID) _, err := s.pool.Exec(ctx, `UPDATE tenants SET imap_mode = $1 WHERE id = $2`, mode, tenantID)
return err return err
} }
// ── Retention ─────────────────────────────────────────────────────────────────
// GetRetentionDays returns the per-tenant retention_days (0 = use global config).
func (s *Store) GetRetentionDays(ctx context.Context, tenantID int64) (int, error) {
var days int
err := s.pool.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id = $1`, tenantID).Scan(&days)
if err != nil {
return 0, nil // safe default: fall back to global
}
return days, nil
}
// SetRetentionDays sets the per-tenant retention_days. 0 means "use global config".
func (s *Store) SetRetentionDays(ctx context.Context, tenantID int64, days int) error {
if days < 0 {
return fmt.Errorf("tenantstore: retention_days must be >= 0")
}
tag, err := s.pool.Exec(ctx, `UPDATE tenants SET retention_days = $1 WHERE id = $2`, days, tenantID)
if err != nil {
return fmt.Errorf("tenantstore: set retention: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("tenantstore: tenant %d not found", tenantID)
}
return nil
}
+8
View File
@@ -76,6 +76,7 @@ import { LabelsTab } from "@/components/admin/tabs/LabelsTab";
import { CertTab } from "@/components/admin/tabs/CertTab"; import { CertTab } from "@/components/admin/tabs/CertTab";
import { ModulesTab } from "@/components/admin/ModulesTab"; import { ModulesTab } from "@/components/admin/ModulesTab";
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab"; import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs"; import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
const AUDIT_PAGE_SIZE = 25; const AUDIT_PAGE_SIZE = 25;
@@ -807,6 +808,7 @@ export default function AdminPage() {
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>} {isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>} {isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>} {isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>} {isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
</TabsList> </TabsList>
@@ -1085,6 +1087,12 @@ export default function AdminPage() {
</TabsContent> </TabsContent>
)} )}
{isSuperAdmin && (
<TabsContent value="retention">
<RetentionTab />
</TabsContent>
)}
<TabsContent value="modules" className="mt-4"> <TabsContent value="modules" className="mt-4">
<ModulesTab /> <ModulesTab />
</TabsContent> </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>
);
}