From bc4a98de0ddf840eff3dc7215f4d2e0666569f74 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 20 Mar 2026 12:30:16 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20admin/page.tsx=20in=20Einzelkomponente?= =?UTF-8?q?n=20aufteilen=20(3917=20=E2=86=92=201304=20Zeilen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tab-Sektionen → src/components/admin/tabs/ (11 Dateien) - Dialoge → src/components/admin/ (TenantLDAPDialog, UserDialogs) - Keine Verhaltensänderungen, TypeScript fehlerfrei Co-Authored-By: Claude Sonnet 4.6 --- src/app/admin/page.tsx | 3197 ++----------------- src/components/admin/ModulesTab.tsx | 102 + src/components/admin/TenantLDAPDialog.tsx | 398 +++ src/components/admin/UserDialogs.tsx | 146 + src/components/admin/tabs/AuditTab.tsx | 114 + src/components/admin/tabs/CertTab.tsx | 256 ++ src/components/admin/tabs/DashboardTab.tsx | 394 +++ src/components/admin/tabs/ImportTab.tsx | 121 + src/components/admin/tabs/LDAPTab.tsx | 340 ++ src/components/admin/tabs/LabelsTab.tsx | 282 ++ src/components/admin/tabs/SecurityTab.tsx | 148 + src/components/admin/tabs/ServicesTab.tsx | 205 ++ src/components/admin/tabs/TenantLDAPTab.tsx | 393 +++ src/components/admin/tabs/TenantsTab.tsx | 507 +++ src/components/admin/tabs/UsersTab.tsx | 242 ++ 15 files changed, 3940 insertions(+), 2905 deletions(-) create mode 100644 src/components/admin/ModulesTab.tsx create mode 100644 src/components/admin/TenantLDAPDialog.tsx create mode 100644 src/components/admin/UserDialogs.tsx create mode 100644 src/components/admin/tabs/AuditTab.tsx create mode 100644 src/components/admin/tabs/CertTab.tsx create mode 100644 src/components/admin/tabs/DashboardTab.tsx create mode 100644 src/components/admin/tabs/ImportTab.tsx create mode 100644 src/components/admin/tabs/LDAPTab.tsx create mode 100644 src/components/admin/tabs/LabelsTab.tsx create mode 100644 src/components/admin/tabs/SecurityTab.tsx create mode 100644 src/components/admin/tabs/ServicesTab.tsx create mode 100644 src/components/admin/tabs/TenantLDAPTab.tsx create mode 100644 src/components/admin/tabs/TenantsTab.tsx create mode 100644 src/components/admin/tabs/UsersTab.tsx diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f29f714..b0dd2ab 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useAuth } from "@/hooks/useAuth"; -import { features, type Feature } from "@/data/features"; import { getUsers, createUser, @@ -35,10 +34,6 @@ import { saveTenantLDAPConfig, deleteTenantLDAPConfig, testTenantLDAPConfig, - getAdminTenantLDAPConfig, - saveAdminTenantLDAPConfig, - deleteAdminTenantLDAPConfig, - testAdminTenantLDAPConfig, syncAdminTenantLDAP, type LDAPSyncResult, getAdminLabels, @@ -59,7 +54,6 @@ import { type ServiceStatus, type SystemStats, type UploadJob, - type SecurityCheck, type SecurityAuditResult, type LDAPConfig, type LDAPTestResult, @@ -76,49 +70,26 @@ import { type CertInfo, } 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"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +// Tab components +import { DashboardTab } from "@/components/admin/tabs/DashboardTab"; +import { ServicesTab } from "@/components/admin/tabs/ServicesTab"; +import { UsersTab } from "@/components/admin/tabs/UsersTab"; +import { AuditTab } from "@/components/admin/tabs/AuditTab"; +import { ImportTab } from "@/components/admin/tabs/ImportTab"; +import { SecurityTab } from "@/components/admin/tabs/SecurityTab"; +import { LDAPTab } from "@/components/admin/tabs/LDAPTab"; +import { TenantLDAPTab } from "@/components/admin/tabs/TenantLDAPTab"; +import { TenantsTab } from "@/components/admin/tabs/TenantsTab"; +import { LabelsTab } from "@/components/admin/tabs/LabelsTab"; +import { CertTab } from "@/components/admin/tabs/CertTab"; +import { ModulesTab } from "@/components/admin/ModulesTab"; +import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs"; 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"; @@ -290,17 +261,14 @@ export default function AdminPage() { const [certLoading, setCertLoading] = useState(false); const [certError, setCertError] = useState(""); const [certSuccess, setCertSuccess] = useState(""); - // Upload state const [certFile, setCertFile] = useState(null); const [keyFile, setKeyFile] = useState(null); const [certUploadLoading, setCertUploadLoading] = useState(false); - // Self-signed state const [selfSignedCN, setSelfSignedCN] = useState("archivmail"); const [selfSignedDNS, setSelfSignedDNS] = useState("archivmail"); const [selfSignedIPs, setSelfSignedIPs] = useState("192.168.1.131"); const [selfSignedYears, setSelfSignedYears] = useState("10"); const [selfSignedLoading, setSelfSignedLoading] = useState(false); - // ACME state const [acmeDomain, setAcmeDomain] = useState(""); const [acmeEmail, setAcmeEmail] = useState(""); const [acmeLoading, setAcmeLoading] = useState(false); @@ -389,7 +357,6 @@ export default function AdminPage() { setUploadLoading(true); try { const { job_id } = await uploadMailFiles(valid); - // Start polling const poll = setInterval(async () => { try { const job = await getUploadProgress(job_id); @@ -434,14 +401,12 @@ export default function AdminPage() { 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); @@ -555,7 +520,6 @@ export default function AdminPage() { 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."); @@ -808,7 +772,6 @@ export default function AdminPage() { try { const result = await syncAdminTenantLDAP(tenantUsersDialogId); setTenantUsersSyncResult(result); - // Reload user list after sync const users = await getTenantUsers(tenantUsersDialogId); setTenantUsers(users || []); } catch (err: unknown) { @@ -1011,8 +974,6 @@ export default function AdminPage() { } } - const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE); - return (
@@ -1044,2319 +1005,271 @@ export default function AdminPage() { {isSuperAdmin && Module} - {/* ── Dashboard ── */} -
-

Systemstatus

-
- {dashRefreshed && ( - - {dashRefreshed.toLocaleTimeString("de-DE")} · nächste Aktualisierung in {countdown}s - - )} - -
-
- - {dashLoading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) : ( - <> - {/* Status-Kacheln */} -
- - {/* API */} - - -
- REST API - - {apiOnline ? "Online" : "Offline"} - -
- -
- Adresse - :8080 - Protokoll - HTTP -
-
-
- - {/* SMTP — superadmin: globaler Daemon; domain_admin: nur eigene Domain-Statistik */} - {isSuperAdmin ? ( - <> - - -
- SMTP-Daemon - - {smtpStatus?.running ? "Aktiv" : smtpStatus?.enabled === false ? "Deaktiviert" : "Gestoppt"} - -
- - {smtpStatus ? ( -
- Adresse - {smtpStatus.bind} - Domain - {smtpStatus.domain || "–"} - TLS - {smtpStatus.tls ? "Ja" : "Nein"} - Max. Größe - {(smtpStatus.max_size_mb ?? 0) > 0 ? `${smtpStatus.max_size_mb} MB` : "50 MB"} -
- ) : ( -

Nicht erreichbar

- )} -
-
- - -
- SMTP Statistik - seit letztem Start -
- - {smtpStatus ? ( -
- Empfangen - {smtpStatus.received ?? 0} - Abgelehnt - {smtpStatus.rejected ?? 0} - Letzte Mail - - {smtpStatus.last_mail_at - ? new Date(smtpStatus.last_mail_at).toLocaleString("de-DE") - : "–"} - -
- ) : ( -

Keine Daten

- )} -
-
- - ) : ( - - -
- SMTP – meine Domain(s) - Tenant -
- - {smtpStatus ? ( -
- Domain(s) - - {(smtpStatus.domains?.length ?? 0) > 0 ? smtpStatus.domains!.join(", ") : "–"} - - Archivierte Mails - {smtpStatus.total_mails?.toLocaleString("de-DE") ?? "–"} - Speicher - {smtpStatus.total_bytes ? formatBytes(smtpStatus.total_bytes) : "–"} -
- ) : ( -

Keine Daten

- )} -
-
- )} - - {/* Archiv-Speicher */} - - -
- Archiv gesamt - {storageStats && ( - - {storageStats.total_mails} Mails - - )} -
- - {storageStats ? ( -
- E-Mails - {storageStats.total_mails.toLocaleString("de-DE")} - Speicher - {formatBytes(storageStats.total_bytes)} -
- ) : ( -

Keine Daten

- )} -
-
-
- - {/* System Stats: nur für superadmin */} - {isSuperAdmin &&
-

Systemauslastung

- {!systemStats ? ( - - - Systemdaten konnten nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft und der Endpunkt /api/admin/system/stats erreichbar ist. - - - ) : ( - <> -
- - {/* CPU */} - - -
- CPU Load Average - {systemStats.cpu.num_cpu} CPU(s) -
- -
- 1 min - {systemStats.cpu.load1.toFixed(2)} - 5 min - {systemStats.cpu.load5.toFixed(2)} - 15 min - {systemStats.cpu.load15.toFixed(2)} -
-
-
- - {/* RAM */} - - -
- Arbeitsspeicher - 90 ? "destructive" : systemStats.ram.used_pct > 70 ? "secondary" : "default"}> - {systemStats.ram.used_pct.toFixed(1)}% - -
- -
-
-
90 ? "bg-destructive" : systemStats.ram.used_pct > 70 ? "bg-yellow-500" : "bg-primary"}`} - style={{ width: `${Math.min(systemStats.ram.used_pct, 100)}%` }} - /> -
-
- Belegt - {formatBytes(systemStats.ram.used_bytes)} - Gesamt - {formatBytes(systemStats.ram.total_bytes)} - Frei - {formatBytes(systemStats.ram.free_bytes)} -
-
- - - - {/* Archivzeitraum */} - - -
- Archivzeitraum -
- - {systemStats.archive.first_mail || systemStats.archive.last_mail ? ( -
- {systemStats.archive.first_mail && ( -
- Älteste Mail - {new Date(systemStats.archive.first_mail.date).toLocaleDateString("de-DE")} - {systemStats.archive.first_mail.from || "–"} - {systemStats.archive.first_mail.subject || "(kein Betreff)"} -
- )} - {systemStats.archive.last_mail && ( -
- Neueste Mail - {new Date(systemStats.archive.last_mail.date).toLocaleDateString("de-DE")} - {systemStats.archive.last_mail.from || "–"} - {systemStats.archive.last_mail.subject || "(kein Betreff)"} -
- )} -
- ) : ( -

Archiv leer

- )} -
-
-
- - {/* Festplatten */} - {systemStats.disks.length > 0 && ( -
-

Festplatten

-
- {systemStats.disks.map((disk) => ( - - -
- {disk.mount} - 90 ? "destructive" : disk.used_pct > 75 ? "secondary" : "outline"}> - {disk.used_pct.toFixed(1)}% - -
-
-
90 ? "bg-destructive" : disk.used_pct > 75 ? "bg-yellow-500" : "bg-primary"}`} - style={{ width: `${Math.min(disk.used_pct, 100)}%` }} - /> -
-
- Belegt - {formatBytes(disk.used_bytes)} - Gesamt - {formatBytes(disk.total_bytes)} - Frei - {formatBytes(disk.free_bytes)} - Dateisystem - {disk.fstype} -
- - - ))} -
-
- )} - - )} -
} - - {/* IP-Allowlist — nur superadmin */} - {isSuperAdmin && smtpStatus && (smtpStatus.allowed_ips?.length ?? 0) > 0 && ( - - - SMTP IP-Allowlist - -
- {smtpStatus.allowed_ips!.map((ip) => ( - - {ip} - - ))} -
-
-
- )} - - {/* Benutzerübersicht */} - - - Benutzer - - {usersLoading ? ( - - ) : ( -
- - {users.filter(u => u.active).length} - aktiv - - - {users.filter(u => u.role === "admin").length} - Admin - - - {users.filter(u => u.role === "auditor").length} - Auditor - - - {users.filter(u => u.role === "user").length} - User - -
- )} -
-
- - {!smtpStatus && ( - - - SMTP-Status konnte nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft. - - - )} - - )} + { loadDashboard(); setCountdown(30); }} + /> - {/* ── Dienste ── */} - -
-

