Files
archivmail/src/app/admin/page.tsx
T
2026-03-17 21:45:40 +01:00

2229 lines
97 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/hooks/useAuth";
import { features, type Feature } from "@/data/features";
import {
getUsers,
createUser,
updateUser,
deleteUser,
getAuditLog,
getSMTPStatus,
getHealth,
getStorageStats,
getServices,
serviceAction,
getSystemStats,
uploadMailFiles,
getUploadProgress,
getSecurityAudit,
fixSecurityIssue,
getLDAPConfig,
saveLDAPConfig,
deleteLDAPConfig,
testLDAPConfig,
getTenants,
createTenant,
updateTenant,
deleteTenant,
getTenantDomains,
addTenantDomain,
removeTenantDomain,
type User,
type AuditEntry,
type SMTPStatus,
type StorageStats,
type ServiceStatus,
type SystemStats,
type UploadJob,
type SecurityCheck,
type SecurityAuditResult,
type LDAPConfig,
type LDAPTestResult,
type Tenant,
type TenantDomain,
} from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
const AUDIT_PAGE_SIZE = 25;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export default function AdminPage() {
const { user, loading: authLoading } = useAuth("domain_admin");
const isSuperAdmin = user?.role === "superadmin";
// Dashboard state
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [dashRefreshed, setDashRefreshed] = useState<Date | null>(null);
const [countdown, setCountdown] = useState(30);
// Services state
const [services, setServices] = useState<ServiceStatus[]>([]);
const [servicesLoading, setServicesLoading] = useState(false);
const [serviceActionLoading, setServiceActionLoading] = useState<string | null>(null);
const [serviceError, setServiceError] = useState("");
// Users state
const [users, setUsers] = useState<User[]>([]);
const [usersLoading, setUsersLoading] = useState(true);
const [usersError, setUsersError] = useState("");
// Create user dialog
const [dialogOpen, setDialogOpen] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newRole, setNewRole] = useState("user");
const [createLoading, setCreateLoading] = useState(false);
const [createError, setCreateError] = useState("");
// User action state
const [userActionLoading, setUserActionLoading] = useState<number | null>(null);
const [resetPasswordUserId, setResetPasswordUserId] = useState<number | null>(null);
const [resetPasswordValue, setResetPasswordValue] = useState("");
const [resetPasswordError, setResetPasswordError] = useState("");
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
// Delete confirmation dialog
const [deleteDialogUser, setDeleteDialogUser] = useState<User | null>(null);
const [deleteActionLoading, setDeleteActionLoading] = useState<"deactivate" | "delete" | null>(null);
const [deleteDialogError, setDeleteDialogError] = useState("");
// Audit state
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const [auditTotal, setAuditTotal] = useState(0);
const [auditPage, setAuditPage] = useState(1);
const [auditLoading, setAuditLoading] = useState(false);
// Security audit state
const [securityAudit, setSecurityAudit] = useState<SecurityAuditResult | null>(null);
const [securityLoading, setSecurityLoading] = useState(false);
const [securityError, setSecurityError] = useState("");
const [fixLoading, setFixLoading] = useState<string | null>(null);
const [fixMessage, setFixMessage] = useState("");
// Upload state
const [uploadDragging, setUploadDragging] = useState(false);
const [uploadJob, setUploadJob] = useState<UploadJob | null>(null);
const [uploadError, setUploadError] = useState("");
const [uploadLoading, setUploadLoading] = useState(false);
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// LDAP state
const [ldapConfig, setLdapConfig] = useState<LDAPConfig | null>(null);
const [ldapLoading, setLdapLoading] = useState(false);
const [ldapSaving, setLdapSaving] = useState(false);
const [ldapTesting, setLdapTesting] = useState(false);
const [ldapError, setLdapError] = useState("");
const [ldapTestResult, setLdapTestResult] = useState<LDAPTestResult | null>(null);
const [ldapForm, setLdapForm] = useState<LDAPConfig>({
enabled: false,
url: "ldap://",
bind_dn: "",
bind_password: "",
base_dn: "",
user_filter: "(sAMAccountName=%s)",
tls: false,
tls_skip_verify: false,
default_role: "user",
group_mappings: [],
});
const [ldapChangePassword, setLdapChangePassword] = useState(false);
// Tenants state
const [tenants, setTenants] = useState<Tenant[]>([]);
const [tenantsLoading, setTenantsLoading] = useState(false);
const [tenantsError, setTenantsError] = useState("");
const [tenantDialogOpen, setTenantDialogOpen] = useState(false);
const [newTenantName, setNewTenantName] = useState("");
const [newTenantSlug, setNewTenantSlug] = useState("");
const [tenantCreateLoading, setTenantCreateLoading] = useState(false);
const [tenantCreateError, setTenantCreateError] = useState("");
const [tenantDeleteId, setTenantDeleteId] = useState<number | null>(null);
const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false);
const [domainDialogTenant, setDomainDialogTenant] = useState<Tenant | null>(null);
const [tenantDomains, setTenantDomains] = useState<TenantDomain[]>([]);
const [domainsLoading, setDomainsLoading] = useState(false);
const [newDomain, setNewDomain] = useState("");
const [addDomainLoading, setAddDomainLoading] = useState(false);
const [domainError, setDomainError] = useState("");
const loadDashboard = useCallback(async () => {
setDashLoading(true);
try {
const [smtp, health, storage, sysStats] = await Promise.allSettled([
getSMTPStatus(),
getHealth(),
getStorageStats(),
getSystemStats(),
]);
setSmtpStatus(smtp.status === "fulfilled" ? smtp.value : null);
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null);
setDashRefreshed(new Date());
} finally {
setDashLoading(false);
}
}, []);
const loadUsers = useCallback(async () => {
setUsersLoading(true);
setUsersError("");
try {
const data = await getUsers();
setUsers(data || []);
} catch {
setUsersError("Benutzer konnten nicht geladen werden.");
} finally {
setUsersLoading(false);
}
}, []);
const loadAudit = useCallback(async (p: number) => {
setAuditLoading(true);
try {
const data = await getAuditLog({ page: p, page_size: AUDIT_PAGE_SIZE });
setAuditEntries(data.entries || []);
setAuditTotal(data.total);
setAuditPage(p);
} catch {
setAuditEntries([]);
} finally {
setAuditLoading(false);
}
}, []);
const loadServices = useCallback(async () => {
setServicesLoading(true);
setServiceError("");
try {
const data = await getServices();
setServices(data || []);
} catch {
setServiceError("Dienste konnten nicht abgerufen werden.");
} finally {
setServicesLoading(false);
}
}, []);
async function handleUploadFiles(files: File[]) {
const valid = files.filter(f => f.name.toLowerCase().endsWith(".eml") || f.name.toLowerCase().endsWith(".mbox"));
if (valid.length === 0) {
setUploadError("Nur .eml und .mbox Dateien erlaubt.");
return;
}
setUploadError("");
setUploadJob(null);
setUploadLoading(true);
try {
const { job_id } = await uploadMailFiles(valid);
// Start polling
const poll = setInterval(async () => {
try {
const job = await getUploadProgress(job_id);
setUploadJob(job);
if (job.status !== "running") {
clearInterval(poll);
uploadPollRef.current = null;
setUploadLoading(false);
}
} catch {
clearInterval(poll);
uploadPollRef.current = null;
setUploadLoading(false);
}
}, 1500);
uploadPollRef.current = poll;
} catch (e: unknown) {
setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen.");
setUploadLoading(false);
}
}
async function handleServiceAction(name: string, action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external") {
setServiceActionLoading(`${name}:${action}`);
setServiceError("");
try {
const updated = await serviceAction(name, action);
setServices((prev) => prev.map((s) => (s.name === updated.name ? updated : s)));
} catch (e: unknown) {
setServiceError(e instanceof Error ? e.message : "Aktion fehlgeschlagen.");
} finally {
setServiceActionLoading(null);
}
}
const dashIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!user) return;
loadDashboard();
loadUsers();
loadAudit(1);
loadServices();
// Auto-Refresh Dashboard alle 30 Sekunden
setCountdown(30);
dashIntervalRef.current = setInterval(() => {
loadDashboard();
setCountdown(30);
}, 30_000);
// Countdown-Ticker
const ticker = setInterval(() => {
setCountdown((c) => (c > 0 ? c - 1 : 0));
}, 1_000);
return () => {
if (dashIntervalRef.current) clearInterval(dashIntervalRef.current);
clearInterval(ticker);
};
}, [user, loadDashboard, loadUsers, loadAudit, loadServices]);
async function handleCreateUser(e: React.FormEvent) {
e.preventDefault();
setCreateLoading(true);
setCreateError("");
try {
await createUser({
username: newUsername,
email: newEmail,
password: newPassword,
role: newRole,
});
setDialogOpen(false);
setNewUsername("");
setNewEmail("");
setNewPassword("");
setNewRole("user");
loadUsers();
} catch {
setCreateError("Benutzer konnte nicht erstellt werden.");
} finally {
setCreateLoading(false);
}
}
async function handleToggleActive(u: User) {
setUserActionLoading(u.id);
try {
await updateUser(u.id, { active: !u.active });
loadUsers();
} catch {
// ignore
} finally {
setUserActionLoading(null);
}
}
async function handleDeactivateConfirmed() {
if (!deleteDialogUser) return;
setDeleteActionLoading("deactivate");
setDeleteDialogError("");
try {
await updateUser(deleteDialogUser.id, { active: false });
setDeleteDialogUser(null);
loadUsers();
} catch {
setDeleteDialogError("Deaktivierung fehlgeschlagen.");
} finally {
setDeleteActionLoading(null);
}
}
async function handleDeleteConfirmed() {
if (!deleteDialogUser) return;
setDeleteActionLoading("delete");
setDeleteDialogError("");
try {
await deleteUser(deleteDialogUser.id);
setDeleteDialogUser(null);
loadUsers();
} catch (err: unknown) {
setDeleteDialogError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setDeleteActionLoading(null);
}
}
async function handleResetPassword(e: React.FormEvent) {
e.preventDefault();
if (!resetPasswordUserId) return;
setResetPasswordLoading(true);
setResetPasswordError("");
try {
await updateUser(resetPasswordUserId, { password: resetPasswordValue });
setResetPasswordUserId(null);
setResetPasswordValue("");
} catch {
setResetPasswordError("Passwort konnte nicht geändert werden.");
} finally {
setResetPasswordLoading(false);
}
}
async function runSecurityAudit() {
setSecurityLoading(true);
setSecurityError("");
setFixMessage("");
try {
const result = await getSecurityAudit();
setSecurityAudit(result);
} catch {
setSecurityError("Security-Audit konnte nicht ausgeführt werden.");
} finally {
setSecurityLoading(false);
}
}
async function runFix(action: string) {
setFixLoading(action);
setFixMessage("");
setSecurityError("");
try {
const res = await fixSecurityIssue(action);
setFixMessage(res.message);
// Re-run audit automatically to reflect new state
await runSecurityAudit();
} catch (e: unknown) {
setSecurityError(e instanceof Error ? e.message : "Fix fehlgeschlagen.");
} finally {
setFixLoading(null);
}
}
// LDAP handlers
const loadLDAP = useCallback(async () => {
setLdapLoading(true);
setLdapError("");
try {
const cfg = await getLDAPConfig();
if (cfg) {
setLdapConfig(cfg);
setLdapForm({ ...cfg, bind_password: "" });
setLdapChangePassword(false);
}
} catch {
setLdapError("LDAP-Konfiguration konnte nicht geladen werden.");
} finally {
setLdapLoading(false);
}
}, []);
async function handleSaveLDAP(e: React.FormEvent) {
e.preventDefault();
setLdapSaving(true);
setLdapError("");
try {
const payload: Partial<LDAPConfig> = { ...ldapForm };
if (!ldapChangePassword) {
delete payload.bind_password;
}
await saveLDAPConfig(payload);
await loadLDAP();
} catch (err: unknown) {
setLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
} finally {
setLdapSaving(false);
}
}
async function handleTestLDAP() {
setLdapTesting(true);
setLdapError("");
setLdapTestResult(null);
try {
const payload = ldapConfig
? { use_saved: true }
: { use_saved: false, ...ldapForm };
const result = await testLDAPConfig(payload as Parameters<typeof testLDAPConfig>[0]);
setLdapTestResult(result);
} catch (err: unknown) {
setLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen.");
} finally {
setLdapTesting(false);
}
}
async function handleDeleteLDAP() {
setLdapSaving(true);
setLdapError("");
try {
await deleteLDAPConfig();
setLdapConfig(null);
setLdapForm({
enabled: false, url: "ldap://", bind_dn: "", bind_password: "",
base_dn: "", user_filter: "(sAMAccountName=%s)", tls: false,
tls_skip_verify: false, default_role: "user", group_mappings: [],
});
setLdapTestResult(null);
} catch (err: unknown) {
setLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setLdapSaving(false);
}
}
// Tenants handlers
const loadTenants = useCallback(async () => {
setTenantsLoading(true);
setTenantsError("");
try {
const data = await getTenants();
setTenants(data || []);
} catch {
setTenantsError("Mandanten konnten nicht geladen werden.");
} finally {
setTenantsLoading(false);
}
}, []);
async function handleCreateTenant(e: React.FormEvent) {
e.preventDefault();
setTenantCreateLoading(true);
setTenantCreateError("");
try {
await createTenant(newTenantName, newTenantSlug);
setTenantDialogOpen(false);
setNewTenantName("");
setNewTenantSlug("");
await loadTenants();
} catch (err: unknown) {
setTenantCreateError(err instanceof Error ? err.message : "Erstellen fehlgeschlagen.");
} finally {
setTenantCreateLoading(false);
}
}
async function handleToggleTenant(t: Tenant) {
try {
await updateTenant(t.id, { active: !t.active });
await loadTenants();
} catch { /* ignore */ }
}
async function handleDeleteTenant() {
if (!tenantDeleteId) return;
setTenantDeleteLoading(true);
try {
await deleteTenant(tenantDeleteId);
setTenantDeleteId(null);
await loadTenants();
} catch { /* ignore */ } finally {
setTenantDeleteLoading(false);
}
}
async function openDomainDialog(t: Tenant) {
setDomainDialogTenant(t);
setDomainsLoading(true);
setDomainError("");
setNewDomain("");
try {
const domains = await getTenantDomains(t.id);
setTenantDomains(domains || []);
} catch { setDomainError("Domains konnten nicht geladen werden."); }
finally { setDomainsLoading(false); }
}
async function handleAddDomain() {
if (!domainDialogTenant || !newDomain) return;
setAddDomainLoading(true);
setDomainError("");
try {
await addTenantDomain(domainDialogTenant.id, newDomain);
setNewDomain("");
const domains = await getTenantDomains(domainDialogTenant.id);
setTenantDomains(domains || []);
} catch (err: unknown) {
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht hinzugefügt werden.");
} finally {
setAddDomainLoading(false);
}
}
async function handleRemoveDomain(domainId: number) {
if (!domainDialogTenant) return;
setDomainError("");
try {
await removeTenantDomain(domainDialogTenant.id, domainId);
const domains = await getTenantDomains(domainDialogTenant.id);
setTenantDomains(domains || []);
} catch (err: unknown) {
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht entfernt werden.");
}
}
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
return (
<div className="min-h-screen">
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
<main className="mx-auto max-w-7xl px-4 py-6">
{(authLoading || !user) ? (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-sm" />
<Skeleton className="h-64 w-full" />
</div>
) : (<>
<h1 className="mb-6 text-2xl font-bold">Administration</h1>
<Tabs defaultValue="dashboard">
<TabsList>
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
<TabsTrigger value="services">Dienste</TabsTrigger>
<TabsTrigger value="users">Benutzer</TabsTrigger>
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
<TabsTrigger value="import">Import</TabsTrigger>
{isSuperAdmin && <TabsTrigger value="ldap" onClick={loadLDAP}>LDAP</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
</TabsList>
{/* ── Dashboard ── */}
<TabsContent value="dashboard" className="mt-4 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Systemstatus</h2>
<div className="flex items-center gap-3">
{dashRefreshed && (
<span className="text-xs text-muted-foreground">
{dashRefreshed.toLocaleTimeString("de-DE")} · nächste Aktualisierung in {countdown}s
</span>
)}
<Button variant="outline" size="sm" onClick={() => { loadDashboard(); setCountdown(30); }} disabled={dashLoading}>
{dashLoading ? "..." : "Jetzt aktualisieren"}
</Button>
</div>
</div>
{dashLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full rounded-lg" />
))}
</div>
) : (
<>
{/* Status-Kacheln */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* API */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">REST API</span>
<Badge variant={apiOnline ? "default" : "destructive"}>
{apiOnline ? "Online" : "Offline"}
</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Adresse</span>
<span className="font-mono">:8080</span>
<span className="text-muted-foreground">Protokoll</span>
<span>HTTP</span>
</div>
</CardContent>
</Card>
{/* SMTP — superadmin: globaler Daemon; domain_admin: nur eigene Domain-Statistik */}
{isSuperAdmin ? (
<>
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SMTP-Daemon</span>
<Badge variant={smtpStatus?.running ? "default" : "destructive"}>
{smtpStatus?.running ? "Aktiv" : smtpStatus?.enabled === false ? "Deaktiviert" : "Gestoppt"}
</Badge>
</div>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Adresse</span>
<span className="font-mono">{smtpStatus.bind}</span>
<span className="text-muted-foreground">Domain</span>
<span className="font-mono">{smtpStatus.domain || ""}</span>
<span className="text-muted-foreground">TLS</span>
<span>{smtpStatus.tls ? "Ja" : "Nein"}</span>
<span className="text-muted-foreground">Max. Größe</span>
<span>{(smtpStatus.max_size_mb ?? 0) > 0 ? `${smtpStatus.max_size_mb} MB` : "50 MB"}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Nicht erreichbar</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SMTP Statistik</span>
<span className="text-xs text-muted-foreground">seit letztem Start</span>
</div>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Empfangen</span>
<span className="font-semibold text-green-600">{smtpStatus.received ?? 0}</span>
<span className="text-muted-foreground">Abgelehnt</span>
<span className="font-semibold text-red-500">{smtpStatus.rejected ?? 0}</span>
<span className="text-muted-foreground">Letzte Mail</span>
<span className="text-xs">
{smtpStatus.last_mail_at
? new Date(smtpStatus.last_mail_at).toLocaleString("de-DE")
: ""}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SMTP meine Domain(s)</span>
<Badge variant="secondary">Tenant</Badge>
</div>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Domain(s)</span>
<span className="font-mono text-xs">
{(smtpStatus.domains?.length ?? 0) > 0 ? smtpStatus.domains!.join(", ") : ""}
</span>
<span className="text-muted-foreground">Archivierte Mails</span>
<span className="font-semibold">{smtpStatus.total_mails?.toLocaleString("de-DE") ?? ""}</span>
<span className="text-muted-foreground">Speicher</span>
<span>{smtpStatus.total_bytes ? formatBytes(smtpStatus.total_bytes) : ""}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
)}
{/* Archiv-Speicher */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Archiv gesamt</span>
{storageStats && (
<Badge variant="secondary" className="font-mono text-xs">
{storageStats.total_mails} Mails
</Badge>
)}
</div>
<Separator />
{storageStats ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">E-Mails</span>
<span className="font-semibold">{storageStats.total_mails.toLocaleString("de-DE")}</span>
<span className="text-muted-foreground">Speicher</span>
<span>{formatBytes(storageStats.total_bytes)}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
</div>
{/* System Stats: nur für superadmin */}
{isSuperAdmin && <div className="space-y-4">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Systemauslastung</h3>
{!systemStats ? (
<Alert variant="destructive">
<AlertDescription>
Systemdaten konnten nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft und der Endpunkt <code className="font-mono">/api/admin/system/stats</code> erreichbar ist.
</AlertDescription>
</Alert>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* CPU */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">CPU Load Average</span>
<Badge variant="secondary">{systemStats.cpu.num_cpu} CPU(s)</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">1 min</span>
<span className="font-semibold">{systemStats.cpu.load1.toFixed(2)}</span>
<span className="text-muted-foreground">5 min</span>
<span className="font-semibold">{systemStats.cpu.load5.toFixed(2)}</span>
<span className="text-muted-foreground">15 min</span>
<span className="font-semibold">{systemStats.cpu.load15.toFixed(2)}</span>
</div>
</CardContent>
</Card>
{/* RAM */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Arbeitsspeicher</span>
<Badge variant={systemStats.ram.used_pct > 90 ? "destructive" : systemStats.ram.used_pct > 70 ? "secondary" : "default"}>
{systemStats.ram.used_pct.toFixed(1)}%
</Badge>
</div>
<Separator />
<div className="space-y-2">
<div className="h-2 w-full rounded-full bg-secondary">
<div
className={`h-2 rounded-full ${systemStats.ram.used_pct > 90 ? "bg-destructive" : systemStats.ram.used_pct > 70 ? "bg-yellow-500" : "bg-primary"}`}
style={{ width: `${Math.min(systemStats.ram.used_pct, 100)}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Belegt</span>
<span>{formatBytes(systemStats.ram.used_bytes)}</span>
<span className="text-muted-foreground">Gesamt</span>
<span>{formatBytes(systemStats.ram.total_bytes)}</span>
<span className="text-muted-foreground">Frei</span>
<span>{formatBytes(systemStats.ram.free_bytes)}</span>
</div>
</div>
</CardContent>
</Card>
{/* Archivzeitraum */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Archivzeitraum</span>
</div>
<Separator />
{systemStats.archive.first_mail || systemStats.archive.last_mail ? (
<div className="space-y-2 text-sm">
{systemStats.archive.first_mail && (
<div>
<span className="text-xs text-muted-foreground block">Älteste Mail</span>
<span className="font-semibold">{new Date(systemStats.archive.first_mail.date).toLocaleDateString("de-DE")}</span>
<span className="block text-muted-foreground truncate">{systemStats.archive.first_mail.from || ""}</span>
<span className="block text-xs truncate">{systemStats.archive.first_mail.subject || "(kein Betreff)"}</span>
</div>
)}
{systemStats.archive.last_mail && (
<div className="pt-1 border-t">
<span className="text-xs text-muted-foreground block">Neueste Mail</span>
<span className="font-semibold">{new Date(systemStats.archive.last_mail.date).toLocaleDateString("de-DE")}</span>
<span className="block text-muted-foreground truncate">{systemStats.archive.last_mail.from || ""}</span>
<span className="block text-xs truncate">{systemStats.archive.last_mail.subject || "(kein Betreff)"}</span>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Archiv leer</p>
)}
</CardContent>
</Card>
</div>
{/* Festplatten */}
{systemStats.disks.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Festplatten</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{systemStats.disks.map((disk) => (
<Card key={disk.mount}>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium font-mono">{disk.mount}</span>
<Badge variant={disk.used_pct > 90 ? "destructive" : disk.used_pct > 75 ? "secondary" : "outline"}>
{disk.used_pct.toFixed(1)}%
</Badge>
</div>
<div className="h-2 w-full rounded-full bg-secondary">
<div
className={`h-2 rounded-full ${disk.used_pct > 90 ? "bg-destructive" : disk.used_pct > 75 ? "bg-yellow-500" : "bg-primary"}`}
style={{ width: `${Math.min(disk.used_pct, 100)}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Belegt</span>
<span>{formatBytes(disk.used_bytes)}</span>
<span className="text-muted-foreground">Gesamt</span>
<span>{formatBytes(disk.total_bytes)}</span>
<span className="text-muted-foreground">Frei</span>
<span>{formatBytes(disk.free_bytes)}</span>
<span className="text-muted-foreground">Dateisystem</span>
<span className="font-mono text-xs">{disk.fstype}</span>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
</>
)}
</div>}
{/* IP-Allowlist — nur superadmin */}
{isSuperAdmin && smtpStatus && (smtpStatus.allowed_ips?.length ?? 0) > 0 && (
<Card>
<CardContent className="pt-6 space-y-2">
<span className="text-sm font-medium text-muted-foreground">SMTP IP-Allowlist</span>
<Separator />
<div className="flex flex-wrap gap-2 pt-1">
{smtpStatus.allowed_ips.map((ip) => (
<Badge key={ip} variant="outline" className="font-mono">
{ip}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Benutzerübersicht */}
<Card>
<CardContent className="pt-6 space-y-2">
<span className="text-sm font-medium text-muted-foreground">Benutzer</span>
<Separator />
{usersLoading ? (
<Skeleton className="h-8 w-full" />
) : (
<div className="flex flex-wrap gap-4 pt-1 text-sm">
<span>
<span className="font-semibold">{users.filter(u => u.active).length}</span>
<span className="text-muted-foreground ml-1">aktiv</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "admin").length}</span>
<span className="text-muted-foreground ml-1">Admin</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "auditor").length}</span>
<span className="text-muted-foreground ml-1">Auditor</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "user").length}</span>
<span className="text-muted-foreground ml-1">User</span>
</span>
</div>
)}
</CardContent>
</Card>
{!smtpStatus && (
<Alert variant="destructive">
<AlertDescription>
SMTP-Status konnte nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft.
</AlertDescription>
</Alert>
)}
</>
)}
</TabsContent>
{/* ── Dienste ── */}
<TabsContent value="services" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Systemdienste</h2>
<Button variant="outline" size="sm" onClick={loadServices} disabled={servicesLoading}>
{servicesLoading ? "..." : "Aktualisieren"}
</Button>
</div>
{serviceError && (
<Alert variant="destructive">
<AlertDescription>{serviceError}</AlertDescription>
</Alert>
)}
{servicesLoading && services.length === 0 ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-44">Dienst</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-24">Autostart</TableHead>
<TableHead className="w-28">Externer Zugriff</TableHead>
<TableHead>Beschreibung</TableHead>
{isSuperAdmin && <TableHead className="w-72 text-right">Aktionen</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{services.map((svc) => {
const isActive = svc.active === "active";
const isFailed = svc.active === "failed";
const isEnabled = svc.enabled === "enabled" || svc.enabled === "static";
const busy = (key: string) => serviceActionLoading === `${svc.name}:${key}`;
const anyBusy = serviceActionLoading?.startsWith(`${svc.name}:`) ?? false;
return (
<TableRow key={svc.name}>
<TableCell className="font-mono text-sm font-medium">
{svc.name}
</TableCell>
<TableCell>
<Badge
variant={isActive ? "default" : isFailed ? "destructive" : "secondary"}
>
{svc.active === "active"
? `Aktiv (${svc.sub})`
: svc.active === "failed"
? "Fehler"
: svc.active === "inactive"
? "Gestoppt"
: svc.active}
</Badge>
</TableCell>
<TableCell>
<Badge variant={isEnabled ? "default" : "outline"}>
{svc.enabled === "enabled"
? "Aktiviert"
: svc.enabled === "disabled"
? "Deaktiviert"
: svc.enabled === "static"
? "Statisch"
: svc.enabled}
</Badge>
</TableCell>
<TableCell>
{svc.external_blocked !== undefined ? (
<Badge variant={svc.external_blocked ? "destructive" : "default"}>
{svc.external_blocked ? "Gesperrt" : "Offen"}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-xs">
{svc.description || ""}
</TableCell>
{isSuperAdmin && (
<TableCell className="text-right">
<div className="flex justify-end gap-1 flex-wrap">
{isActive ? (
<>
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "restart")}
>
{busy("restart") ? "..." : "Neustart"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "stop")}
>
{busy("stop") ? "..." : "Stop"}
</Button>
</>
) : (
<Button
size="sm"
variant="default"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "start")}
>
{busy("start") ? "..." : "Start"}
</Button>
)}
{svc.enabled === "enabled" ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "disable")}
>
{busy("disable") ? "..." : "Deaktivieren"}
</Button>
) : svc.enabled === "disabled" ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "enable")}
>
{busy("enable") ? "..." : "Aktivieren"}
</Button>
) : null}
{svc.external_blocked !== undefined && (
svc.external_blocked ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "allow_external")}
>
{busy("allow_external") ? "..." : "Extern freigeben"}
</Button>
) : (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "block_external")}
>
{busy("block_external") ? "..." : "Extern sperren"}
</Button>
)
)}
</div>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</TabsContent>
<TabsContent value="users" className="mt-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Benutzerverwaltung</h2>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button>Benutzer anlegen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
<DialogDescription>
Erstellen Sie einen neuen Benutzer-Account.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateUser} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-username">Benutzername</Label>
<Input
id="new-username"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
aria-label="Neuer Benutzername"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-email">E-Mail</Label>
<Input
id="new-email"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
required
aria-label="Neue E-Mail-Adresse"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Passwort</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
aria-label="Neues Passwort"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-role">Rolle</Label>
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger id="new-role" aria-label="Rolle auswaehlen">
<SelectValue placeholder="Rolle waehlen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="domain_admin">Domain Admin</SelectItem>
{isSuperAdmin && <SelectItem value="superadmin">Superadmin</SelectItem>}
</SelectContent>
</Select>
</div>
{createError && (
<p className="text-sm text-destructive" role="alert">
{createError}
</p>
)}
<DialogFooter>
<Button type="submit" disabled={createLoading}>
{createLoading ? "Erstellen..." : "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{usersLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : usersError ? (
<Card>
<CardContent className="p-8 text-center text-destructive">
{usersError}
</CardContent>
</Card>
) : users.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Benutzer vorhanden.
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzername</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role}</Badge>
</TableCell>
<TableCell>
<Badge variant={u.active ? "default" : "destructive"}>
{u.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
disabled={userActionLoading === u.id}
onClick={() => {
setResetPasswordUserId(u.id);
setResetPasswordValue("");
setResetPasswordError("");
}}
>
Passwort
</Button>
<Button
size="sm"
variant="outline"
disabled={userActionLoading === u.id}
onClick={() => handleToggleActive(u)}
>
{userActionLoading === u.id ? "..." : u.active ? "Sperren" : "Freischalten"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={userActionLoading === u.id}
onClick={() => { setDeleteDialogUser(u); setDeleteDialogError(""); }}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</TabsContent>
<TabsContent value="audit" className="mt-4">
<h2 className="mb-4 text-lg font-semibold">Audit-Log</h2>
{auditLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : auditEntries.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Audit-Eintraege vorhanden.
</CardContent>
</Card>
) : (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeitstempel</TableHead>
<TableHead>Ereignis</TableHead>
<TableHead>Benutzer</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditEntries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="whitespace-nowrap">
{new Date(entry.timestamp).toLocaleString("de-DE")}
</TableCell>
<TableCell>
<Badge variant="outline">
{entry.event_type}
</Badge>
</TableCell>
<TableCell>{entry.username}</TableCell>
<TableCell className="max-w-xs truncate">
{entry.detail}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{auditTotalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={auditPage <= 1}
onClick={() => loadAudit(auditPage - 1)}
>
Zurueck
</Button>
<span className="text-sm text-muted-foreground">
Seite {auditPage} von {auditTotalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={auditPage >= auditTotalPages}
onClick={() => loadAudit(auditPage + 1)}
>
Weiter
</Button>
</div>
)}
</>
)}
</TabsContent>
{/* ── Import ── */}
<TabsContent value="import" className="mt-4 space-y-4">
<h2 className="text-lg font-semibold">EML / MBOX importieren</h2>
<p className="text-sm text-muted-foreground">
Lade .eml oder .mbox Dateien hoch um sie ins Archiv zu importieren. Duplikate werden automatisch übersprungen.
</p>
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setUploadDragging(true); }}
onDragLeave={() => setUploadDragging(false)}
onDrop={(e) => {
e.preventDefault();
setUploadDragging(false);
const files = Array.from(e.dataTransfer.files);
handleUploadFiles(files);
}}
className={`border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer ${
uploadDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50"
}`}
onClick={() => document.getElementById("upload-file-input")?.click()}
>
<input
id="upload-file-input"
type="file"
multiple
accept=".eml,.mbox"
className="hidden"
onChange={(e) => {
if (e.target.files) handleUploadFiles(Array.from(e.target.files));
e.target.value = "";
}}
/>
<p className="text-sm font-medium">Dateien hierher ziehen oder klicken zum Auswählen</p>
<p className="text-xs text-muted-foreground mt-1">Akzeptiert: .eml, .mbox</p>
</div>
{uploadError && (
<Alert variant="destructive">
<AlertDescription>{uploadError}</AlertDescription>
</Alert>
)}
{/* Progress */}
{(uploadLoading || uploadJob) && uploadJob && (
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{uploadJob.status === "running" ? "Import läuft..." : "Import abgeschlossen"}
</span>
<Badge variant={uploadJob.status === "done" ? "default" : "secondary"}>
{uploadJob.status === "done" ? "Fertig" : "Läuft"}
</Badge>
</div>
{/* Progress bar */}
{uploadJob.total > 0 && (
<div className="space-y-1">
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${Math.min(100, ((uploadJob.imported + uploadJob.skipped + uploadJob.errors) / uploadJob.total) * 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total} verarbeitet
</p>
</div>
)}
{uploadJob.status === "done" && (
<div className="grid grid-cols-3 gap-3 text-center text-sm">
<div className="rounded bg-green-50 dark:bg-green-950 p-2">
<p className="font-bold text-green-700 dark:text-green-400">{uploadJob.imported}</p>
<p className="text-xs text-muted-foreground">Importiert</p>
</div>
<div className="rounded bg-yellow-50 dark:bg-yellow-950 p-2">
<p className="font-bold text-yellow-700 dark:text-yellow-400">{uploadJob.skipped}</p>
<p className="text-xs text-muted-foreground">Übersprungen</p>
</div>
<div className="rounded bg-red-50 dark:bg-red-950 p-2">
<p className="font-bold text-red-700 dark:text-red-400">{uploadJob.errors}</p>
<p className="text-xs text-muted-foreground">Fehler</p>
</div>
</div>
)}
</CardContent>
</Card>
)}
{uploadLoading && !uploadJob && (
<p className="text-sm text-muted-foreground animate-pulse">Upload läuft...</p>
)}
</TabsContent>
{/* ── Security Audit ── */}
<TabsContent value="security" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Security Audit</h2>
{securityAudit && (
<p className="text-xs text-muted-foreground mt-0.5">
Zuletzt geprüft: {new Date(securityAudit.run_at).toLocaleString("de-DE")}
</p>
)}
</div>
<Button onClick={runSecurityAudit} disabled={securityLoading || fixLoading !== null} size="sm">
{securityLoading ? "Prüfe..." : "Jetzt prüfen"}
</Button>
</div>
{securityError && (
<Alert variant="destructive">
<AlertDescription>{securityError}</AlertDescription>
</Alert>
)}
{fixMessage && (
<Alert>
<AlertDescription className="text-green-700">{fixMessage}</AlertDescription>
</Alert>
)}
{!securityAudit && !securityLoading && !securityError && (
<div className="rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground">
Klicke auf &ldquo;Jetzt prüfen&rdquo; um den Security-Audit zu starten.
</div>
)}
{securityLoading && (
<div className="space-y-3">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
)}
{securityAudit && !securityLoading && (() => {
// Map check names to fix actions
const fixActions: Record<string, { action: string; label: string }> = {
"Fail2ban": { action: "install_fail2ban", label: "Installieren & aktivieren" },
"Firewall (nftables)": { action: "enable_firewall", label: "Firewall aktivieren" },
"SSH Passwort-Auth": { action: "fix_ssh_password_auth", label: "Deaktivieren" },
"SSH Root-Login": { action: "fix_ssh_root_login", label: "Auf prohibit-password setzen" },
};
return (
<div className="space-y-2">
{securityAudit.checks.map((check: SecurityCheck, i: number) => {
const fix = check.status !== "ok" ? fixActions[check.name] : undefined;
return (
<Card key={i}>
<CardContent className="p-4 flex items-start gap-3">
<span className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
check.status === "ok" ? "bg-green-500" :
check.status === "warning" ? "bg-yellow-400" : "bg-red-500"
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{check.name}</span>
<Badge
variant={check.status === "ok" ? "default" : check.status === "warning" ? "secondary" : "destructive"}
className={`text-xs ${check.status === "ok" ? "bg-green-100 text-green-800 hover:bg-green-100" : check.status === "warning" ? "bg-yellow-100 text-yellow-800 hover:bg-yellow-100" : ""}`}
>
{check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{check.message}</p>
</div>
{fix && (
<Button
size="sm"
variant="outline"
className="flex-shrink-0 text-xs"
disabled={fixLoading !== null}
onClick={() => runFix(fix.action)}
>
{fixLoading === fix.action ? "Wird behoben..." : fix.label}
</Button>
)}
{check.name === "HTTPS (TLS)" && check.status !== "ok" && (
<a
href="https://certbot.eff.org/instructions?os=debianbuster&tab=standard"
target="_blank"
rel="noopener noreferrer"
>
<Button size="sm" variant="outline" className="flex-shrink-0 text-xs">
Anleitung
</Button>
</a>
)}
</CardContent>
</Card>
);
})}
{/* Summary */}
<div className="mt-4 grid grid-cols-3 gap-3 text-center text-sm">
{[
{ label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length },
{ label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length },
{ label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length },
].map((s) => (
<div key={s.label} className={`rounded p-3 ${s.color}`}>
<p className="text-2xl font-bold">{s.count}</p>
<p className="text-xs">{s.label}</p>
</div>
))}
</div>
</div>
);
})()}
</TabsContent>
{/* ── LDAP ── */}
<TabsContent value="ldap" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">LDAP / Active Directory</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">LDAP aktiviert</span>
<input
type="checkbox"
className="h-4 w-4"
checked={ldapForm.enabled}
onChange={(e) => setLdapForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</div>
</div>
{ldapError && (
<Alert variant="destructive">
<AlertDescription>{ldapError}</AlertDescription>
</Alert>
)}
{ldapLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<form onSubmit={handleSaveLDAP} className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ldap-url">Server-URL</Label>
<Input
id="ldap-url"
placeholder="ldap://dc.example.com:389"
value={ldapForm.url}
onChange={(e) => setLdapForm((f) => ({ ...f, url: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-bind-dn">Bind-DN (Service-Account)</Label>
<Input
id="ldap-bind-dn"
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
value={ldapForm.bind_dn}
onChange={(e) => setLdapForm((f) => ({ ...f, bind_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-pw">Bind-Passwort</Label>
{ldapConfig && !ldapChangePassword ? (
<div className="flex gap-2">
<Input id="ldap-pw" type="password" value="••••••" readOnly className="flex-1" />
<Button type="button" variant="outline" size="sm" onClick={() => setLdapChangePassword(true)}>
Ändern
</Button>
</div>
) : (
<Input
id="ldap-pw"
type="password"
placeholder="Neues Passwort eingeben"
value={ldapForm.bind_password}
onChange={(e) => setLdapForm((f) => ({ ...f, bind_password: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="ldap-base-dn">Base-DN</Label>
<Input
id="ldap-base-dn"
placeholder="DC=example,DC=com"
value={ldapForm.base_dn}
onChange={(e) => setLdapForm((f) => ({ ...f, base_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-filter">User-Filter</Label>
<Input
id="ldap-filter"
placeholder="(sAMAccountName=%s)"
value={ldapForm.user_filter}
onChange={(e) => setLdapForm((f) => ({ ...f, user_filter: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-role">Standard-Rolle</Label>
<Select
value={ldapForm.default_role}
onValueChange={(v) => setLdapForm((f) => ({ ...f, default_role: v }))}
>
<SelectTrigger id="ldap-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={ldapForm.tls}
onChange={(e) => setLdapForm((f) => ({ ...f, tls: e.target.checked }))}
/>
STARTTLS verwenden
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={ldapForm.tls_skip_verify}
onChange={(e) => setLdapForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
/>
TLS-Zertifikat ignorieren
{ldapForm.tls_skip_verify && (
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
)}
</label>
</div>
<Separator />
{/* Group mappings */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Gruppen-Rollenzuordnung</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setLdapForm((f) => ({
...f,
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
}))
}
>
+ Hinzufügen
</Button>
</div>
{ldapForm.group_mappings.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
) : (
<div className="space-y-2">
{ldapForm.group_mappings.map((gm, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
className="flex-1"
placeholder="CN=Archivadmins,OU=Groups,DC=example,DC=com"
value={gm.group_dn}
onChange={(e) => {
const gms = [...ldapForm.group_mappings];
gms[i] = { ...gms[i], group_dn: e.target.value };
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
/>
<Select
value={gm.role}
onValueChange={(v) => {
const gms = [...ldapForm.group_mappings];
gms[i] = { ...gms[i], role: v };
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
const gms = ldapForm.group_mappings.filter((_, j) => j !== i);
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
Entfernen
</Button>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Test result */}
{ldapTestResult && (
<Card>
<CardContent className="pt-4 space-y-2">
<div className="flex items-center gap-2">
<Badge variant={ldapTestResult.ok ? "default" : "destructive"}>
{ldapTestResult.ok ? "Verbunden" : "Fehler"}
</Badge>
<span className="text-sm">{ldapTestResult.message}</span>
{ldapTestResult.latency_ms > 0 && (
<span className="text-xs text-muted-foreground">{ldapTestResult.latency_ms} ms</span>
)}
</div>
{ldapTestResult.server_info && (
<p className="text-xs text-muted-foreground font-mono">{ldapTestResult.server_info}</p>
)}
{ldapTestResult.users_found > 0 && (
<p className="text-sm">{ldapTestResult.users_found} Benutzer gefunden</p>
)}
{ldapTestResult.error_detail && (
<p className="text-xs text-destructive font-mono">{ldapTestResult.error_detail}</p>
)}
</CardContent>
</Card>
)}
{/* Action bar */}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={handleTestLDAP} disabled={ldapTesting || ldapSaving}>
{ldapTesting ? "Teste..." : "Verbindung testen"}
</Button>
<Button type="submit" disabled={ldapSaving || ldapTesting}>
{ldapSaving ? "Speichern..." : "Speichern"}
</Button>
{ldapConfig && (
<Button
type="button"
variant="destructive"
disabled={ldapSaving}
onClick={handleDeleteLDAP}
>
Konfiguration löschen
</Button>
)}
</div>
</form>
)}
{ldapConfig && (
<p className="text-xs text-muted-foreground">
Zuletzt geändert: {ldapConfig.updated_at ? new Date(ldapConfig.updated_at).toLocaleString("de-DE") : ""}
{ldapConfig.updated_by ? ` von ${ldapConfig.updated_by}` : ""}
</p>
)}
</TabsContent>
{/* ── Mandanten ── */}
<TabsContent value="tenants" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Mandantenverwaltung</h2>
<Dialog open={tenantDialogOpen} onOpenChange={setTenantDialogOpen}>
<DialogTrigger asChild>
<Button size="sm">+ Mandant anlegen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuen Mandanten anlegen</DialogTitle>
<DialogDescription>Name und URL-Slug für den neuen Mandanten eingeben.</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateTenant} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tenant-name">Name</Label>
<Input
id="tenant-name"
value={newTenantName}
required
onChange={(e) => {
setNewTenantName(e.target.value);
setNewTenantSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug">Slug</Label>
<Input
id="tenant-slug"
value={newTenantSlug}
required
onChange={(e) => setNewTenantSlug(e.target.value)}
/>
</div>
{tenantCreateError && (
<p className="text-sm text-destructive">{tenantCreateError}</p>
)}
<DialogFooter>
<Button type="submit" disabled={tenantCreateLoading}>
{tenantCreateLoading ? "Erstellen..." : "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{tenantsError && (
<Alert variant="destructive">
<AlertDescription>{tenantsError}</AlertDescription>
</Alert>
)}
{tenantsLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : tenants.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Mandanten vorhanden. Klicke auf &ldquo;+ Mandant anlegen&rdquo; um den ersten Mandanten zu erstellen.
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Slug</TableHead>
<TableHead className="text-center">Domains</TableHead>
<TableHead className="text-center">Nutzer</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenants.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{t.slug}</TableCell>
<TableCell className="text-center">{t.domain_count ?? 0}</TableCell>
<TableCell className="text-center">{t.user_count ?? 0}</TableCell>
<TableCell>
<Badge variant={t.active ? "default" : "secondary"}>
{t.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" onClick={() => openDomainDialog(t)}>
Domains
</Button>
<Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}>
{t.active ? "Deaktivieren" : "Aktivieren"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setTenantDeleteId(t.id)}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
{/* Tenant delete confirmation */}
<Dialog open={tenantDeleteId !== null} onOpenChange={(open) => { if (!open) setTenantDeleteId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mandant löschen</DialogTitle>
<DialogDescription>
Alle Daten dieses Mandanten (Domains, LDAP-Konfiguration) werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setTenantDeleteId(null)}>Abbrechen</Button>
<Button variant="destructive" disabled={tenantDeleteLoading} onClick={handleDeleteTenant}>
{tenantDeleteLoading ? "Löschen..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Domain management dialog */}
<Dialog open={domainDialogTenant !== null} onOpenChange={(open) => { if (!open) setDomainDialogTenant(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Domains: {domainDialogTenant?.name}</DialogTitle>
<DialogDescription>E-Mail-Domains diesem Mandanten zuweisen.</DialogDescription>
</DialogHeader>
{domainError && (
<Alert variant="destructive">
<AlertDescription>{domainError}</AlertDescription>
</Alert>
)}
{domainsLoading ? (
<Skeleton className="h-24 w-full" />
) : (
<div className="space-y-2">
{tenantDomains.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Domains zugewiesen.</p>
) : (
tenantDomains.map((d) => (
<div key={d.id} className="flex items-center justify-between rounded border px-3 py-2">
<span className="font-mono text-sm">{d.domain}</span>
<Button size="sm" variant="destructive" onClick={() => handleRemoveDomain(d.id)}>
Entfernen
</Button>
</div>
))
)}
</div>
)}
<div className="flex gap-2 pt-2">
<Input
placeholder="example.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddDomain(); } }}
/>
<Button onClick={handleAddDomain} disabled={addDomainLoading || !newDomain}>
{addDomainLoading ? "..." : "Hinzufügen"}
</Button>
</div>
</DialogContent>
</Dialog>
</TabsContent>
<TabsContent value="modules" className="mt-4">
<ModulesTab />
</TabsContent>
</Tabs>
</>)}
</main>
{/* Passwort-Reset Dialog */}
<Dialog open={resetPasswordUserId !== null} onOpenChange={(open) => { if (!open) setResetPasswordUserId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Passwort zurücksetzen</DialogTitle>
<DialogDescription>
Neues Passwort für den Benutzer festlegen.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleResetPassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-password">Neues Passwort</Label>
<Input
id="new-password"
type="password"
value={resetPasswordValue}
onChange={(e) => setResetPasswordValue(e.target.value)}
required
minLength={8}
placeholder="Mindestens 8 Zeichen"
/>
</div>
{resetPasswordError && (
<p className="text-sm text-destructive">{resetPasswordError}</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setResetPasswordUserId(null)}>
Abbrechen
</Button>
<Button type="submit" disabled={resetPasswordLoading}>
{resetPasswordLoading ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Nutzer-Löschung Dialog */}
<Dialog open={deleteDialogUser !== null} onOpenChange={(open) => { if (!open) setDeleteDialogUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Benutzer entfernen</DialogTitle>
<DialogDescription>
Was soll mit dem Konto <strong>{deleteDialogUser?.username}</strong> passieren?
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800">
<strong>Hinweis (GoBD):</strong> E-Mails bleiben unabhängig von dieser Aktion im Archiv erhalten. Die gesetzliche Aufbewahrungspflicht besteht auch nach Ausscheiden des Mitarbeiters.
</div>
<div className="space-y-3">
<div className="rounded-md border p-3">
<p className="font-medium text-sm">Konto deaktivieren (empfohlen)</p>
<p className="text-xs text-muted-foreground mt-1">
Login wird gesperrt. Konto und IMAP-Verbindungen bleiben erhalten und können reaktiviert werden.
</p>
</div>
<div className="rounded-md border border-destructive/30 p-3">
<p className="font-medium text-sm text-destructive">Konto endgültig löschen</p>
<p className="text-xs text-muted-foreground mt-1">
Account und alle IMAP-Verbindungen werden dauerhaft entfernt. Nicht rückgängig zu machen.
</p>
</div>
</div>
{deleteDialogError && (
<p className="text-sm text-destructive">{deleteDialogError}</p>
)}
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => setDeleteDialogUser(null)} disabled={deleteActionLoading !== null}>
Abbrechen
</Button>
<Button
variant="outline"
onClick={handleDeactivateConfirmed}
disabled={deleteActionLoading !== null || deleteDialogUser?.active === false}
>
{deleteActionLoading === "deactivate" ? "Wird deaktiviert..." : "Deaktivieren"}
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirmed}
disabled={deleteActionLoading !== null}
>
{deleteActionLoading === "delete" ? "Wird gelöscht..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── Module Tab ─────────────────────────────────────────────────────────────
const statusColors: Record<string, string> = {
"Planned": "bg-gray-100 text-gray-700",
"In Progress": "bg-yellow-100 text-yellow-800",
"In Review": "bg-blue-100 text-blue-800",
"Deployed": "bg-green-100 text-green-800",
};
const statusCounts = (list: Feature[]) => ({
total: list.length,
planned: list.filter((f) => f.status === "Planned").length,
inProgress: list.filter((f) => f.status === "In Progress").length,
inReview: list.filter((f) => f.status === "In Review").length,
deployed: list.filter((f) => f.status === "Deployed").length,
});
function ModulesTab() {
const counts = statusCounts(features);
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Modulübersicht</h2>
{/* Summary bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "In Progress", value: counts.inProgress, color: "bg-yellow-100 text-yellow-800" },
{ label: "In Review", value: counts.inReview, color: "bg-blue-100 text-blue-800" },
{ label: "Deployed", value: counts.deployed, color: "bg-green-100 text-green-800" },
{ label: "Geplant", value: counts.planned, color: "bg-gray-100 text-gray-700" },
].map((s) => (
<Card key={s.label}>
<CardContent className="p-4 flex items-center justify-between">
<span className="text-sm text-muted-foreground">{s.label}</span>
<span className={`text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
{s.value}
</span>
</CardContent>
</Card>
))}
</div>
{/* Table */}
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead>Feature</TableHead>
<TableHead className="w-32">Status</TableHead>
<TableHead className="w-24 text-center">Frontend</TableHead>
<TableHead className="w-24 text-center">Backend</TableHead>
<TableHead className="w-32">Aktualisiert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{features.map((f) => (
<TableRow key={f.id}>
<TableCell className="font-mono text-xs text-muted-foreground">
{f.id}
</TableCell>
<TableCell className="font-medium">{f.name}</TableCell>
<TableCell>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
{f.status}
</span>
</TableCell>
<TableCell className="text-center">
{f.frontend
? <span className="text-green-600 font-bold"></span>
: <span className="text-muted-foreground"></span>
}
</TableCell>
<TableCell className="text-center">
{f.backend
? <span className="text-green-600 font-bold"></span>
: <span className="text-muted-foreground"></span>
}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{f.lastUpdated}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
);
}