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 := 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 == "" {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
+11
-3
@@ -78,8 +78,9 @@ type Server struct {
|
|||||||
tenantStore *tenantstore.Store
|
tenantStore *tenantstore.Store
|
||||||
tenantLdapStore *ldapcfg.TenantStore
|
tenantLdapStore *ldapcfg.TenantStore
|
||||||
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))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ type Tenant struct {
|
|||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
// Computed fields populated by List.
|
// Computed fields populated by List.
|
||||||
DomainCount int `json:"domain_count,omitempty"`
|
DomainCount int `json:"domain_count,omitempty"`
|
||||||
UserCount int `json:"user_count,omitempty"`
|
UserCount int `json:"user_count,omitempty"`
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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