Systemdienste

- -
- - {serviceError && ( - - {serviceError} - - )} - - {servicesLoading && services.length === 0 ? ( - - - {Array.from({ length: 5 }).map((_, i) => ( - - ))} - - - ) : ( - - - - - Dienst - Status - Autostart - Externer Zugriff - Beschreibung - {isSuperAdmin && Aktionen} - - - - {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 ( - - - {svc.name} - - - - {svc.active === "active" - ? `Aktiv (${svc.sub})` - : svc.active === "failed" - ? "Fehler" - : svc.active === "inactive" - ? "Gestoppt" - : svc.active} - - - - - {svc.enabled === "enabled" - ? "Aktiviert" - : svc.enabled === "disabled" - ? "Deaktiviert" - : svc.enabled === "static" - ? "Statisch" - : svc.enabled} - - - - {svc.external_blocked !== undefined ? ( - - {svc.external_blocked ? "Gesperrt" : "Offen"} - - ) : ( - - )} - - - {svc.description || "–"} - - {isSuperAdmin && ( - -
- {isActive ? ( - <> - - - - ) : ( - - )} - {svc.enabled === "enabled" ? ( - - ) : svc.enabled === "disabled" ? ( - - ) : null} - {svc.external_blocked !== undefined && ( - svc.external_blocked ? ( - - ) : ( - - ) - )} -
-
- )} -
- ); - })} -
-
-
- )} -
- - -
-

Benutzerverwaltung

- - - - - - - Neuen Benutzer anlegen - - Erstellen Sie einen neuen Benutzer-Account. - - -
-
- - setNewUsername(e.target.value)} - required - aria-label="Neuer Benutzername" - /> -
-
- - setNewEmail(e.target.value)} - required - aria-label="Neue E-Mail-Adresse" - /> -
-
- - setNewPassword(e.target.value)} - required - aria-label="Neues Passwort" - /> -
-
- - -
- {createError && ( -

- {createError} -

- )} - - - -
-
-
-
- - {usersLoading ? ( - - - {Array.from({ length: 4 }).map((_, i) => ( - - ))} - - - ) : usersError ? ( - - - {usersError} - - - ) : users.length === 0 ? ( - - - Keine Benutzer vorhanden. - - - ) : ( - - - - - Benutzername - E-Mail - Rolle - Status - Aktionen - - - - {users.map((u) => ( - - {u.username} - {u.email} - - {u.role} - - - - {u.active ? "Aktiv" : "Inaktiv"} - - - -
- - - -
-
-
- ))} -
-
-
- )} -
- - -

Audit-Log

- - {auditLoading ? ( - - - {Array.from({ length: 5 }).map((_, i) => ( - - ))} - - - ) : auditEntries.length === 0 ? ( - - - Keine Audit-Eintraege vorhanden. - - - ) : ( - <> - - - - - Zeitstempel - Ereignis - Benutzer - Details - - - - {auditEntries.map((entry) => ( - - - {new Date(entry.timestamp).toLocaleString("de-DE")} - - - - {entry.event_type} - - - {entry.username} - - {entry.detail} - - - ))} - -
-
- - {auditTotalPages > 1 && ( -
- - - Seite {auditPage} von {auditTotalPages} - - -
- )} - - )} -
- {/* ── Import ── */} - -

EML / MBOX importieren

-

- Lade .eml oder .mbox Dateien hoch um sie ins Archiv zu importieren. Duplikate werden automatisch übersprungen. -

- - {/* Drop zone */} -
{ 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()} - > - { - if (e.target.files) handleUploadFiles(Array.from(e.target.files)); - e.target.value = ""; - }} - /> -

Dateien hierher ziehen oder klicken zum Auswählen

-

Akzeptiert: .eml, .mbox

-
- - {uploadError && ( - - {uploadError} - - )} - - {/* Progress */} - {(uploadLoading || uploadJob) && uploadJob && ( - - -
- - {uploadJob.status === "running" ? "Import läuft..." : "Import abgeschlossen"} - - - {uploadJob.status === "done" ? "Fertig" : "Läuft"} - -
- - {/* Progress bar */} - {uploadJob.total > 0 && ( -
-
-
-
-

- {uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total} verarbeitet -

-
- )} - - {uploadJob.status === "done" && ( -
-
-

{uploadJob.imported}

-

Importiert

-
-
-

{uploadJob.skipped}

-

Übersprungen

-
-
-

{uploadJob.errors}

-

Fehler

-
-
- )} - - - )} - - {uploadLoading && !uploadJob && ( -

Upload läuft...

- )} - - - {/* ── Security Audit ── */} - -
-
-

Security Audit

- {securityAudit && ( -

- Zuletzt geprüft: {new Date(securityAudit.run_at).toLocaleString("de-DE")} -

- )} -
- -
- - {securityError && ( - - {securityError} - - )} - - {fixMessage && ( - - {fixMessage} - - )} - - {!securityAudit && !securityLoading && !securityError && ( -
- Klicke auf “Jetzt prüfen” um den Security-Audit zu starten. -
- )} - - {securityLoading && ( -
- {Array.from({ length: 7 }).map((_, i) => ( - - ))} -
- )} - - {securityAudit && !securityLoading && (() => { - // Map check names to fix actions - const fixActions: Record = { - "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 ( -
- {securityAudit.checks.map((check: SecurityCheck, i: number) => { - const fix = check.status !== "ok" ? fixActions[check.name] : undefined; - return ( - - - -
-
- {check.name} - - {check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"} - -
-

{check.message}

-
- {fix && ( - - )} - {check.name === "HTTPS (TLS)" && check.status !== "ok" && ( - - - - )} -
-
- ); - })} - - {/* Summary */} -
- {[ - { 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) => ( -
-

{s.count}

-

{s.label}

-
- ))} -
-
- ); - })()} -
- - {/* ── LDAP ── */} - -
-

LDAP / Active Directory

-
- LDAP aktiviert - setLdapForm((f) => ({ ...f, enabled: e.target.checked }))} - /> -
-
- - {ldapError && ( - - {ldapError} - - )} - - {ldapLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- ) : ( -
- - -
-
- - setLdapForm((f) => ({ ...f, url: e.target.value }))} - /> -

- Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636 -

-
-
- - setLdapForm((f) => ({ ...f, bind_dn: e.target.value }))} - /> -
-
- - {ldapConfig && !ldapChangePassword ? ( -
- - -
- ) : ( - setLdapForm((f) => ({ ...f, bind_password: e.target.value }))} - /> - )} -
-
- - setLdapForm((f) => ({ ...f, base_dn: e.target.value }))} - /> -
-
- - setLdapForm((f) => ({ ...f, user_filter: e.target.value }))} - /> -
-
- - -
-
- - - -
- - -
- - - - {/* Group mappings */} -
-
- - -
- {ldapForm.group_mappings.length === 0 ? ( -

Keine Gruppen-Zuordnungen definiert.

- ) : ( -
- {ldapForm.group_mappings.map((gm, i) => ( -
- { - const gms = [...ldapForm.group_mappings]; - gms[i] = { ...gms[i], group_dn: e.target.value }; - setLdapForm((f) => ({ ...f, group_mappings: gms })); - }} - /> - - -
- ))} -
- )} -
-
-
- - {/* Test result */} - {ldapTestResult && ( - - -
- - {ldapTestResult.ok ? "Verbunden" : "Fehler"} - - {ldapTestResult.message} - {ldapTestResult.latency_ms > 0 && ( - {ldapTestResult.latency_ms} ms - )} -
- {ldapTestResult.server_info && ( -

{ldapTestResult.server_info}

- )} - {ldapTestResult.error_detail && ( -

{ldapTestResult.error_detail}

- )} - {ldapTestResult.ok && ldapTestResult.users_found > 0 && ( -
-

- {ldapTestResult.users_found} Benutzer gefunden - {ldapTestResult.users?.length < ldapTestResult.users_found && ( - (Vorschau: {ldapTestResult.users?.length}) - )} -

-
- - - - - - - - - - {ldapTestResult.users?.map((u, i) => ( - - - - - - ))} - -
UIDNameE-Mail
{u.uid || "–"}{u.display_name || "–"}{u.mail || "–"}
-
-
- )} -
-
- )} - - {/* Action bar */} -
- - - {ldapConfig && ( - - )} -
-
- )} - - {ldapConfig && ( -

- Zuletzt geändert: {ldapConfig.updated_at ? new Date(ldapConfig.updated_at).toLocaleString("de-DE") : "–"} - {ldapConfig.updated_by ? ` von ${ldapConfig.updated_by}` : ""} -

- )} -
- - {/* ── Tenant LDAP (domain_admin) ── */} - -
-
-

LDAP / Active Directory — Mandantenkonfiguration

-

Konfiguriere den LDAP-Server für deinen Mandanten.

-
-
- LDAP aktiviert - setTenantLdapForm((f) => ({ ...f, enabled: e.target.checked }))} - /> -
-
- - {tenantLdapError && ( - - {tenantLdapError} - - )} - - {tenantLdapLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- ) : ( -
- - -
-
- - setTenantLdapForm((f) => ({ ...f, url: e.target.value }))} - /> -

- Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636 -

-
-
- - setTenantLdapForm((f) => ({ ...f, bind_dn: e.target.value }))} - /> -
-
- - {tenantLdapConfig && !tenantLdapChangePassword ? ( -
- - -
- ) : ( - setTenantLdapForm((f) => ({ ...f, bind_password: e.target.value }))} - /> - )} -
-
- - setTenantLdapForm((f) => ({ ...f, base_dn: e.target.value }))} - /> -
-
- - setTenantLdapForm((f) => ({ ...f, user_filter: e.target.value }))} - /> -
-
- - -
-
- - - -
- - -
- - - - {/* Group mappings -- domain_admin: nur user + auditor */} -
-
- - -
- {tenantLdapForm.group_mappings.length === 0 ? ( -

Keine Gruppen-Zuordnungen definiert.

- ) : ( -
- {tenantLdapForm.group_mappings.map((gm, i) => ( -
- { - const gms = [...tenantLdapForm.group_mappings]; - gms[i] = { ...gms[i], group_dn: e.target.value }; - setTenantLdapForm((f) => ({ ...f, group_mappings: gms })); - }} - /> - - -
- ))} -
- )} -
-
-
- - {/* Test result */} - {tenantLdapTestResult && ( - - -
- - {tenantLdapTestResult.ok ? "Verbunden" : "Fehler"} - - {tenantLdapTestResult.message} - {tenantLdapTestResult.latency_ms > 0 && ( - {tenantLdapTestResult.latency_ms} ms - )} -
- {tenantLdapTestResult.server_info && ( -

{tenantLdapTestResult.server_info}

- )} - {tenantLdapTestResult.error_detail && ( -

{tenantLdapTestResult.error_detail}

- )} - {tenantLdapTestResult.ok && tenantLdapTestResult.users_found > 0 && ( -
-

- {tenantLdapTestResult.users_found} Benutzer gefunden - {tenantLdapTestResult.users?.length < tenantLdapTestResult.users_found && ( - (Vorschau: {tenantLdapTestResult.users?.length}) - )} -

-
- - - - - - - - - - {tenantLdapTestResult.users?.map((u, i) => ( - - - - - - ))} - -
UIDNameE-Mail
{u.uid || "–"}{u.display_name || "–"}{u.mail || "–"}
-
-
- )} -
-
- )} - - {/* Action bar */} -
- - - {tenantLdapConfig && ( - - )} -
-
- )} - - {tenantLdapConfig && ( -

- Zuletzt geändert: {tenantLdapConfig.updated_at ? new Date(tenantLdapConfig.updated_at).toLocaleString("de-DE") : "–"} - {tenantLdapConfig.updated_by ? ` von ${tenantLdapConfig.updated_by}` : ""} -

- )} - - {/* Logo section for domain_admin */} -
-
-

