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:
@@ -177,6 +177,7 @@ func main() {
|
||||
}
|
||||
srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger)
|
||||
srv.SetVersion(AppVersion, Modules)
|
||||
srv.SetGlobalRetentionDays(cfg.Storage.RetentionDays)
|
||||
|
||||
bind := cfg.API.Bind
|
||||
if bind == "" {
|
||||
|
||||
@@ -12,17 +12,17 @@ const AppVersion = "0.9.1"
|
||||
// MAJOR: Interface-Änderungen, Breaking changes innerhalb des Moduls
|
||||
// MINOR: Neue Funktionen, Bugfixes, Security-Patches
|
||||
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
|
||||
"imapserver": "1.3", // PROJ-28 FQDN-Greeting (RFC 3501)
|
||||
"auth": "1.3", // JWT, bcrypt cost 12, TOTP
|
||||
"audit": "1.1", // PostgreSQL append-only, QueryFilter
|
||||
"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
|
||||
"imap": "1.2", // IMAP-Sync, Scheduler, POP3
|
||||
"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
|
||||
"mailparser": "1.1", // RFC-2822, MIME, MessageID-Extraktion
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
+11
-3
@@ -78,8 +78,9 @@ type Server struct {
|
||||
tenantStore *tenantstore.Store
|
||||
tenantLdapStore *ldapcfg.TenantStore
|
||||
idxMgr *index.TenantIndexManager
|
||||
appVersion string
|
||||
moduleVersions map[string]string
|
||||
appVersion string
|
||||
moduleVersions map[string]string
|
||||
globalRetentionDays int // from storage config (PROJ-34)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func New(
|
||||
cfg config.APIConfig,
|
||||
@@ -170,8 +176,10 @@ func (s *Server) routes() {
|
||||
// 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)))
|
||||
|
||||
// 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("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
|
||||
s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode))
|
||||
|
||||
@@ -324,9 +324,16 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
|
||||
} else {
|
||||
s.insertMetaMinimal(ctx, id, len(raw), tenantID)
|
||||
}
|
||||
// PROJ-34: Set retention lock if configured
|
||||
if s.retentionDays > 0 {
|
||||
until := time.Now().AddDate(0, 0, s.retentionDays)
|
||||
// PROJ-34: Set retention lock — prefer per-tenant retention, fall back to global.
|
||||
effectiveRetention := 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ type Tenant struct {
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Computed fields populated by List.
|
||||
DomainCount int `json:"domain_count,omitempty"`
|
||||
UserCount int `json:"user_count,omitempty"`
|
||||
LDAPEnabled *bool `json:"ldap_enabled,omitempty"`
|
||||
LDAPURL string `json:"ldap_url,omitempty"`
|
||||
HasLogo bool `json:"has_logo,omitempty"`
|
||||
DomainCount int `json:"domain_count,omitempty"`
|
||||
UserCount int `json:"user_count,omitempty"`
|
||||
LDAPEnabled *bool `json:"ldap_enabled,omitempty"`
|
||||
LDAPURL string `json:"ldap_url,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.
|
||||
@@ -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_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 retention_days INT NOT NULL DEFAULT 0;
|
||||
`
|
||||
|
||||
// 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,
|
||||
tl.enabled AS ldap_enabled,
|
||||
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
|
||||
LEFT JOIN tenant_domains td ON td.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
|
||||
for rows.Next() {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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