Mandanten-Logo

-

Logo deines Mandanten hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB).

-
- {ownLogoError &&

{ownLogoError}

} - {ownLogoPreviewUrl ? ( -
-
- {/* eslint-disable-next-line @next/next/no-img-element */} - Logo -
-
- { const f = e.target.files?.[0]; if (f) handleOwnLogoUpload(f); }} - className="w-auto" - /> - -
-
- ) : ( -
-
- Kein Logo -
- { const f = e.target.files?.[0]; if (f) handleOwnLogoUpload(f); }} - className="w-auto" - /> -
- )} -
-
- - {/* ── Mandanten ── */} - -
-

Mandantenverwaltung

- - - - - - - Neuen Mandanten anlegen - Name und URL-Slug für den neuen Mandanten eingeben. - -
-
- - { - setNewTenantName(e.target.value); - setNewTenantSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")); - }} - /> -
-
- - setNewTenantSlug(e.target.value)} - /> -
- {tenantCreateError && ( -

{tenantCreateError}

- )} - - - -
-
-
-
- - {tenantsError && ( - - {tenantsError} - - )} - - {tenantsLoading ? ( - - - {Array.from({ length: 4 }).map((_, i) => ( - - ))} - - - ) : tenants.length === 0 ? ( - - - Keine Mandanten vorhanden. Klicke auf “+ Mandant anlegen” um den ersten Mandanten zu erstellen. - - - ) : ( - - - - - Name - Slug - Domains - Nutzer - LDAP - Status - Aktionen - - - - {tenants.map((t) => ( - - {t.name} - {t.slug} - {t.domain_count ?? 0} - {t.user_count ?? 0} - - {t.ldap_enabled === true ? ( - Aktiv - ) : t.ldap_url ? ( - Deaktiviert - ) : ( - - )} - - - - {t.active ? "Aktiv" : "Inaktiv"} - - - -
- - - - - - -
-
-
- ))} -
-
-
- )} - - {/* Tenant delete confirmation */} - { if (!open) setTenantDeleteId(null); }}> - - - Mandant löschen - - Alle Daten dieses Mandanten (Domains, LDAP-Konfiguration) werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. - - - - - - - - - - {/* Logo upload dialog */} - { if (!open) { setLogoDialogTenant(null); setLogoPreviewUrl(null); } }}> - - - Logo — {logoDialogTenant?.name} - Logo hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB). - -
- {logoPreviewUrl && ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - Logo -
- )} - {!logoPreviewUrl && ( -
- Kein Logo gesetzt -
- )} - {logoError &&

{logoError}

} -
- { const f = e.target.files?.[0]; if (f) handleLogoUpload(f); }} - /> - {logoPreviewUrl && ( - - )} -
-
- - - -
-
- - {/* Default credentials dialog after tenant creation */} - - - - Mandant „{tenantCreatedName}“ erstellt - - Folgende Standard-Benutzer wurden angelegt. Passwörter bitte sofort notieren — sie werden nur einmalig angezeigt. - - -
- {tenantCreatedUsers.map((u) => ( -
-
Benutzer: {u.username}
-
Passwort: {u.password}
-
Rolle: {u.role}
-
- ))} -
- - - -
-
- - {/* Domain management dialog */} - { if (!open) setDomainDialogTenant(null); }}> - - - Domains: {domainDialogTenant?.name} - E-Mail-Domains diesem Mandanten zuweisen. - - - {domainError && ( - - {domainError} - - )} - - {domainsLoading ? ( - - ) : ( -
- {tenantDomains.length === 0 ? ( -

Keine Domains zugewiesen.

- ) : ( - tenantDomains.map((d) => ( -
- {d.domain} - -
- )) - )} -
- )} - -
- setNewDomain(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddDomain(); } }} - /> - -
-
-
- - {/* Tenant users dialog */} - { if (!open) setTenantUsersDialogId(null); }}> - - - Nutzer: {tenantUsersDialogName} - Dem Mandanten zugewiesene Benutzerkonten. - - {tenantUsersLoading ? ( - - ) : tenantUsersError ? ( - - {tenantUsersError} - - ) : tenantUsers.length === 0 ? ( -
-

Keine lokalen Benutzer diesem Mandanten zugewiesen.

- {tenantUsersDialogLdap && ( -

LDAP ist aktiv — Benutzer erscheinen hier nach ihrem ersten Login oder nach der Synchronisation.

- )} -
- ) : ( - - - - Benutzername - E-Mail - Rolle - Quelle - Status - - - - {tenantUsers.map((u) => ( - - {u.username} - {u.email} - {u.role} - {u.source || "local"} - - - {u.active ? "Aktiv" : "Inaktiv"} - - - - ))} - -
- )} - {tenantUsersDialogLdap && ( -
-
- - {tenantUsersSyncResult && ( - - {tenantUsersSyncResult.synced} Benutzer synchronisiert - {tenantUsersSyncResult.errors.length > 0 && ( - ({tenantUsersSyncResult.errors.length} Fehler) - )} - - )} -
- {tenantUsersSyncResult && (tenantUsersSyncResult.errors?.length ?? 0) > 0 && ( -

{tenantUsersSyncResult.errors.join(", ")}

- )} -
- )} -
-
- - {/* Tenant LDAP dialog (superadmin) */} - {tenantLdapDialogId !== null && ( - { setTenantLdapDialogId(null); loadTenants(); }} - /> - )} -
- - {/* ── Labels (Admin) ── */} - - {adminLabelsError && ( - - {adminLabelsError} - - )} - - {/* Globale Labels */} - - -

Globale Labels

-
-
- - setNewLabelName(e.target.value)} - placeholder="Label-Name" - className="h-8 w-48" - /> -
-
- -
- {["#ef4444","#f97316","#eab308","#22c55e","#06b6d4","#6366f1","#a855f7","#ec4899"].map((c) => ( -
-
- -
- - {adminLabelsLoading ? ( -
- - -
- ) : adminLabels.length === 0 ? ( -

Keine globalen Labels vorhanden.

- ) : ( - - - - Name - Farbe - Aktionen - - - - {adminLabels.map((label) => ( - - {label.name} - - - - - - - - ))} - -
- )} -
-
- - {/* Auto-Regeln */} - - -

Auto-Regeln

-
-
- - -
-
- - setNewRuleValue(e.target.value)} - placeholder="z.B. example.com" - className="h-8 w-48" - /> -
-
- - -
- -
- - {labelRulesLoading ? ( -
- - -
- ) : labelRules.length === 0 ? ( -

Keine Regeln vorhanden.

- ) : ( - - - - Bedingung - Wert - Label - Aktionen - - - - {labelRules.map((rule) => { - const condLabels: Record = { - from_domain: "Absender-Domain", - source: "Import-Quelle", - subject_contains: "Betreff enthaelt", - }; - const matchLabel = adminLabels.find((l) => l.id === rule.label_id); - return ( - - {condLabels[rule.condition_field] || rule.condition_field} - {rule.condition_value} - - {matchLabel ? ( - - - {matchLabel.name} - - ) : ( - ID {rule.label_id} - )} - - - - - - ); - })} - -
- )} -
-
-
- - {/* ── Zertifikat ── */} {isSuperAdmin && ( - - {/* Aktuelles Zertifikat */} - - -
-

Aktuelles Zertifikat

- -
- {certLoading &&
Lade...
} - {certError && {certError}} - {certSuccess && {certSuccess}} - {certInfo && !certLoading && ( - certInfo.exists ? ( -
-
- Aussteller - {certInfo.issuer} - Subject - {certInfo.subject} - Gueltig bis - - {certInfo.not_after ? new Date(certInfo.not_after).toLocaleDateString("de-DE") : "--"} - {" "}({certInfo.days_remaining} Tage) - - DNS-Namen - {certInfo.dns_names?.join(", ") || "--"} - IP-Adressen - {certInfo.ip_addresses?.join(", ") || "--"} - Typ - {certInfo.is_self_signed ? "Selbstsigniert" : "CA-signiert"} - SHA-256 - {certInfo.fingerprint_sha256} -
-
- ) : ( -
Kein Zertifikat gefunden unter /etc/ssl/archivmail/
- ) - )} -
-
+ + + + )} - {/* Zertifikat hochladen */} - - -

Zertifikat hochladen

-

Eigenes CA-signiertes oder Let's Encrypt Zertifikat hochladen.

-
-
- - setCertFile(e.target.files?.[0] ?? null)} /> -
-
- - setKeyFile(e.target.files?.[0] ?? null)} /> -
- -
-
-
+ + { + setResetPasswordUserId(userId); + setResetPasswordValue(""); + setResetPasswordError(""); + }} + onOpenDeleteDialog={(u) => { setDeleteDialogUser(u); setDeleteDialogError(""); }} + /> + - {/* Self-Signed generieren */} - - -

Self-Signed Zertifikat ausstellen

-
-
- - setSelfSignedCN(e.target.value)} placeholder="archivmail" /> -
-
- - -
-
- - setSelfSignedDNS(e.target.value)} placeholder="archivmail,mail.intern" /> -
-
- - setSelfSignedIPs(e.target.value)} placeholder="192.168.1.131" /> -
-
- -
-
+ + + - {/* ACME / Let's Encrypt */} - - -

Let's Encrypt / ACME

-

- Oeffentlich erreichbare Domain erforderlich (Port 80 muss von aussen erreichbar sein). - certbot muss auf dem Server installiert sein. -

-
-
- - setAcmeDomain(e.target.value)} placeholder="mail.example.com" /> -
-
- - setAcmeEmail(e.target.value)} placeholder="admin@example.com" type="email" /> -
-
- {acmeOutput && ( -
{acmeOutput}
- )} - -
-
+ + + + + {isSuperAdmin && ( + + + + )} + + {isSuperAdmin && ( + + + + )} + + {!isSuperAdmin && user?.role === "domain_admin" && ( + + + + )} + + {isSuperAdmin && ( + + + + )} + + {isSuperAdmin && ( + + + + )} + + {isSuperAdmin && ( + + { setLogoDialogTenant(null); setLogoPreviewUrl(null); }} + tenantLdapDialogId={tenantLdapDialogId} + setTenantLdapDialogId={setTenantLdapDialogId} + onLoadTenants={loadTenants} + onToggleTenant={handleToggleTenant} + /> )} @@ -3367,551 +1280,25 @@ export default function AdminPage() { )} - {/* Passwort-Reset Dialog */} - { if (!open) setResetPasswordUserId(null); }}> - - - Passwort zurücksetzen - - Neues Passwort für den Benutzer festlegen. - - -
-
- - setResetPasswordValue(e.target.value)} - required - minLength={8} - placeholder="Mindestens 8 Zeichen" - /> -
- {resetPasswordError && ( -

{resetPasswordError}

- )} - - - - -
-
-
+ {/* Global dialogs (not tab-specific) */} + setResetPasswordUserId(null)} + value={resetPasswordValue} + setValue={setResetPasswordValue} + error={resetPasswordError} + loading={resetPasswordLoading} + onSubmit={handleResetPassword} + /> - {/* Nutzer-Löschung Dialog */} - { if (!open) setDeleteDialogUser(null); }}> - - - Benutzer entfernen - - Was soll mit dem Konto {deleteDialogUser?.username} passieren? - - - -
- Hinweis (GoBD): E-Mails bleiben unabhängig von dieser Aktion im Archiv erhalten. Die gesetzliche Aufbewahrungspflicht besteht auch nach Ausscheiden des Mitarbeiters. -
- -
-
-

Konto deaktivieren (empfohlen)

-

- Login wird gesperrt. Konto und IMAP-Verbindungen bleiben erhalten und können reaktiviert werden. -

-
-
-

Konto endgültig löschen

-

- Account und alle IMAP-Verbindungen werden dauerhaft entfernt. Nicht rückgängig zu machen. -

-
-
- - {deleteDialogError && ( -

{deleteDialogError}

- )} - - - - - - -
-
+ setDeleteDialogUser(null)} + deleteActionLoading={deleteActionLoading} + deleteDialogError={deleteDialogError} + onDeactivate={handleDeactivateConfirmed} + onDelete={handleDeleteConfirmed} + />
); } - -// ── Module Tab ───────────────────────────────────────────────────────────── - -const statusColors: Record = { - "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 ( -
-

Modulübersicht

- - {/* Summary bar */} -
- {[ - { 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) => ( - - - {s.label} - - {s.value} - - - - ))} -
- - {/* Table */} - - - - - ID - Feature - Status - Frontend - Backend - Aktualisiert - - - - {features.map((f) => ( - - - {f.id} - - {f.name} - - - {f.status} - - - - {f.frontend - ? - : - } - - - {f.backend - ? - : - } - - - {f.lastUpdated} - - - ))} - -
-
-
- ); -} - -// ── Tenant LDAP Dialog (superadmin) ───────────────────────────────────────── - -function TenantLDAPDialog({ tenantID, onClose }: { tenantID: number; onClose: () => void }) { - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [error, setError] = useState(""); - const [testResult, setTestResult] = useState(null); - const [changePassword, setChangePassword] = useState(false); - const [form, setForm] = useState({ - 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 loadConfig = useCallback(async () => { - setLoading(true); - setError(""); - try { - const cfg = await getAdminTenantLDAPConfig(tenantID); - if (cfg) { - setConfig(cfg); - setForm({ ...cfg, bind_password: "" }); - setChangePassword(false); - } - } catch { - setError("LDAP-Konfiguration konnte nicht geladen werden."); - } finally { - setLoading(false); - } - }, [tenantID]); - - useEffect(() => { - loadConfig(); - }, [loadConfig]); - - async function handleSave(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setError(""); - try { - const payload: Partial = { ...form }; - if (!changePassword) { - delete payload.bind_password; - } - await saveAdminTenantLDAPConfig(tenantID, payload); - await loadConfig(); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen."); - } finally { - setSaving(false); - } - } - - async function handleTest() { - setTesting(true); - setError(""); - setTestResult(null); - try { - const payload = config - ? { use_saved: true } - : { use_saved: false, ...form }; - const result = await testAdminTenantLDAPConfig(tenantID, payload as Parameters[1]); - setTestResult(result); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Test fehlgeschlagen."); - } finally { - setTesting(false); - } - } - - async function handleDelete() { - setSaving(true); - setError(""); - try { - await deleteAdminTenantLDAPConfig(tenantID); - setConfig(null); - setForm({ - 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: [], - }); - setTestResult(null); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); - } finally { - setSaving(false); - } - } - - return ( - { if (!open) onClose(); }}> - - - LDAP-Konfiguration (Mandant #{tenantID}) - LDAP-Server für diesen Mandanten konfigurieren. - - - {error && ( - - {error} - - )} - - {loading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- ) : ( -
-
- LDAP aktiviert - setForm((f) => ({ ...f, enabled: e.target.checked }))} - /> -
- -
-
- - setForm((f) => ({ ...f, url: e.target.value }))} - /> -

- Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636 -

-
-
- - setForm((f) => ({ ...f, bind_dn: e.target.value }))} - /> -
-
- - {config && !changePassword ? ( -
- - -
- ) : ( - setForm((f) => ({ ...f, bind_password: e.target.value }))} - /> - )} -
-
- - setForm((f) => ({ ...f, base_dn: e.target.value }))} - /> -
-
- - setForm((f) => ({ ...f, user_filter: e.target.value }))} - /> -
-
- - -
-
- - - -
- - -
- - - - {/* Group mappings -- superadmin per tenant: bis domain_admin */} -
-
- - -
- {form.group_mappings.length === 0 ? ( -

Keine Gruppen-Zuordnungen definiert.

- ) : ( -
- {form.group_mappings.map((gm, i) => ( -
- { - const gms = [...form.group_mappings]; - gms[i] = { ...gms[i], group_dn: e.target.value }; - setForm((f) => ({ ...f, group_mappings: gms })); - }} - /> - - -
- ))} -
- )} -
- - {/* Test result */} - {testResult && ( - - -
- - {testResult.ok ? "Verbunden" : "Fehler"} - - {testResult.message} - {testResult.latency_ms > 0 && ( - {testResult.latency_ms} ms - )} -
- {testResult.server_info && ( -

{testResult.server_info}

- )} - {testResult.users_found > 0 && ( -

{testResult.users_found} Benutzer gefunden

- )} - {testResult.error_detail && ( -

{testResult.error_detail}

- )} -
-
- )} - - {/* Action bar */} -
- - - {config && ( - - )} -
- - {config && ( -

- Zuletzt geändert: {config.updated_at ? new Date(config.updated_at).toLocaleString("de-DE") : "–"} - {config.updated_by ? ` von ${config.updated_by}` : ""} -

- )} - - )} -
-
- ); -} diff --git a/src/components/admin/ModulesTab.tsx b/src/components/admin/ModulesTab.tsx new file mode 100644 index 0000000..81ef4ac --- /dev/null +++ b/src/components/admin/ModulesTab.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { features, type Feature } from "@/data/features"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const statusColors: Record = { + "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, +}); + +export function ModulesTab() { + const counts = statusCounts(features); + + return ( +
+

Modulübersicht

+ + {/* Summary bar */} +
+ {[ + { 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) => ( + + + {s.label} + + {s.value} + + + + ))} +
+ + {/* Table */} + + + + + ID + Feature + Status + Frontend + Backend + Aktualisiert + + + + {features.map((f) => ( + + + {f.id} + + {f.name} + + + {f.status} + + + + {f.frontend + ? + : + } + + + {f.backend + ? + : + } + + + {f.lastUpdated} + + + ))} + +
+
+
+ ); +} diff --git a/src/components/admin/TenantLDAPDialog.tsx b/src/components/admin/TenantLDAPDialog.tsx new file mode 100644 index 0000000..735a2ef --- /dev/null +++ b/src/components/admin/TenantLDAPDialog.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + getAdminTenantLDAPConfig, + saveAdminTenantLDAPConfig, + deleteAdminTenantLDAPConfig, + testAdminTenantLDAPConfig, + type TenantLDAPConfig, + type LDAPTestResult, +} from "@/lib/api"; +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 { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface TenantLDAPDialogProps { + tenantID: number; + onClose: () => void; +} + +export function TenantLDAPDialog({ tenantID, onClose }: TenantLDAPDialogProps) { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [error, setError] = useState(""); + const [testResult, setTestResult] = useState(null); + const [changePassword, setChangePassword] = useState(false); + const [form, setForm] = useState({ + 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 loadConfig = useCallback(async () => { + setLoading(true); + setError(""); + try { + const cfg = await getAdminTenantLDAPConfig(tenantID); + if (cfg) { + setConfig(cfg); + setForm({ ...cfg, bind_password: "" }); + setChangePassword(false); + } + } catch { + setError("LDAP-Konfiguration konnte nicht geladen werden."); + } finally { + setLoading(false); + } + }, [tenantID]); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(""); + try { + const payload: Partial = { ...form }; + if (!changePassword) { + delete payload.bind_password; + } + await saveAdminTenantLDAPConfig(tenantID, payload); + await loadConfig(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen."); + } finally { + setSaving(false); + } + } + + async function handleTest() { + setTesting(true); + setError(""); + setTestResult(null); + try { + const payload = config + ? { use_saved: true } + : { use_saved: false, ...form }; + const result = await testAdminTenantLDAPConfig(tenantID, payload as Parameters[1]); + setTestResult(result); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Test fehlgeschlagen."); + } finally { + setTesting(false); + } + } + + async function handleDelete() { + setSaving(true); + setError(""); + try { + await deleteAdminTenantLDAPConfig(tenantID); + setConfig(null); + setForm({ + 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: [], + }); + setTestResult(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); + } finally { + setSaving(false); + } + } + + return ( + { if (!open) onClose(); }}> + + + LDAP-Konfiguration (Mandant #{tenantID}) + LDAP-Server für diesen Mandanten konfigurieren. + + + {error && ( + + {error} + + )} + + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : ( +
+
+ LDAP aktiviert + setForm((f) => ({ ...f, enabled: e.target.checked }))} + /> +
+ +
+
+ + setForm((f) => ({ ...f, url: e.target.value }))} + /> +

+ Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636 +

+
+
+ + setForm((f) => ({ ...f, bind_dn: e.target.value }))} + /> +
+
+ + {config && !changePassword ? ( +
+ + +
+ ) : ( + setForm((f) => ({ ...f, bind_password: e.target.value }))} + /> + )} +
+
+ + setForm((f) => ({ ...f, base_dn: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, user_filter: e.target.value }))} + /> +
+
+ + +
+
+ + + +
+ + +
+ + + + {/* Group mappings -- superadmin per tenant: bis domain_admin */} +
+
+ + +
+ {form.group_mappings.length === 0 ? ( +

Keine Gruppen-Zuordnungen definiert.

+ ) : ( +
+ {form.group_mappings.map((gm, i) => ( +
+ { + const gms = [...form.group_mappings]; + gms[i] = { ...gms[i], group_dn: e.target.value }; + setForm((f) => ({ ...f, group_mappings: gms })); + }} + /> + + +
+ ))} +
+ )} +
+ + {/* Test result */} + {testResult && ( + + +
+ + {testResult.ok ? "Verbunden" : "Fehler"} + + {testResult.message} + {testResult.latency_ms > 0 && ( + {testResult.latency_ms} ms + )} +
+ {testResult.server_info && ( +

{testResult.server_info}

+ )} + {testResult.users_found > 0 && ( +

{testResult.users_found} Benutzer gefunden

+ )} + {testResult.error_detail && ( +

{testResult.error_detail}

+ )} +
+
+ )} + + {/* Action bar */} +
+ + + {config && ( + + )} +
+ + {config && ( +

+ Zuletzt geändert: {config.updated_at ? new Date(config.updated_at).toLocaleString("de-DE") : "–"} + {config.updated_by ? ` von ${config.updated_by}` : ""} +

+ )} + + )} +
+
+ ); +} diff --git a/src/components/admin/UserDialogs.tsx b/src/components/admin/UserDialogs.tsx new file mode 100644 index 0000000..70cb885 --- /dev/null +++ b/src/components/admin/UserDialogs.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { type User } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface ResetPasswordDialogProps { + open: boolean; + onClose: () => void; + value: string; + setValue: (v: string) => void; + error: string; + loading: boolean; + onSubmit: (e: React.FormEvent) => void; +} + +export function ResetPasswordDialog({ + open, + onClose, + value, + setValue, + error, + loading, + onSubmit, +}: ResetPasswordDialogProps) { + return ( + { if (!o) onClose(); }}> + + + Passwort zurücksetzen + + Neues Passwort für den Benutzer festlegen. + + +
+
+ + setValue(e.target.value)} + required + minLength={8} + placeholder="Mindestens 8 Zeichen" + /> +
+ {error && ( +

{error}

+ )} + + + + +
+
+
+ ); +} + +interface DeleteUserDialogProps { + user: User | null; + onClose: () => void; + deleteActionLoading: "deactivate" | "delete" | null; + deleteDialogError: string; + onDeactivate: () => void; + onDelete: () => void; +} + +export function DeleteUserDialog({ + user, + onClose, + deleteActionLoading, + deleteDialogError, + onDeactivate, + onDelete, +}: DeleteUserDialogProps) { + return ( + { if (!open) onClose(); }}> + + + Benutzer entfernen + + Was soll mit dem Konto {user?.username} passieren? + + + +
+ Hinweis (GoBD): E-Mails bleiben unabhängig von dieser Aktion im Archiv erhalten. Die gesetzliche Aufbewahrungspflicht besteht auch nach Ausscheiden des Mitarbeiters. +
+ +
+
+

Konto deaktivieren (empfohlen)

+

+ Login wird gesperrt. Konto und IMAP-Verbindungen bleiben erhalten und können reaktiviert werden. +

+
+
+

Konto endgültig löschen

+

+ Account und alle IMAP-Verbindungen werden dauerhaft entfernt. Nicht rückgängig zu machen. +

+
+
+ + {deleteDialogError && ( +

{deleteDialogError}

+ )} + + + + + + +
+
+ ); +} diff --git a/src/components/admin/tabs/AuditTab.tsx b/src/components/admin/tabs/AuditTab.tsx new file mode 100644 index 0000000..6bc8739 --- /dev/null +++ b/src/components/admin/tabs/AuditTab.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { type AuditEntry } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const AUDIT_PAGE_SIZE = 25; + +interface AuditTabProps { + auditEntries: AuditEntry[]; + auditTotal: number; + auditPage: number; + auditLoading: boolean; + onLoadAudit: (page: number) => void; +} + +export function AuditTab({ + auditEntries, + auditTotal, + auditPage, + auditLoading, + onLoadAudit, +}: AuditTabProps) { + const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE); + + return ( +
+

Audit-Log

+ + {auditLoading ? ( + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + + ) : auditEntries.length === 0 ? ( + + + Keine Audit-Eintraege vorhanden. + + + ) : ( + <> + + + + + Zeitstempel + Ereignis + Benutzer + Details + + + + {auditEntries.map((entry) => ( + + + {new Date(entry.timestamp).toLocaleString("de-DE")} + + + + {entry.event_type} + + + {entry.username} + + {entry.detail} + + + ))} + +
+
+ + {auditTotalPages > 1 && ( +
+ + + Seite {auditPage} von {auditTotalPages} + + +
+ )} + + )} +
+ ); +} diff --git a/src/components/admin/tabs/CertTab.tsx b/src/components/admin/tabs/CertTab.tsx new file mode 100644 index 0000000..58942cf --- /dev/null +++ b/src/components/admin/tabs/CertTab.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { type CertInfo, uploadCert, generateSelfSignedCert, requestACMECert } from "@/lib/api"; +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 { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface CertTabProps { + certInfo: CertInfo | null; + certLoading: boolean; + certError: string; + certSuccess: string; + certFile: File | null; + setCertFile: (f: File | null) => void; + keyFile: File | null; + setKeyFile: (f: File | null) => void; + certUploadLoading: boolean; + setCertUploadLoading: (v: boolean) => void; + selfSignedCN: string; + setSelfSignedCN: (v: string) => void; + selfSignedDNS: string; + setSelfSignedDNS: (v: string) => void; + selfSignedIPs: string; + setSelfSignedIPs: (v: string) => void; + selfSignedYears: string; + setSelfSignedYears: (v: string) => void; + selfSignedLoading: boolean; + setSelfSignedLoading: (v: boolean) => void; + acmeDomain: string; + setAcmeDomain: (v: string) => void; + acmeEmail: string; + setAcmeEmail: (v: string) => void; + acmeLoading: boolean; + setAcmeLoading: (v: boolean) => void; + acmeOutput: string; + setAcmeOutput: (v: string) => void; + setCertError: (v: string) => void; + setCertSuccess: (v: string) => void; + setCertInfo: (info: CertInfo) => void; + onLoadCert: () => void; +} + +export function CertTab({ + certInfo, + certLoading, + certError, + certSuccess, + certFile, + setCertFile, + keyFile, + setKeyFile, + certUploadLoading, + setCertUploadLoading, + selfSignedCN, + setSelfSignedCN, + selfSignedDNS, + setSelfSignedDNS, + selfSignedIPs, + setSelfSignedIPs, + selfSignedYears, + setSelfSignedYears, + selfSignedLoading, + setSelfSignedLoading, + acmeDomain, + setAcmeDomain, + acmeEmail, + setAcmeEmail, + acmeLoading, + setAcmeLoading, + acmeOutput, + setAcmeOutput, + setCertError, + setCertSuccess, + setCertInfo, + onLoadCert, +}: CertTabProps) { + return ( +
+ {/* Aktuelles Zertifikat */} + + +
+

Aktuelles Zertifikat

+ +
+ {certLoading &&
Lade...
} + {certError && {certError}} + {certSuccess && {certSuccess}} + {certInfo && !certLoading && ( + certInfo.exists ? ( +
+
+ Aussteller + {certInfo.issuer} + Subject + {certInfo.subject} + Gueltig bis + + {certInfo.not_after ? new Date(certInfo.not_after).toLocaleDateString("de-DE") : "--"} + {" "}({certInfo.days_remaining} Tage) + + DNS-Namen + {certInfo.dns_names?.join(", ") || "--"} + IP-Adressen + {certInfo.ip_addresses?.join(", ") || "--"} + Typ + {certInfo.is_self_signed ? "Selbstsigniert" : "CA-signiert"} + SHA-256 + {certInfo.fingerprint_sha256} +
+
+ ) : ( +
Kein Zertifikat gefunden unter /etc/ssl/archivmail/
+ ) + )} +
+
+ + {/* Zertifikat hochladen */} + + +

Zertifikat hochladen

+

Eigenes CA-signiertes oder Let's Encrypt Zertifikat hochladen.

+
+
+ + setCertFile(e.target.files?.[0] ?? null)} /> +
+
+ + setKeyFile(e.target.files?.[0] ?? null)} /> +
+ +
+
+
+ + {/* Self-Signed generieren */} + + +

Self-Signed Zertifikat ausstellen

+
+
+ + setSelfSignedCN(e.target.value)} placeholder="archivmail" /> +
+
+ + +
+
+ + setSelfSignedDNS(e.target.value)} placeholder="archivmail,mail.intern" /> +
+
+ + setSelfSignedIPs(e.target.value)} placeholder="192.168.1.131" /> +
+
+ +
+
+ + {/* ACME / Let's Encrypt */} + + +

Let's Encrypt / ACME

+

+ Oeffentlich erreichbare Domain erforderlich (Port 80 muss von aussen erreichbar sein). + certbot muss auf dem Server installiert sein. +

+
+
+ + setAcmeDomain(e.target.value)} placeholder="mail.example.com" /> +
+
+ + setAcmeEmail(e.target.value)} placeholder="admin@example.com" type="email" /> +
+
+ {acmeOutput && ( +
{acmeOutput}
+ )} + +
+
+
+ ); +} diff --git a/src/components/admin/tabs/DashboardTab.tsx b/src/components/admin/tabs/DashboardTab.tsx new file mode 100644 index 0000000..16058a2 --- /dev/null +++ b/src/components/admin/tabs/DashboardTab.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { + type SMTPStatus, + type StorageStats, + type SystemStats, +} from "@/lib/api"; +import { type User } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +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"; + +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`; +} + +interface DashboardTabProps { + isSuperAdmin: boolean; + smtpStatus: SMTPStatus | null; + storageStats: StorageStats | null; + systemStats: SystemStats | null; + apiOnline: boolean | null; + dashLoading: boolean; + dashRefreshed: Date | null; + countdown: number; + users: User[]; + usersLoading: boolean; + onRefresh: () => void; +} + +export function DashboardTab({ + isSuperAdmin, + smtpStatus, + storageStats, + systemStats, + apiOnline, + dashLoading, + dashRefreshed, + countdown, + users, + usersLoading, + onRefresh, +}: DashboardTabProps) { + return ( + <> +
+

Systemstatus

+
+ {dashRefreshed && ( + + {dashRefreshed.toLocaleTimeString("de-DE")} · nächste Aktualisierung in {countdown}s + + )} + +
+
+ + {dashLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : ( + <> + {/* Status-Kacheln */} +
+ + {/* API */} + + +
+ REST API + + {apiOnline ? "Online" : "Offline"} + +
+ +
+ Adresse + :8080 + Protokoll + HTTP +
+
+
+ + {/* SMTP — superadmin: globaler Daemon; domain_admin: nur eigene Domain-Statistik */} + {isSuperAdmin ? ( + <> + + +
+ SMTP-Daemon + + {smtpStatus?.running ? "Aktiv" : smtpStatus?.enabled === false ? "Deaktiviert" : "Gestoppt"} + +
+ + {smtpStatus ? ( +
+ Adresse + {smtpStatus.bind} + Domain + {smtpStatus.domain || "–"} + TLS + {smtpStatus.tls ? "Ja" : "Nein"} + Max. Größe + {(smtpStatus.max_size_mb ?? 0) > 0 ? `${smtpStatus.max_size_mb} MB` : "50 MB"} +
+ ) : ( +

Nicht erreichbar

+ )} +
+
+ + +
+ SMTP Statistik + seit letztem Start +
+ + {smtpStatus ? ( +
+ Empfangen + {smtpStatus.received ?? 0} + Abgelehnt + {smtpStatus.rejected ?? 0} + Letzte Mail + + {smtpStatus.last_mail_at + ? new Date(smtpStatus.last_mail_at).toLocaleString("de-DE") + : "–"} + +
+ ) : ( +

Keine Daten

+ )} +
+
+ + ) : ( + + +
+ SMTP – meine Domain(s) + Tenant +
+ + {smtpStatus ? ( +
+ Domain(s) + + {(smtpStatus.domains?.length ?? 0) > 0 ? smtpStatus.domains!.join(", ") : "–"} + + Archivierte Mails + {smtpStatus.total_mails?.toLocaleString("de-DE") ?? "–"} + Speicher + {smtpStatus.total_bytes ? formatBytes(smtpStatus.total_bytes) : "–"} +
+ ) : ( +

Keine Daten

+ )} +
+
+ )} + + {/* Archiv-Speicher */} + + +
+ Archiv gesamt + {storageStats && ( + + {storageStats.total_mails} Mails + + )} +
+ + {storageStats ? ( +
+ E-Mails + {storageStats.total_mails.toLocaleString("de-DE")} + Speicher + {formatBytes(storageStats.total_bytes)} +
+ ) : ( +

Keine Daten

+ )} +
+
+
+ + {/* System Stats: nur für superadmin */} + {isSuperAdmin &&
+

Systemauslastung

+ {!systemStats ? ( + + + Systemdaten konnten nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft und der Endpunkt /api/admin/system/stats erreichbar ist. + + + ) : ( + <> +
+ + {/* CPU */} + + +
+ CPU Load Average + {systemStats.cpu.num_cpu} CPU(s) +
+ +
+ 1 min + {systemStats.cpu.load1.toFixed(2)} + 5 min + {systemStats.cpu.load5.toFixed(2)} + 15 min + {systemStats.cpu.load15.toFixed(2)} +
+
+
+ + {/* RAM */} + + +
+ Arbeitsspeicher + 90 ? "destructive" : systemStats.ram.used_pct > 70 ? "secondary" : "default"}> + {systemStats.ram.used_pct.toFixed(1)}% + +
+ +
+
+
90 ? "bg-destructive" : systemStats.ram.used_pct > 70 ? "bg-yellow-500" : "bg-primary"}`} + style={{ width: `${Math.min(systemStats.ram.used_pct, 100)}%` }} + /> +
+
+ Belegt + {formatBytes(systemStats.ram.used_bytes)} + Gesamt + {formatBytes(systemStats.ram.total_bytes)} + Frei + {formatBytes(systemStats.ram.free_bytes)} +
+
+ + + + {/* Archivzeitraum */} + + +
+ Archivzeitraum +
+ + {systemStats.archive.first_mail || systemStats.archive.last_mail ? ( +
+ {systemStats.archive.first_mail && ( +
+ Älteste Mail + {new Date(systemStats.archive.first_mail.date).toLocaleDateString("de-DE")} + {systemStats.archive.first_mail.from || "–"} + {systemStats.archive.first_mail.subject || "(kein Betreff)"} +
+ )} + {systemStats.archive.last_mail && ( +
+ Neueste Mail + {new Date(systemStats.archive.last_mail.date).toLocaleDateString("de-DE")} + {systemStats.archive.last_mail.from || "–"} + {systemStats.archive.last_mail.subject || "(kein Betreff)"} +
+ )} +
+ ) : ( +

Archiv leer

+ )} +
+
+
+ + {/* Festplatten */} + {systemStats.disks.length > 0 && ( +
+

Festplatten

+
+ {systemStats.disks.map((disk) => ( + + +
+ {disk.mount} + 90 ? "destructive" : disk.used_pct > 75 ? "secondary" : "outline"}> + {disk.used_pct.toFixed(1)}% + +
+
+
90 ? "bg-destructive" : disk.used_pct > 75 ? "bg-yellow-500" : "bg-primary"}`} + style={{ width: `${Math.min(disk.used_pct, 100)}%` }} + /> +
+
+ Belegt + {formatBytes(disk.used_bytes)} + Gesamt + {formatBytes(disk.total_bytes)} + Frei + {formatBytes(disk.free_bytes)} + Dateisystem + {disk.fstype} +
+ + + ))} +
+
+ )} + + )} +
} + + {/* IP-Allowlist — nur superadmin */} + {isSuperAdmin && smtpStatus && (smtpStatus.allowed_ips?.length ?? 0) > 0 && ( + + + SMTP IP-Allowlist + +
+ {smtpStatus.allowed_ips!.map((ip) => ( + + {ip} + + ))} +
+
+
+ )} + + {/* Benutzerübersicht */} + + + Benutzer + + {usersLoading ? ( + + ) : ( +
+ + {users.filter(u => u.active).length} + aktiv + + + {users.filter(u => u.role === "admin").length} + Admin + + + {users.filter(u => u.role === "auditor").length} + Auditor + + + {users.filter(u => u.role === "user").length} + User + +
+ )} +
+
+ + {!smtpStatus && ( + + + SMTP-Status konnte nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft. + + + )} + + )} + + ); +} diff --git a/src/components/admin/tabs/ImportTab.tsx b/src/components/admin/tabs/ImportTab.tsx new file mode 100644 index 0000000..41b08ef --- /dev/null +++ b/src/components/admin/tabs/ImportTab.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { type UploadJob } from "@/lib/api"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +interface ImportTabProps { + uploadDragging: boolean; + setUploadDragging: (v: boolean) => void; + uploadJob: UploadJob | null; + uploadError: string; + uploadLoading: boolean; + onUploadFiles: (files: File[]) => void; +} + +export function ImportTab({ + uploadDragging, + setUploadDragging, + uploadJob, + uploadError, + uploadLoading, + onUploadFiles, +}: ImportTabProps) { + return ( +
+

EML / MBOX importieren

+

+ Lade .eml oder .mbox Dateien hoch um sie ins Archiv zu importieren. Duplikate werden automatisch übersprungen. +

+ + {/* Drop zone */} +
{ e.preventDefault(); setUploadDragging(true); }} + onDragLeave={() => setUploadDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setUploadDragging(false); + const files = Array.from(e.dataTransfer.files); + onUploadFiles(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()} + > + { + if (e.target.files) onUploadFiles(Array.from(e.target.files)); + e.target.value = ""; + }} + /> +

Dateien hierher ziehen oder klicken zum Auswählen

+

Akzeptiert: .eml, .mbox

+
+ + {uploadError && ( + + {uploadError} + + )} + + {/* Progress */} + {(uploadLoading || uploadJob) && uploadJob && ( + + +
+ + {uploadJob.status === "running" ? "Import läuft..." : "Import abgeschlossen"} + + + {uploadJob.status === "done" ? "Fertig" : "Läuft"} + +
+ + {/* Progress bar */} + {uploadJob.total > 0 && ( +
+
+
+
+

+ {uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total} verarbeitet +

+
+ )} + + {uploadJob.status === "done" && ( +
+
+

{uploadJob.imported}

+

Importiert

+
+
+

{uploadJob.skipped}

+

Übersprungen

+
+
+

{uploadJob.errors}

+

Fehler

+
+
+ )} + + + )} + + {uploadLoading && !uploadJob && ( +

Upload läuft...

+ )} +
+ ); +} diff --git a/src/components/admin/tabs/LDAPTab.tsx b/src/components/admin/tabs/LDAPTab.tsx new file mode 100644 index 0000000..0055305 --- /dev/null +++ b/src/components/admin/tabs/LDAPTab.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { type LDAPConfig, type LDAPTestResult } from "@/lib/api"; +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 { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Separator } from "@/components/ui/separator"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface LDAPTabProps { + ldapConfig: LDAPConfig | null; + ldapLoading: boolean; + ldapSaving: boolean; + ldapTesting: boolean; + ldapError: string; + ldapTestResult: LDAPTestResult | null; + ldapForm: LDAPConfig; + setLdapForm: React.Dispatch>; + ldapChangePassword: boolean; + setLdapChangePassword: (v: boolean) => void; + onSave: (e: React.FormEvent) => void; + onTest: () => void; + onDelete: () => void; +} + +export function LDAPTab({ + ldapConfig, + ldapLoading, + ldapSaving, + ldapTesting, + ldapError, + ldapTestResult, + ldapForm, + setLdapForm, + ldapChangePassword, + setLdapChangePassword, + onSave, + onTest, + onDelete, +}: LDAPTabProps) { + return ( +
+
+

LDAP / Active Directory

+
+ LDAP aktiviert + setLdapForm((f) => ({ ...f, enabled: e.target.checked }))} + /> +
+
+ + {ldapError && ( + + {ldapError} + + )} + + {ldapLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ + +
+
+ + setLdapForm((f) => ({ ...f, url: e.target.value }))} + /> +

+ Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636 +

+
+
+ + setLdapForm((f) => ({ ...f, bind_dn: e.target.value }))} + /> +
+
+ + {ldapConfig && !ldapChangePassword ? ( +
+ + +
+ ) : ( + setLdapForm((f) => ({ ...f, bind_password: e.target.value }))} + /> + )} +
+
+ + setLdapForm((f) => ({ ...f, base_dn: e.target.value }))} + /> +
+
+ + setLdapForm((f) => ({ ...f, user_filter: e.target.value }))} + /> +
+
+ + +
+
+ + + +
+ + +
+ + + + {/* Group mappings */} +
+
+ + +
+ {ldapForm.group_mappings.length === 0 ? ( +

Keine Gruppen-Zuordnungen definiert.

+ ) : ( +
+ {ldapForm.group_mappings.map((gm, i) => ( +
+ { + const gms = [...ldapForm.group_mappings]; + gms[i] = { ...gms[i], group_dn: e.target.value }; + setLdapForm((f) => ({ ...f, group_mappings: gms })); + }} + /> + + +
+ ))} +
+ )} +
+
+
+ + {/* Test result */} + {ldapTestResult && ( + + +
+ + {ldapTestResult.ok ? "Verbunden" : "Fehler"} + + {ldapTestResult.message} + {ldapTestResult.latency_ms > 0 && ( + {ldapTestResult.latency_ms} ms + )} +
+ {ldapTestResult.server_info && ( +

{ldapTestResult.server_info}

+ )} + {ldapTestResult.error_detail && ( +

{ldapTestResult.error_detail}

+ )} + {ldapTestResult.ok && ldapTestResult.users_found > 0 && ( +
+

+ {ldapTestResult.users_found} Benutzer gefunden + {ldapTestResult.users?.length < ldapTestResult.users_found && ( + (Vorschau: {ldapTestResult.users?.length}) + )} +

+
+ + + + + + + + + + {ldapTestResult.users?.map((u, i) => ( + + + + + + ))} + +
UIDNameE-Mail
{u.uid || "–"}{u.display_name || "–"}{u.mail || "–"}
+
+
+ )} +
+
+ )} + + {/* Action bar */} +
+ + + {ldapConfig && ( + + )} +
+
+ )} + + {ldapConfig && ( +

+ Zuletzt geändert: {ldapConfig.updated_at ? new Date(ldapConfig.updated_at).toLocaleString("de-DE") : "–"} + {ldapConfig.updated_by ? ` von ${ldapConfig.updated_by}` : ""} +

+ )} +
+ ); +} diff --git a/src/components/admin/tabs/LabelsTab.tsx b/src/components/admin/tabs/LabelsTab.tsx new file mode 100644 index 0000000..6382513 --- /dev/null +++ b/src/components/admin/tabs/LabelsTab.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { type MailLabel, type LabelRule } from "@/lib/api"; +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 { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface LabelsTabProps { + adminLabels: MailLabel[]; + adminLabelsLoading: boolean; + adminLabelsError: string; + newLabelName: string; + setNewLabelName: (v: string) => void; + newLabelColor: string; + setNewLabelColor: (v: string) => void; + labelCreating: boolean; + labelRules: LabelRule[]; + labelRulesLoading: boolean; + newRuleField: string; + setNewRuleField: (v: string) => void; + newRuleValue: string; + setNewRuleValue: (v: string) => void; + newRuleLabelId: number | null; + setNewRuleLabelId: (v: number | null) => void; + ruleCreating: boolean; + onCreateLabel: (e: React.FormEvent) => void; + onDeleteLabel: (id: number, name: string) => void; + onCreateRule: (e: React.FormEvent) => void; + onDeleteRule: (id: number) => void; +} + +export function LabelsTab({ + adminLabels, + adminLabelsLoading, + adminLabelsError, + newLabelName, + setNewLabelName, + newLabelColor, + setNewLabelColor, + labelCreating, + labelRules, + labelRulesLoading, + newRuleField, + setNewRuleField, + newRuleValue, + setNewRuleValue, + newRuleLabelId, + setNewRuleLabelId, + ruleCreating, + onCreateLabel, + onDeleteLabel, + onCreateRule, + onDeleteRule, +}: LabelsTabProps) { + return ( +
+ {adminLabelsError && ( + + {adminLabelsError} + + )} + + {/* Globale Labels */} + + +

Globale Labels

+
+
+ + setNewLabelName(e.target.value)} + placeholder="Label-Name" + className="h-8 w-48" + /> +
+
+ +
+ {["#ef4444","#f97316","#eab308","#22c55e","#06b6d4","#6366f1","#a855f7","#ec4899"].map((c) => ( +
+
+ +
+ + {adminLabelsLoading ? ( +
+ + +
+ ) : adminLabels.length === 0 ? ( +

Keine globalen Labels vorhanden.

+ ) : ( + + + + Name + Farbe + Aktionen + + + + {adminLabels.map((label) => ( + + {label.name} + + + + + + + + ))} + +
+ )} +
+
+ + {/* Auto-Regeln */} + + +

Auto-Regeln

+
+
+ + +
+
+ + setNewRuleValue(e.target.value)} + placeholder="z.B. example.com" + className="h-8 w-48" + /> +
+
+ + +
+ +
+ + {labelRulesLoading ? ( +
+ + +
+ ) : labelRules.length === 0 ? ( +

Keine Regeln vorhanden.

+ ) : ( + + + + Bedingung + Wert + Label + Aktionen + + + + {labelRules.map((rule) => { + const condLabels: Record = { + from_domain: "Absender-Domain", + source: "Import-Quelle", + subject_contains: "Betreff enthaelt", + }; + const matchLabel = adminLabels.find((l) => l.id === rule.label_id); + return ( + + {condLabels[rule.condition_field] || rule.condition_field} + {rule.condition_value} + + {matchLabel ? ( + + + {matchLabel.name} + + ) : ( + ID {rule.label_id} + )} + + + + + + ); + })} + +
+ )} +
+
+
+ ); +} diff --git a/src/components/admin/tabs/SecurityTab.tsx b/src/components/admin/tabs/SecurityTab.tsx new file mode 100644 index 0000000..49e3039 --- /dev/null +++ b/src/components/admin/tabs/SecurityTab.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { type SecurityCheck, type SecurityAuditResult } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +interface SecurityTabProps { + securityAudit: SecurityAuditResult | null; + securityLoading: boolean; + securityError: string; + fixLoading: string | null; + fixMessage: string; + onRunAudit: () => void; + onRunFix: (action: string) => void; +} + +export function SecurityTab({ + securityAudit, + securityLoading, + securityError, + fixLoading, + fixMessage, + onRunAudit, + onRunFix, +}: SecurityTabProps) { + return ( +
+
+
+

Security Audit

+ {securityAudit && ( +

+ Zuletzt geprüft: {new Date(securityAudit.run_at).toLocaleString("de-DE")} +

+ )} +
+ +
+ + {securityError && ( + + {securityError} + + )} + + {fixMessage && ( + + {fixMessage} + + )} + + {!securityAudit && !securityLoading && !securityError && ( +
+ Klicke auf “Jetzt prüfen” um den Security-Audit zu starten. +
+ )} + + {securityLoading && ( +
+ {Array.from({ length: 7 }).map((_, i) => ( + + ))} +
+ )} + + {securityAudit && !securityLoading && (() => { + // Map check names to fix actions + const fixActions: Record = { + "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 ( +
+ {securityAudit.checks.map((check: SecurityCheck, i: number) => { + const fix = check.status !== "ok" ? fixActions[check.name] : undefined; + return ( + + + +
+
+ {check.name} + + {check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"} + +
+

{check.message}

+
+ {fix && ( + + )} + {check.name === "HTTPS (TLS)" && check.status !== "ok" && ( + + + + )} +
+
+ ); + })} + + {/* Summary */} +
+ {[ + { 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) => ( +
+

{s.count}

+

{s.label}

+
+ ))} +
+
+ ); + })()} +
+ ); +} diff --git a/src/components/admin/tabs/ServicesTab.tsx b/src/components/admin/tabs/ServicesTab.tsx new file mode 100644 index 0000000..8dec97c --- /dev/null +++ b/src/components/admin/tabs/ServicesTab.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { type ServiceStatus } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface ServicesTabProps { + isSuperAdmin: boolean; + services: ServiceStatus[]; + servicesLoading: boolean; + serviceActionLoading: string | null; + serviceError: string; + onLoadServices: () => void; + onServiceAction: (name: string, action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external") => void; +} + +export function ServicesTab({ + isSuperAdmin, + services, + servicesLoading, + serviceActionLoading, + serviceError, + onLoadServices, + onServiceAction, +}: ServicesTabProps) { + return ( +
+
+

Systemdienste

+ +
+ + {serviceError && ( + + {serviceError} + + )} + + {servicesLoading && services.length === 0 ? ( + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + + ) : ( + + + + + Dienst + Status + Autostart + Externer Zugriff + Beschreibung + {isSuperAdmin && Aktionen} + + + + {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 ( + + + {svc.name} + + + + {svc.active === "active" + ? `Aktiv (${svc.sub})` + : svc.active === "failed" + ? "Fehler" + : svc.active === "inactive" + ? "Gestoppt" + : svc.active} + + + + + {svc.enabled === "enabled" + ? "Aktiviert" + : svc.enabled === "disabled" + ? "Deaktiviert" + : svc.enabled === "static" + ? "Statisch" + : svc.enabled} + + + + {svc.external_blocked !== undefined ? ( + + {svc.external_blocked ? "Gesperrt" : "Offen"} + + ) : ( + + )} + + + {svc.description || "–"} + + {isSuperAdmin && ( + +
+ {isActive ? ( + <> + + + + ) : ( + + )} + {svc.enabled === "enabled" ? ( + + ) : svc.enabled === "disabled" ? ( + + ) : null} + {svc.external_blocked !== undefined && ( + svc.external_blocked ? ( + + ) : ( + + ) + )} +
+
+ )} +
+ ); + })} +
+
+
+ )} +
+ ); +} diff --git a/src/components/admin/tabs/TenantLDAPTab.tsx b/src/components/admin/tabs/TenantLDAPTab.tsx new file mode 100644 index 0000000..414d04b --- /dev/null +++ b/src/components/admin/tabs/TenantLDAPTab.tsx @@ -0,0 +1,393 @@ +"use client"; + +import { type TenantLDAPConfig, type LDAPTestResult } from "@/lib/api"; +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 { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Separator } from "@/components/ui/separator"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface TenantLDAPTabProps { + tenantLdapConfig: TenantLDAPConfig | null; + tenantLdapLoading: boolean; + tenantLdapSaving: boolean; + tenantLdapTesting: boolean; + tenantLdapError: string; + tenantLdapTestResult: LDAPTestResult | null; + tenantLdapForm: TenantLDAPConfig; + setTenantLdapForm: React.Dispatch>; + tenantLdapChangePassword: boolean; + setTenantLdapChangePassword: (v: boolean) => void; + ownLogoPreviewUrl: string | null; + ownLogoUploading: boolean; + ownLogoError: string; + onSave: (e: React.FormEvent) => void; + onTest: () => void; + onDelete: () => void; + onOwnLogoUpload: (file: File) => void; + onOwnLogoDelete: () => void; +} + +export function TenantLDAPTab({ + tenantLdapConfig, + tenantLdapLoading, + tenantLdapSaving, + tenantLdapTesting, + tenantLdapError, + tenantLdapTestResult, + tenantLdapForm, + setTenantLdapForm, + tenantLdapChangePassword, + setTenantLdapChangePassword, + ownLogoPreviewUrl, + ownLogoUploading, + ownLogoError, + onSave, + onTest, + onDelete, + onOwnLogoUpload, + onOwnLogoDelete, +}: TenantLDAPTabProps) { + return ( +
+
+
+

LDAP / Active Directory — Mandantenkonfiguration

+

Konfiguriere den LDAP-Server für deinen Mandanten.

+
+
+ LDAP aktiviert + setTenantLdapForm((f) => ({ ...f, enabled: e.target.checked }))} + /> +
+
+ + {tenantLdapError && ( + + {tenantLdapError} + + )} + + {tenantLdapLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ + +
+
+ + setTenantLdapForm((f) => ({ ...f, url: e.target.value }))} + /> +

+ Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636 +

+
+
+ + setTenantLdapForm((f) => ({ ...f, bind_dn: e.target.value }))} + /> +
+
+ + {tenantLdapConfig && !tenantLdapChangePassword ? ( +
+ + +
+ ) : ( + setTenantLdapForm((f) => ({ ...f, bind_password: e.target.value }))} + /> + )} +
+
+ + setTenantLdapForm((f) => ({ ...f, base_dn: e.target.value }))} + /> +
+
+ + setTenantLdapForm((f) => ({ ...f, user_filter: e.target.value }))} + /> +
+
+ + +
+
+ + + +
+ + +
+ + + + {/* Group mappings -- domain_admin: nur user + auditor */} +
+
+ + +
+ {tenantLdapForm.group_mappings.length === 0 ? ( +

Keine Gruppen-Zuordnungen definiert.

+ ) : ( +
+ {tenantLdapForm.group_mappings.map((gm, i) => ( +
+ { + const gms = [...tenantLdapForm.group_mappings]; + gms[i] = { ...gms[i], group_dn: e.target.value }; + setTenantLdapForm((f) => ({ ...f, group_mappings: gms })); + }} + /> + + +
+ ))} +
+ )} +
+
+
+ + {/* Test result */} + {tenantLdapTestResult && ( + + +
+ + {tenantLdapTestResult.ok ? "Verbunden" : "Fehler"} + + {tenantLdapTestResult.message} + {tenantLdapTestResult.latency_ms > 0 && ( + {tenantLdapTestResult.latency_ms} ms + )} +
+ {tenantLdapTestResult.server_info && ( +

{tenantLdapTestResult.server_info}

+ )} + {tenantLdapTestResult.error_detail && ( +

{tenantLdapTestResult.error_detail}

+ )} + {tenantLdapTestResult.ok && tenantLdapTestResult.users_found > 0 && ( +
+

+ {tenantLdapTestResult.users_found} Benutzer gefunden + {tenantLdapTestResult.users?.length < tenantLdapTestResult.users_found && ( + (Vorschau: {tenantLdapTestResult.users?.length}) + )} +

+
+ + + + + + + + + + {tenantLdapTestResult.users?.map((u, i) => ( + + + + + + ))} + +
UIDNameE-Mail
{u.uid || "–"}{u.display_name || "–"}{u.mail || "–"}
+
+
+ )} +
+
+ )} + + {/* Action bar */} +
+ + + {tenantLdapConfig && ( + + )} +
+
+ )} + + {tenantLdapConfig && ( +

+ Zuletzt geändert: {tenantLdapConfig.updated_at ? new Date(tenantLdapConfig.updated_at).toLocaleString("de-DE") : "–"} + {tenantLdapConfig.updated_by ? ` von ${tenantLdapConfig.updated_by}` : ""} +

+ )} + + {/* Logo section for domain_admin */} +
+
+

Mandanten-Logo

+

Logo deines Mandanten hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB).

+
+ {ownLogoError &&

{ownLogoError}

} + {ownLogoPreviewUrl ? ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Logo +
+
+ { const f = e.target.files?.[0]; if (f) onOwnLogoUpload(f); }} + className="w-auto" + /> + +
+
+ ) : ( +
+
+ Kein Logo +
+ { const f = e.target.files?.[0]; if (f) onOwnLogoUpload(f); }} + className="w-auto" + /> +
+ )} +
+
+ ); +} diff --git a/src/components/admin/tabs/TenantsTab.tsx b/src/components/admin/tabs/TenantsTab.tsx new file mode 100644 index 0000000..6b74c32 --- /dev/null +++ b/src/components/admin/tabs/TenantsTab.tsx @@ -0,0 +1,507 @@ +"use client"; + +import { + type Tenant, + type TenantDomain, + type TenantDefaultUser, + type User, + type LDAPSyncResult, +} from "@/lib/api"; +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 { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { TenantLDAPDialog } from "@/components/admin/TenantLDAPDialog"; + +interface TenantsTabProps { + tenants: Tenant[]; + tenantsLoading: boolean; + tenantsError: string; + // Create dialog + tenantDialogOpen: boolean; + setTenantDialogOpen: (open: boolean) => void; + newTenantName: string; + setNewTenantName: (v: string) => void; + newTenantSlug: string; + setNewTenantSlug: (v: string) => void; + tenantCreateLoading: boolean; + tenantCreateError: string; + onCreateTenant: (e: React.FormEvent) => void; + // Credentials dialog + tenantCredDialogOpen: boolean; + setTenantCredDialogOpen: (open: boolean) => void; + tenantCreatedName: string; + tenantCreatedUsers: TenantDefaultUser[]; + // Delete dialog + tenantDeleteId: number | null; + setTenantDeleteId: (id: number | null) => void; + tenantDeleteLoading: boolean; + onDeleteTenant: () => void; + // Domain dialog + domainDialogTenant: Tenant | null; + setDomainDialogTenant: (t: Tenant | null) => void; + tenantDomains: TenantDomain[]; + domainsLoading: boolean; + newDomain: string; + setNewDomain: (v: string) => void; + addDomainLoading: boolean; + domainError: string; + onOpenDomainDialog: (t: Tenant) => void; + onAddDomain: () => void; + onRemoveDomain: (domainId: number) => void; + // Users dialog + tenantUsersDialogId: number | null; + setTenantUsersDialogId: (id: number | null) => void; + tenantUsersDialogName: string; + tenantUsersDialogLdap: boolean; + tenantUsers: User[]; + tenantUsersLoading: boolean; + tenantUsersError: string; + tenantUsersSyncing: boolean; + tenantUsersSyncResult: LDAPSyncResult | null; + onOpenUsersDialog: (t: Tenant) => void; + onSyncLDAPUsers: () => void; + // Logo dialog + logoDialogTenant: Tenant | null; + logoPreviewUrl: string | null; + logoUploading: boolean; + logoError: string; + onOpenLogoDialog: (t: Tenant) => void; + onLogoUpload: (file: File) => void; + onLogoDelete: () => void; + onLogoDialogClose: () => void; + // Tenant LDAP dialog + tenantLdapDialogId: number | null; + setTenantLdapDialogId: (id: number | null) => void; + onLoadTenants: () => void; + // Toggle / actions + onToggleTenant: (t: Tenant) => void; +} + +export function TenantsTab({ + tenants, + tenantsLoading, + tenantsError, + tenantDialogOpen, + setTenantDialogOpen, + newTenantName, + setNewTenantName, + newTenantSlug, + setNewTenantSlug, + tenantCreateLoading, + tenantCreateError, + onCreateTenant, + tenantCredDialogOpen, + setTenantCredDialogOpen, + tenantCreatedName, + tenantCreatedUsers, + tenantDeleteId, + setTenantDeleteId, + tenantDeleteLoading, + onDeleteTenant, + domainDialogTenant, + setDomainDialogTenant, + tenantDomains, + domainsLoading, + newDomain, + setNewDomain, + addDomainLoading, + domainError, + onOpenDomainDialog, + onAddDomain, + onRemoveDomain, + tenantUsersDialogId, + setTenantUsersDialogId, + tenantUsersDialogName, + tenantUsersDialogLdap, + tenantUsers, + tenantUsersLoading, + tenantUsersError, + tenantUsersSyncing, + tenantUsersSyncResult, + onOpenUsersDialog, + onSyncLDAPUsers, + logoDialogTenant, + logoPreviewUrl, + logoUploading, + logoError, + onOpenLogoDialog, + onLogoUpload, + onLogoDelete, + onLogoDialogClose, + tenantLdapDialogId, + setTenantLdapDialogId, + onLoadTenants, + onToggleTenant, +}: TenantsTabProps) { + return ( +
+
+

Mandantenverwaltung

+ + + + + + + Neuen Mandanten anlegen + Name und URL-Slug für den neuen Mandanten eingeben. + +
+
+ + { + setNewTenantName(e.target.value); + setNewTenantSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")); + }} + /> +
+
+ + setNewTenantSlug(e.target.value)} + /> +
+ {tenantCreateError && ( +

{tenantCreateError}

+ )} + + + +
+
+
+
+ + {tenantsError && ( + + {tenantsError} + + )} + + {tenantsLoading ? ( + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + ) : tenants.length === 0 ? ( + + + Keine Mandanten vorhanden. Klicke auf “+ Mandant anlegen” um den ersten Mandanten zu erstellen. + + + ) : ( + + + + + Name + Slug + Domains + Nutzer + LDAP + Status + Aktionen + + + + {tenants.map((t) => ( + + {t.name} + {t.slug} + {t.domain_count ?? 0} + {t.user_count ?? 0} + + {t.ldap_enabled === true ? ( + Aktiv + ) : t.ldap_url ? ( + Deaktiviert + ) : ( + + )} + + + + {t.active ? "Aktiv" : "Inaktiv"} + + + +
+ + + + + + +
+
+
+ ))} +
+
+
+ )} + + {/* Tenant delete confirmation */} + { if (!open) setTenantDeleteId(null); }}> + + + Mandant löschen + + Alle Daten dieses Mandanten (Domains, LDAP-Konfiguration) werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. + + + + + + + + + + {/* Logo upload dialog */} + { if (!open) onLogoDialogClose(); }}> + + + Logo — {logoDialogTenant?.name} + Logo hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB). + +
+ {logoPreviewUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Logo +
+ )} + {!logoPreviewUrl && ( +
+ Kein Logo gesetzt +
+ )} + {logoError &&

{logoError}

} +
+ { const f = e.target.files?.[0]; if (f) onLogoUpload(f); }} + /> + {logoPreviewUrl && ( + + )} +
+
+ + + +
+
+ + {/* Default credentials dialog after tenant creation */} + + + + Mandant „{tenantCreatedName}“ erstellt + + Folgende Standard-Benutzer wurden angelegt. Passwörter bitte sofort notieren — sie werden nur einmalig angezeigt. + + +
+ {tenantCreatedUsers.map((u) => ( +
+
Benutzer: {u.username}
+
Passwort: {u.password}
+
Rolle: {u.role}
+
+ ))} +
+ + + +
+
+ + {/* Domain management dialog */} + { if (!open) setDomainDialogTenant(null); }}> + + + Domains: {domainDialogTenant?.name} + E-Mail-Domains diesem Mandanten zuweisen. + + + {domainError && ( + + {domainError} + + )} + + {domainsLoading ? ( + + ) : ( +
+ {tenantDomains.length === 0 ? ( +

Keine Domains zugewiesen.

+ ) : ( + tenantDomains.map((d) => ( +
+ {d.domain} + +
+ )) + )} +
+ )} + +
+ setNewDomain(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); onAddDomain(); } }} + /> + +
+
+
+ + {/* Tenant users dialog */} + { if (!open) setTenantUsersDialogId(null); }}> + + + Nutzer: {tenantUsersDialogName} + Dem Mandanten zugewiesene Benutzerkonten. + + {tenantUsersLoading ? ( + + ) : tenantUsersError ? ( + + {tenantUsersError} + + ) : tenantUsers.length === 0 ? ( +
+

Keine lokalen Benutzer diesem Mandanten zugewiesen.

+ {tenantUsersDialogLdap && ( +

LDAP ist aktiv — Benutzer erscheinen hier nach ihrem ersten Login oder nach der Synchronisation.

+ )} +
+ ) : ( + + + + Benutzername + E-Mail + Rolle + Quelle + Status + + + + {tenantUsers.map((u) => ( + + {u.username} + {u.email} + {u.role} + {u.source || "local"} + + + {u.active ? "Aktiv" : "Inaktiv"} + + + + ))} + +
+ )} + {tenantUsersDialogLdap && ( +
+
+ + {tenantUsersSyncResult && ( + + {tenantUsersSyncResult.synced} Benutzer synchronisiert + {tenantUsersSyncResult.errors.length > 0 && ( + ({tenantUsersSyncResult.errors.length} Fehler) + )} + + )} +
+ {tenantUsersSyncResult && (tenantUsersSyncResult.errors?.length ?? 0) > 0 && ( +

{tenantUsersSyncResult.errors.join(", ")}

+ )} +
+ )} +
+
+ + {/* Tenant LDAP dialog (superadmin) */} + {tenantLdapDialogId !== null && ( + { setTenantLdapDialogId(null); onLoadTenants(); }} + /> + )} +
+ ); +} diff --git a/src/components/admin/tabs/UsersTab.tsx b/src/components/admin/tabs/UsersTab.tsx new file mode 100644 index 0000000..3a87dc4 --- /dev/null +++ b/src/components/admin/tabs/UsersTab.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { type User } from "@/lib/api"; +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 { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +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"; + +interface UsersTabProps { + isSuperAdmin: boolean; + users: User[]; + usersLoading: boolean; + usersError: string; + // Create dialog + dialogOpen: boolean; + setDialogOpen: (open: boolean) => void; + newUsername: string; + setNewUsername: (v: string) => void; + newEmail: string; + setNewEmail: (v: string) => void; + newPassword: string; + setNewPassword: (v: string) => void; + newRole: string; + setNewRole: (v: string) => void; + createLoading: boolean; + createError: string; + onCreateUser: (e: React.FormEvent) => void; + // User actions + userActionLoading: number | null; + onToggleActive: (u: User) => void; + onOpenResetPassword: (userId: number) => void; + onOpenDeleteDialog: (u: User) => void; +} + +export function UsersTab({ + isSuperAdmin, + users, + usersLoading, + usersError, + dialogOpen, + setDialogOpen, + newUsername, + setNewUsername, + newEmail, + setNewEmail, + newPassword, + setNewPassword, + newRole, + setNewRole, + createLoading, + createError, + onCreateUser, + userActionLoading, + onToggleActive, + onOpenResetPassword, + onOpenDeleteDialog, +}: UsersTabProps) { + return ( +
+
+

Benutzerverwaltung

+ + + + + + + Neuen Benutzer anlegen + + Erstellen Sie einen neuen Benutzer-Account. + + +
+
+ + setNewUsername(e.target.value)} + required + aria-label="Neuer Benutzername" + /> +
+
+ + setNewEmail(e.target.value)} + required + aria-label="Neue E-Mail-Adresse" + /> +
+
+ + setNewPassword(e.target.value)} + required + aria-label="Neues Passwort" + /> +
+
+ + +
+ {createError && ( +

+ {createError} +

+ )} + + + +
+
+
+
+ + {usersLoading ? ( + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + ) : usersError ? ( + + + {usersError} + + + ) : users.length === 0 ? ( + + + Keine Benutzer vorhanden. + + + ) : ( + + + + + Benutzername + E-Mail + Rolle + Status + Aktionen + + + + {users.map((u) => ( + + {u.username} + {u.email} + + {u.role} + + + + {u.active ? "Aktiv" : "Inaktiv"} + + + +
+ + + +
+
+
+ ))} +
+
+
+ )} +
+ ); +}