Files
archivmail/src/app/admin/page.tsx
T
sysops 9db433c4c1 fix: Mandantenverwaltung LDAP-Status und Nutzer-Listing
- tenantstore.List(): LEFT JOIN tenant_ldap hinzugefügt — ldap_enabled + ldap_url
  werden jetzt im GET /api/tenants Response mitgeliefert
- Tenant-Struct: Felder LDAPEnabled *bool + LDAPURL string ergänzt
- Neuer Endpunkt GET /api/tenants/{id}/users → listet Nutzer eines Mandanten
- api.ts: getTenantUsers() Funktion + tenant_id Feld im User Interface
- Admin-Panel: "Nutzer"-Button im Mandanten-Tab öffnet Dialog mit Nutzerliste

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 02:01:12 +01:00

3574 lines
156 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/hooks/useAuth";
import { features, type Feature } from "@/data/features";
import {
getUsers,
createUser,
updateUser,
deleteUser,
getAuditLog,
getSMTPStatus,
getHealth,
getStorageStats,
getServices,
serviceAction,
getSystemStats,
uploadMailFiles,
getUploadProgress,
getSecurityAudit,
fixSecurityIssue,
getLDAPConfig,
saveLDAPConfig,
deleteLDAPConfig,
testLDAPConfig,
getTenants,
getTenantUsers,
createTenant,
updateTenant,
deleteTenant,
getTenantDomains,
addTenantDomain,
removeTenantDomain,
getTenantLDAPConfig,
saveTenantLDAPConfig,
deleteTenantLDAPConfig,
testTenantLDAPConfig,
getAdminTenantLDAPConfig,
saveAdminTenantLDAPConfig,
deleteAdminTenantLDAPConfig,
testAdminTenantLDAPConfig,
getAdminLabels,
createAdminLabel,
deleteAdminLabel,
getLabelRules,
createLabelRule,
deleteLabelRule,
type User,
type AuditEntry,
type SMTPStatus,
type StorageStats,
type ServiceStatus,
type SystemStats,
type UploadJob,
type SecurityCheck,
type SecurityAuditResult,
type LDAPConfig,
type LDAPTestResult,
type TenantLDAPConfig,
type Tenant,
type TenantDomain,
type MailLabel,
type LabelRule,
getCertInfo,
uploadCert,
generateSelfSignedCert,
requestACMECert,
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";
const AUDIT_PAGE_SIZE = 25;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export default function AdminPage() {
const { user, loading: authLoading } = useAuth("domain_admin");
const isSuperAdmin = user?.role === "superadmin";
// Dashboard state
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [dashRefreshed, setDashRefreshed] = useState<Date | null>(null);
const [countdown, setCountdown] = useState(30);
// Services state
const [services, setServices] = useState<ServiceStatus[]>([]);
const [servicesLoading, setServicesLoading] = useState(false);
const [serviceActionLoading, setServiceActionLoading] = useState<string | null>(null);
const [serviceError, setServiceError] = useState("");
// Users state
const [users, setUsers] = useState<User[]>([]);
const [usersLoading, setUsersLoading] = useState(true);
const [usersError, setUsersError] = useState("");
// Create user dialog
const [dialogOpen, setDialogOpen] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newRole, setNewRole] = useState("user");
const [createLoading, setCreateLoading] = useState(false);
const [createError, setCreateError] = useState("");
// User action state
const [userActionLoading, setUserActionLoading] = useState<number | null>(null);
const [resetPasswordUserId, setResetPasswordUserId] = useState<number | null>(null);
const [resetPasswordValue, setResetPasswordValue] = useState("");
const [resetPasswordError, setResetPasswordError] = useState("");
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
// Delete confirmation dialog
const [deleteDialogUser, setDeleteDialogUser] = useState<User | null>(null);
const [deleteActionLoading, setDeleteActionLoading] = useState<"deactivate" | "delete" | null>(null);
const [deleteDialogError, setDeleteDialogError] = useState("");
// Audit state
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const [auditTotal, setAuditTotal] = useState(0);
const [auditPage, setAuditPage] = useState(1);
const [auditLoading, setAuditLoading] = useState(false);
// Security audit state
const [securityAudit, setSecurityAudit] = useState<SecurityAuditResult | null>(null);
const [securityLoading, setSecurityLoading] = useState(false);
const [securityError, setSecurityError] = useState("");
const [fixLoading, setFixLoading] = useState<string | null>(null);
const [fixMessage, setFixMessage] = useState("");
// Upload state
const [uploadDragging, setUploadDragging] = useState(false);
const [uploadJob, setUploadJob] = useState<UploadJob | null>(null);
const [uploadError, setUploadError] = useState("");
const [uploadLoading, setUploadLoading] = useState(false);
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// LDAP state
const [ldapConfig, setLdapConfig] = useState<LDAPConfig | null>(null);
const [ldapLoading, setLdapLoading] = useState(false);
const [ldapSaving, setLdapSaving] = useState(false);
const [ldapTesting, setLdapTesting] = useState(false);
const [ldapError, setLdapError] = useState("");
const [ldapTestResult, setLdapTestResult] = useState<LDAPTestResult | null>(null);
const [ldapForm, setLdapForm] = useState<LDAPConfig>({
enabled: false,
url: "ldap://",
bind_dn: "",
bind_password: "",
base_dn: "",
user_filter: "(sAMAccountName=%s)",
tls: false,
tls_skip_verify: false,
default_role: "user",
group_mappings: [],
});
const [ldapChangePassword, setLdapChangePassword] = useState(false);
// Tenants state
const [tenants, setTenants] = useState<Tenant[]>([]);
const [tenantsLoading, setTenantsLoading] = useState(false);
const [tenantsError, setTenantsError] = useState("");
const [tenantDialogOpen, setTenantDialogOpen] = useState(false);
const [newTenantName, setNewTenantName] = useState("");
const [newTenantSlug, setNewTenantSlug] = useState("");
const [tenantCreateLoading, setTenantCreateLoading] = useState(false);
const [tenantCreateError, setTenantCreateError] = useState("");
const [tenantDeleteId, setTenantDeleteId] = useState<number | null>(null);
const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false);
const [domainDialogTenant, setDomainDialogTenant] = useState<Tenant | null>(null);
const [tenantDomains, setTenantDomains] = useState<TenantDomain[]>([]);
const [domainsLoading, setDomainsLoading] = useState(false);
const [newDomain, setNewDomain] = useState("");
const [addDomainLoading, setAddDomainLoading] = useState(false);
const [domainError, setDomainError] = useState("");
// Tenant LDAP state (domain_admin own tenant)
const [tenantLdapConfig, setTenantLdapConfig] = useState<TenantLDAPConfig | null>(null);
const [tenantLdapLoading, setTenantLdapLoading] = useState(false);
const [tenantLdapSaving, setTenantLdapSaving] = useState(false);
const [tenantLdapTesting, setTenantLdapTesting] = useState(false);
const [tenantLdapError, setTenantLdapError] = useState("");
const [tenantLdapTestResult, setTenantLdapTestResult] = useState<LDAPTestResult | null>(null);
const [tenantLdapForm, setTenantLdapForm] = useState<TenantLDAPConfig>({
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 [tenantLdapChangePassword, setTenantLdapChangePassword] = useState(false);
// Superadmin: tenant LDAP dialog
const [tenantLdapDialogId, setTenantLdapDialogId] = useState<number | null>(null);
// Tenant users dialog
const [tenantUsersDialogId, setTenantUsersDialogId] = useState<number | null>(null);
const [tenantUsersDialogName, setTenantUsersDialogName] = useState("");
const [tenantUsers, setTenantUsers] = useState<User[]>([]);
const [tenantUsersLoading, setTenantUsersLoading] = useState(false);
// Labels state
const [adminLabels, setAdminLabels] = useState<MailLabel[]>([]);
const [adminLabelsLoading, setAdminLabelsLoading] = useState(false);
const [adminLabelsError, setAdminLabelsError] = useState("");
const [newLabelName, setNewLabelName] = useState("");
const [newLabelColor, setNewLabelColor] = useState("#ef4444");
const [labelCreating, setLabelCreating] = useState(false);
const [labelRules, setLabelRules] = useState<LabelRule[]>([]);
const [labelRulesLoading, setLabelRulesLoading] = useState(false);
const [newRuleField, setNewRuleField] = useState("from_domain");
const [newRuleValue, setNewRuleValue] = useState("");
const [newRuleLabelId, setNewRuleLabelId] = useState<number | null>(null);
const [ruleCreating, setRuleCreating] = useState(false);
// Certificate state
const [certInfo, setCertInfo] = useState<CertInfo | null>(null);
const [certLoading, setCertLoading] = useState(false);
const [certError, setCertError] = useState("");
const [certSuccess, setCertSuccess] = useState("");
// Upload state
const [certFile, setCertFile] = useState<File | null>(null);
const [keyFile, setKeyFile] = useState<File | null>(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);
const [acmeOutput, setAcmeOutput] = useState("");
const loadDashboard = useCallback(async () => {
setDashLoading(true);
try {
const [smtp, health, storage, sysStats] = await Promise.allSettled([
getSMTPStatus(),
getHealth(),
getStorageStats(),
getSystemStats(),
]);
setSmtpStatus(smtp.status === "fulfilled" ? smtp.value : null);
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null);
setDashRefreshed(new Date());
} finally {
setDashLoading(false);
}
}, []);
const loadUsers = useCallback(async () => {
setUsersLoading(true);
setUsersError("");
try {
const data = await getUsers();
setUsers(data || []);
} catch {
setUsersError("Benutzer konnten nicht geladen werden.");
} finally {
setUsersLoading(false);
}
}, []);
const loadAudit = useCallback(async (p: number) => {
setAuditLoading(true);
try {
const data = await getAuditLog({ page: p, page_size: AUDIT_PAGE_SIZE });
setAuditEntries(data.entries || []);
setAuditTotal(data.total);
setAuditPage(p);
} catch {
setAuditEntries([]);
} finally {
setAuditLoading(false);
}
}, []);
const loadServices = useCallback(async () => {
setServicesLoading(true);
setServiceError("");
try {
const data = await getServices();
setServices(data || []);
} catch {
setServiceError("Dienste konnten nicht abgerufen werden.");
} finally {
setServicesLoading(false);
}
}, []);
const loadCert = useCallback(async () => {
setCertLoading(true);
setCertError("");
try {
const info = await getCertInfo();
setCertInfo(info);
} catch (e) {
setCertError(String(e));
} finally {
setCertLoading(false);
}
}, []);
async function handleUploadFiles(files: File[]) {
const valid = files.filter(f => f.name.toLowerCase().endsWith(".eml") || f.name.toLowerCase().endsWith(".mbox"));
if (valid.length === 0) {
setUploadError("Nur .eml und .mbox Dateien erlaubt.");
return;
}
setUploadError("");
setUploadJob(null);
setUploadLoading(true);
try {
const { job_id } = await uploadMailFiles(valid);
// Start polling
const poll = setInterval(async () => {
try {
const job = await getUploadProgress(job_id);
setUploadJob(job);
if (job.status !== "running") {
clearInterval(poll);
uploadPollRef.current = null;
setUploadLoading(false);
}
} catch {
clearInterval(poll);
uploadPollRef.current = null;
setUploadLoading(false);
}
}, 1500);
uploadPollRef.current = poll;
} catch (e: unknown) {
setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen.");
setUploadLoading(false);
}
}
async function handleServiceAction(name: string, action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external") {
setServiceActionLoading(`${name}:${action}`);
setServiceError("");
try {
const updated = await serviceAction(name, action);
setServices((prev) => prev.map((s) => (s.name === updated.name ? updated : s)));
} catch (e: unknown) {
setServiceError(e instanceof Error ? e.message : "Aktion fehlgeschlagen.");
} finally {
setServiceActionLoading(null);
}
}
const dashIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!user) return;
loadDashboard();
loadUsers();
loadAudit(1);
loadServices();
// Auto-Refresh Dashboard alle 30 Sekunden
setCountdown(30);
dashIntervalRef.current = setInterval(() => {
loadDashboard();
setCountdown(30);
}, 30_000);
// Countdown-Ticker
const ticker = setInterval(() => {
setCountdown((c) => (c > 0 ? c - 1 : 0));
}, 1_000);
return () => {
if (dashIntervalRef.current) clearInterval(dashIntervalRef.current);
clearInterval(ticker);
};
}, [user, loadDashboard, loadUsers, loadAudit, loadServices]);
async function handleCreateUser(e: React.FormEvent) {
e.preventDefault();
setCreateLoading(true);
setCreateError("");
try {
await createUser({
username: newUsername,
email: newEmail,
password: newPassword,
role: newRole,
});
setDialogOpen(false);
setNewUsername("");
setNewEmail("");
setNewPassword("");
setNewRole("user");
loadUsers();
} catch {
setCreateError("Benutzer konnte nicht erstellt werden.");
} finally {
setCreateLoading(false);
}
}
async function handleToggleActive(u: User) {
setUserActionLoading(u.id);
try {
await updateUser(u.id, { active: !u.active });
loadUsers();
} catch {
// ignore
} finally {
setUserActionLoading(null);
}
}
async function handleDeactivateConfirmed() {
if (!deleteDialogUser) return;
setDeleteActionLoading("deactivate");
setDeleteDialogError("");
try {
await updateUser(deleteDialogUser.id, { active: false });
setDeleteDialogUser(null);
loadUsers();
} catch {
setDeleteDialogError("Deaktivierung fehlgeschlagen.");
} finally {
setDeleteActionLoading(null);
}
}
async function handleDeleteConfirmed() {
if (!deleteDialogUser) return;
setDeleteActionLoading("delete");
setDeleteDialogError("");
try {
await deleteUser(deleteDialogUser.id);
setDeleteDialogUser(null);
loadUsers();
} catch (err: unknown) {
setDeleteDialogError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setDeleteActionLoading(null);
}
}
async function handleResetPassword(e: React.FormEvent) {
e.preventDefault();
if (!resetPasswordUserId) return;
setResetPasswordLoading(true);
setResetPasswordError("");
try {
await updateUser(resetPasswordUserId, { password: resetPasswordValue });
setResetPasswordUserId(null);
setResetPasswordValue("");
} catch {
setResetPasswordError("Passwort konnte nicht geändert werden.");
} finally {
setResetPasswordLoading(false);
}
}
async function runSecurityAudit() {
setSecurityLoading(true);
setSecurityError("");
setFixMessage("");
try {
const result = await getSecurityAudit();
setSecurityAudit(result);
} catch {
setSecurityError("Security-Audit konnte nicht ausgeführt werden.");
} finally {
setSecurityLoading(false);
}
}
async function runFix(action: string) {
setFixLoading(action);
setFixMessage("");
setSecurityError("");
try {
const res = await fixSecurityIssue(action);
setFixMessage(res.message);
// Re-run audit automatically to reflect new state
await runSecurityAudit();
} catch (e: unknown) {
setSecurityError(e instanceof Error ? e.message : "Fix fehlgeschlagen.");
} finally {
setFixLoading(null);
}
}
// LDAP handlers
const loadLDAP = useCallback(async () => {
setLdapLoading(true);
setLdapError("");
try {
const cfg = await getLDAPConfig();
if (cfg) {
setLdapConfig(cfg);
setLdapForm({ ...cfg, bind_password: "" });
setLdapChangePassword(false);
}
} catch {
setLdapError("LDAP-Konfiguration konnte nicht geladen werden.");
} finally {
setLdapLoading(false);
}
}, []);
async function handleSaveLDAP(e: React.FormEvent) {
e.preventDefault();
setLdapSaving(true);
setLdapError("");
try {
const payload: Partial<LDAPConfig> = { ...ldapForm };
if (!ldapChangePassword) {
delete payload.bind_password;
}
await saveLDAPConfig(payload);
await loadLDAP();
} catch (err: unknown) {
setLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
} finally {
setLdapSaving(false);
}
}
async function handleTestLDAP() {
setLdapTesting(true);
setLdapError("");
setLdapTestResult(null);
try {
const payload = ldapConfig
? { use_saved: true }
: { use_saved: false, ...ldapForm };
const result = await testLDAPConfig(payload as Parameters<typeof testLDAPConfig>[0]);
setLdapTestResult(result);
} catch (err: unknown) {
setLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen.");
} finally {
setLdapTesting(false);
}
}
async function handleDeleteLDAP() {
setLdapSaving(true);
setLdapError("");
try {
await deleteLDAPConfig();
setLdapConfig(null);
setLdapForm({
enabled: false, url: "ldap://", bind_dn: "", bind_password: "",
base_dn: "", user_filter: "(sAMAccountName=%s)", tls: false,
tls_skip_verify: false, default_role: "user", group_mappings: [],
});
setLdapTestResult(null);
} catch (err: unknown) {
setLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setLdapSaving(false);
}
}
// Tenants handlers
const loadTenants = useCallback(async () => {
setTenantsLoading(true);
setTenantsError("");
try {
const data = await getTenants();
setTenants(data || []);
} catch {
setTenantsError("Mandanten konnten nicht geladen werden.");
} finally {
setTenantsLoading(false);
}
}, []);
const loadAdminLabels = useCallback(async () => {
setAdminLabelsLoading(true);
setAdminLabelsError("");
try {
const data = await getAdminLabels();
setAdminLabels(data || []);
} catch {
setAdminLabelsError("Labels konnten nicht geladen werden.");
} finally {
setAdminLabelsLoading(false);
}
}, []);
const loadLabelRules = useCallback(async () => {
setLabelRulesLoading(true);
try {
const data = await getLabelRules();
setLabelRules(data || []);
} catch {
// ignore
} finally {
setLabelRulesLoading(false);
}
}, []);
function loadLabelsTab() {
loadAdminLabels();
loadLabelRules();
}
async function handleCreateAdminLabel(e: React.FormEvent) {
e.preventDefault();
if (!newLabelName.trim()) return;
setLabelCreating(true);
try {
await createAdminLabel(newLabelName.trim(), newLabelColor);
setNewLabelName("");
setNewLabelColor("#ef4444");
await loadAdminLabels();
} catch (err) {
setAdminLabelsError(err instanceof Error ? err.message : "Fehler");
} finally {
setLabelCreating(false);
}
}
async function handleDeleteAdminLabel(id: number, name: string) {
if (!window.confirm(`Globales Label "${name}" wirklich loeschen?`)) return;
try {
await deleteAdminLabel(id);
await loadAdminLabels();
await loadLabelRules();
} catch (err) {
setAdminLabelsError(err instanceof Error ? err.message : "Fehler");
}
}
async function handleCreateRule(e: React.FormEvent) {
e.preventDefault();
if (!newRuleValue.trim() || !newRuleLabelId) return;
setRuleCreating(true);
try {
await createLabelRule(newRuleField, newRuleValue.trim(), newRuleLabelId);
setNewRuleValue("");
await loadLabelRules();
} catch (err) {
setAdminLabelsError(err instanceof Error ? err.message : "Fehler");
} finally {
setRuleCreating(false);
}
}
async function handleDeleteRule(id: number) {
if (!window.confirm("Regel wirklich loeschen?")) return;
try {
await deleteLabelRule(id);
await loadLabelRules();
} catch {
// ignore
}
}
async function handleCreateTenant(e: React.FormEvent) {
e.preventDefault();
setTenantCreateLoading(true);
setTenantCreateError("");
try {
await createTenant(newTenantName, newTenantSlug);
setTenantDialogOpen(false);
setNewTenantName("");
setNewTenantSlug("");
await loadTenants();
} catch (err: unknown) {
setTenantCreateError(err instanceof Error ? err.message : "Erstellen fehlgeschlagen.");
} finally {
setTenantCreateLoading(false);
}
}
async function handleToggleTenant(t: Tenant) {
try {
await updateTenant(t.id, { active: !t.active });
await loadTenants();
} catch { /* ignore */ }
}
async function handleDeleteTenant() {
if (!tenantDeleteId) return;
setTenantDeleteLoading(true);
try {
await deleteTenant(tenantDeleteId);
setTenantDeleteId(null);
await loadTenants();
} catch { /* ignore */ } finally {
setTenantDeleteLoading(false);
}
}
async function openDomainDialog(t: Tenant) {
setDomainDialogTenant(t);
setDomainsLoading(true);
setDomainError("");
setNewDomain("");
try {
const domains = await getTenantDomains(t.id);
setTenantDomains(domains || []);
} catch { setDomainError("Domains konnten nicht geladen werden."); }
finally { setDomainsLoading(false); }
}
async function openUsersDialog(t: Tenant) {
setTenantUsersDialogId(t.id);
setTenantUsersDialogName(t.name);
setTenantUsersLoading(true);
setTenantUsers([]);
try {
const users = await getTenantUsers(t.id);
setTenantUsers(users || []);
} catch { /* ignore */ }
finally { setTenantUsersLoading(false); }
}
async function handleAddDomain() {
if (!domainDialogTenant || !newDomain) return;
setAddDomainLoading(true);
setDomainError("");
try {
await addTenantDomain(domainDialogTenant.id, newDomain);
setNewDomain("");
const domains = await getTenantDomains(domainDialogTenant.id);
setTenantDomains(domains || []);
} catch (err: unknown) {
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht hinzugefügt werden.");
} finally {
setAddDomainLoading(false);
}
}
async function handleRemoveDomain(domainId: number) {
if (!domainDialogTenant) return;
setDomainError("");
try {
await removeTenantDomain(domainDialogTenant.id, domainId);
const domains = await getTenantDomains(domainDialogTenant.id);
setTenantDomains(domains || []);
} catch (err: unknown) {
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht entfernt werden.");
}
}
// Tenant LDAP handlers (domain_admin)
const loadTenantLDAP = useCallback(async () => {
setTenantLdapLoading(true);
setTenantLdapError("");
try {
const cfg = await getTenantLDAPConfig();
if (cfg) {
setTenantLdapConfig(cfg);
setTenantLdapForm({ ...cfg, bind_password: "" });
setTenantLdapChangePassword(false);
}
} catch {
setTenantLdapError("LDAP-Konfiguration konnte nicht geladen werden.");
} finally {
setTenantLdapLoading(false);
}
}, []);
async function handleSaveTenantLDAP(e: React.FormEvent) {
e.preventDefault();
setTenantLdapSaving(true);
setTenantLdapError("");
try {
const payload: Partial<TenantLDAPConfig> = { ...tenantLdapForm };
if (!tenantLdapChangePassword) {
delete payload.bind_password;
}
await saveTenantLDAPConfig(payload);
await loadTenantLDAP();
} catch (err: unknown) {
setTenantLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
} finally {
setTenantLdapSaving(false);
}
}
async function handleTestTenantLDAP() {
setTenantLdapTesting(true);
setTenantLdapError("");
setTenantLdapTestResult(null);
try {
const payload = tenantLdapConfig
? { use_saved: true }
: { use_saved: false, ...tenantLdapForm };
const result = await testTenantLDAPConfig(payload as Parameters<typeof testTenantLDAPConfig>[0]);
setTenantLdapTestResult(result);
} catch (err: unknown) {
setTenantLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen.");
} finally {
setTenantLdapTesting(false);
}
}
async function handleDeleteTenantLDAP() {
setTenantLdapSaving(true);
setTenantLdapError("");
try {
await deleteTenantLDAPConfig();
setTenantLdapConfig(null);
setTenantLdapForm({
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: [],
});
setTenantLdapTestResult(null);
} catch (err: unknown) {
setTenantLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setTenantLdapSaving(false);
}
}
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
return (
<div className="min-h-screen">
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
<main className="mx-auto max-w-7xl px-4 py-6">
{(authLoading || !user) ? (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-sm" />
<Skeleton className="h-64 w-full" />
</div>
) : (<>
<h1 className="mb-6 text-2xl font-bold">Administration</h1>
<Tabs defaultValue="dashboard">
<TabsList>
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
{isSuperAdmin && <TabsTrigger value="services">Dienste</TabsTrigger>}
<TabsTrigger value="users">Benutzer</TabsTrigger>
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
<TabsTrigger value="import">Import</TabsTrigger>
{isSuperAdmin && <TabsTrigger value="ldap" onClick={loadLDAP}>LDAP (Global)</TabsTrigger>}
{!isSuperAdmin && user?.role === "domain_admin" && (
<TabsTrigger value="tenant-ldap" onClick={loadTenantLDAP}>LDAP</TabsTrigger>
)}
{isSuperAdmin && <TabsTrigger value="labels" onClick={loadLabelsTab}>Labels</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
</TabsList>
{/* ── Dashboard ── */}
<TabsContent value="dashboard" className="mt-4 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Systemstatus</h2>
<div className="flex items-center gap-3">
{dashRefreshed && (
<span className="text-xs text-muted-foreground">
{dashRefreshed.toLocaleTimeString("de-DE")} · nächste Aktualisierung in {countdown}s
</span>
)}
<Button variant="outline" size="sm" onClick={() => { loadDashboard(); setCountdown(30); }} disabled={dashLoading}>
{dashLoading ? "..." : "Jetzt aktualisieren"}
</Button>
</div>
</div>
{dashLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full rounded-lg" />
))}
</div>
) : (
<>
{/* Status-Kacheln */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* API */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">REST API</span>
<Badge variant={apiOnline ? "default" : "destructive"}>
{apiOnline ? "Online" : "Offline"}
</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Adresse</span>
<span className="font-mono">:8080</span>
<span className="text-muted-foreground">Protokoll</span>
<span>HTTP</span>
</div>
</CardContent>
</Card>
{/* SMTP — superadmin: globaler Daemon; domain_admin: nur eigene Domain-Statistik */}
{isSuperAdmin ? (
<>
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SMTP-Daemon</span>
<Badge variant={smtpStatus?.running ? "default" : "destructive"}>
{smtpStatus?.running ? "Aktiv" : smtpStatus?.enabled === false ? "Deaktiviert" : "Gestoppt"}
</Badge>
</div>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Adresse</span>
<span className="font-mono">{smtpStatus.bind}</span>
<span className="text-muted-foreground">Domain</span>
<span className="font-mono">{smtpStatus.domain || ""}</span>
<span className="text-muted-foreground">TLS</span>
<span>{smtpStatus.tls ? "Ja" : "Nein"}</span>
<span className="text-muted-foreground">Max. Größe</span>
<span>{(smtpStatus.max_size_mb ?? 0) > 0 ? `${smtpStatus.max_size_mb} MB` : "50 MB"}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Nicht erreichbar</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SMTP Statistik</span>
<span className="text-xs text-muted-foreground">seit letztem Start</span>
</div>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Empfangen</span>
<span className="font-semibold text-green-600">{smtpStatus.received ?? 0}</span>
<span className="text-muted-foreground">Abgelehnt</span>
<span className="font-semibold text-red-500">{smtpStatus.rejected ?? 0}</span>
<span className="text-muted-foreground">Letzte Mail</span>
<span className="text-xs">
{smtpStatus.last_mail_at
? new Date(smtpStatus.last_mail_at).toLocaleString("de-DE")
: ""}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SMTP meine Domain(s)</span>
<Badge variant="secondary">Tenant</Badge>
</div>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Domain(s)</span>
<span className="font-mono text-xs">
{(smtpStatus.domains?.length ?? 0) > 0 ? smtpStatus.domains!.join(", ") : ""}
</span>
<span className="text-muted-foreground">Archivierte Mails</span>
<span className="font-semibold">{smtpStatus.total_mails?.toLocaleString("de-DE") ?? ""}</span>
<span className="text-muted-foreground">Speicher</span>
<span>{smtpStatus.total_bytes ? formatBytes(smtpStatus.total_bytes) : ""}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
)}
{/* Archiv-Speicher */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Archiv gesamt</span>
{storageStats && (
<Badge variant="secondary" className="font-mono text-xs">
{storageStats.total_mails} Mails
</Badge>
)}
</div>
<Separator />
{storageStats ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">E-Mails</span>
<span className="font-semibold">{storageStats.total_mails.toLocaleString("de-DE")}</span>
<span className="text-muted-foreground">Speicher</span>
<span>{formatBytes(storageStats.total_bytes)}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
</div>
{/* System Stats: nur für superadmin */}
{isSuperAdmin && <div className="space-y-4">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Systemauslastung</h3>
{!systemStats ? (
<Alert variant="destructive">
<AlertDescription>
Systemdaten konnten nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft und der Endpunkt <code className="font-mono">/api/admin/system/stats</code> erreichbar ist.
</AlertDescription>
</Alert>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* CPU */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">CPU Load Average</span>
<Badge variant="secondary">{systemStats.cpu.num_cpu} CPU(s)</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">1 min</span>
<span className="font-semibold">{systemStats.cpu.load1.toFixed(2)}</span>
<span className="text-muted-foreground">5 min</span>
<span className="font-semibold">{systemStats.cpu.load5.toFixed(2)}</span>
<span className="text-muted-foreground">15 min</span>
<span className="font-semibold">{systemStats.cpu.load15.toFixed(2)}</span>
</div>
</CardContent>
</Card>
{/* RAM */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Arbeitsspeicher</span>
<Badge variant={systemStats.ram.used_pct > 90 ? "destructive" : systemStats.ram.used_pct > 70 ? "secondary" : "default"}>
{systemStats.ram.used_pct.toFixed(1)}%
</Badge>
</div>
<Separator />
<div className="space-y-2">
<div className="h-2 w-full rounded-full bg-secondary">
<div
className={`h-2 rounded-full ${systemStats.ram.used_pct > 90 ? "bg-destructive" : systemStats.ram.used_pct > 70 ? "bg-yellow-500" : "bg-primary"}`}
style={{ width: `${Math.min(systemStats.ram.used_pct, 100)}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Belegt</span>
<span>{formatBytes(systemStats.ram.used_bytes)}</span>
<span className="text-muted-foreground">Gesamt</span>
<span>{formatBytes(systemStats.ram.total_bytes)}</span>
<span className="text-muted-foreground">Frei</span>
<span>{formatBytes(systemStats.ram.free_bytes)}</span>
</div>
</div>
</CardContent>
</Card>
{/* Archivzeitraum */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Archivzeitraum</span>
</div>
<Separator />
{systemStats.archive.first_mail || systemStats.archive.last_mail ? (
<div className="space-y-2 text-sm">
{systemStats.archive.first_mail && (
<div>
<span className="text-xs text-muted-foreground block">Älteste Mail</span>
<span className="font-semibold">{new Date(systemStats.archive.first_mail.date).toLocaleDateString("de-DE")}</span>
<span className="block text-muted-foreground truncate">{systemStats.archive.first_mail.from || ""}</span>
<span className="block text-xs truncate">{systemStats.archive.first_mail.subject || "(kein Betreff)"}</span>
</div>
)}
{systemStats.archive.last_mail && (
<div className="pt-1 border-t">
<span className="text-xs text-muted-foreground block">Neueste Mail</span>
<span className="font-semibold">{new Date(systemStats.archive.last_mail.date).toLocaleDateString("de-DE")}</span>
<span className="block text-muted-foreground truncate">{systemStats.archive.last_mail.from || ""}</span>
<span className="block text-xs truncate">{systemStats.archive.last_mail.subject || "(kein Betreff)"}</span>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Archiv leer</p>
)}
</CardContent>
</Card>
</div>
{/* Festplatten */}
{systemStats.disks.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Festplatten</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{systemStats.disks.map((disk) => (
<Card key={disk.mount}>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium font-mono">{disk.mount}</span>
<Badge variant={disk.used_pct > 90 ? "destructive" : disk.used_pct > 75 ? "secondary" : "outline"}>
{disk.used_pct.toFixed(1)}%
</Badge>
</div>
<div className="h-2 w-full rounded-full bg-secondary">
<div
className={`h-2 rounded-full ${disk.used_pct > 90 ? "bg-destructive" : disk.used_pct > 75 ? "bg-yellow-500" : "bg-primary"}`}
style={{ width: `${Math.min(disk.used_pct, 100)}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Belegt</span>
<span>{formatBytes(disk.used_bytes)}</span>
<span className="text-muted-foreground">Gesamt</span>
<span>{formatBytes(disk.total_bytes)}</span>
<span className="text-muted-foreground">Frei</span>
<span>{formatBytes(disk.free_bytes)}</span>
<span className="text-muted-foreground">Dateisystem</span>
<span className="font-mono text-xs">{disk.fstype}</span>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
</>
)}
</div>}
{/* IP-Allowlist — nur superadmin */}
{isSuperAdmin && smtpStatus && (smtpStatus.allowed_ips?.length ?? 0) > 0 && (
<Card>
<CardContent className="pt-6 space-y-2">
<span className="text-sm font-medium text-muted-foreground">SMTP IP-Allowlist</span>
<Separator />
<div className="flex flex-wrap gap-2 pt-1">
{smtpStatus.allowed_ips!.map((ip) => (
<Badge key={ip} variant="outline" className="font-mono">
{ip}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Benutzerübersicht */}
<Card>
<CardContent className="pt-6 space-y-2">
<span className="text-sm font-medium text-muted-foreground">Benutzer</span>
<Separator />
{usersLoading ? (
<Skeleton className="h-8 w-full" />
) : (
<div className="flex flex-wrap gap-4 pt-1 text-sm">
<span>
<span className="font-semibold">{users.filter(u => u.active).length}</span>
<span className="text-muted-foreground ml-1">aktiv</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "admin").length}</span>
<span className="text-muted-foreground ml-1">Admin</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "auditor").length}</span>
<span className="text-muted-foreground ml-1">Auditor</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "user").length}</span>
<span className="text-muted-foreground ml-1">User</span>
</span>
</div>
)}
</CardContent>
</Card>
{!smtpStatus && (
<Alert variant="destructive">
<AlertDescription>
SMTP-Status konnte nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft.
</AlertDescription>
</Alert>
)}
</>
)}
</TabsContent>
{/* ── Dienste ── */}
<TabsContent value="services" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Systemdienste</h2>
<Button variant="outline" size="sm" onClick={loadServices} disabled={servicesLoading}>
{servicesLoading ? "..." : "Aktualisieren"}
</Button>
</div>
{serviceError && (
<Alert variant="destructive">
<AlertDescription>{serviceError}</AlertDescription>
</Alert>
)}
{servicesLoading && services.length === 0 ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-44">Dienst</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-24">Autostart</TableHead>
<TableHead className="w-28">Externer Zugriff</TableHead>
<TableHead>Beschreibung</TableHead>
{isSuperAdmin && <TableHead className="w-72 text-right">Aktionen</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{services.map((svc) => {
const isActive = svc.active === "active";
const isFailed = svc.active === "failed";
const isEnabled = svc.enabled === "enabled" || svc.enabled === "static";
const busy = (key: string) => serviceActionLoading === `${svc.name}:${key}`;
const anyBusy = serviceActionLoading?.startsWith(`${svc.name}:`) ?? false;
return (
<TableRow key={svc.name}>
<TableCell className="font-mono text-sm font-medium">
{svc.name}
</TableCell>
<TableCell>
<Badge
variant={isActive ? "default" : isFailed ? "destructive" : "secondary"}
>
{svc.active === "active"
? `Aktiv (${svc.sub})`
: svc.active === "failed"
? "Fehler"
: svc.active === "inactive"
? "Gestoppt"
: svc.active}
</Badge>
</TableCell>
<TableCell>
<Badge variant={isEnabled ? "default" : "outline"}>
{svc.enabled === "enabled"
? "Aktiviert"
: svc.enabled === "disabled"
? "Deaktiviert"
: svc.enabled === "static"
? "Statisch"
: svc.enabled}
</Badge>
</TableCell>
<TableCell>
{svc.external_blocked !== undefined ? (
<Badge variant={svc.external_blocked ? "destructive" : "default"}>
{svc.external_blocked ? "Gesperrt" : "Offen"}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-xs">
{svc.description || ""}
</TableCell>
{isSuperAdmin && (
<TableCell className="text-right">
<div className="flex justify-end gap-1 flex-wrap">
{isActive ? (
<>
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "restart")}
>
{busy("restart") ? "..." : "Neustart"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "stop")}
>
{busy("stop") ? "..." : "Stop"}
</Button>
</>
) : (
<Button
size="sm"
variant="default"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "start")}
>
{busy("start") ? "..." : "Start"}
</Button>
)}
{svc.enabled === "enabled" ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "disable")}
>
{busy("disable") ? "..." : "Deaktivieren"}
</Button>
) : svc.enabled === "disabled" ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "enable")}
>
{busy("enable") ? "..." : "Aktivieren"}
</Button>
) : null}
{svc.external_blocked !== undefined && (
svc.external_blocked ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "allow_external")}
>
{busy("allow_external") ? "..." : "Extern freigeben"}
</Button>
) : (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "block_external")}
>
{busy("block_external") ? "..." : "Extern sperren"}
</Button>
)
)}
</div>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</TabsContent>
<TabsContent value="users" className="mt-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Benutzerverwaltung</h2>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button>Benutzer anlegen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
<DialogDescription>
Erstellen Sie einen neuen Benutzer-Account.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateUser} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-username">Benutzername</Label>
<Input
id="new-username"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
aria-label="Neuer Benutzername"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-email">E-Mail</Label>
<Input
id="new-email"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
required
aria-label="Neue E-Mail-Adresse"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Passwort</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
aria-label="Neues Passwort"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-role">Rolle</Label>
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger id="new-role" aria-label="Rolle auswaehlen">
<SelectValue placeholder="Rolle waehlen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="domain_admin">Domain Admin</SelectItem>
{isSuperAdmin && <SelectItem value="superadmin">Superadmin</SelectItem>}
</SelectContent>
</Select>
</div>
{createError && (
<p className="text-sm text-destructive" role="alert">
{createError}
</p>
)}
<DialogFooter>
<Button type="submit" disabled={createLoading}>
{createLoading ? "Erstellen..." : "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{usersLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : usersError ? (
<Card>
<CardContent className="p-8 text-center text-destructive">
{usersError}
</CardContent>
</Card>
) : users.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Benutzer vorhanden.
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzername</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role}</Badge>
</TableCell>
<TableCell>
<Badge variant={u.active ? "default" : "destructive"}>
{u.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
disabled={userActionLoading === u.id}
onClick={() => {
setResetPasswordUserId(u.id);
setResetPasswordValue("");
setResetPasswordError("");
}}
>
Passwort
</Button>
<Button
size="sm"
variant="outline"
disabled={userActionLoading === u.id}
onClick={() => handleToggleActive(u)}
>
{userActionLoading === u.id ? "..." : u.active ? "Sperren" : "Freischalten"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={userActionLoading === u.id}
onClick={() => { setDeleteDialogUser(u); setDeleteDialogError(""); }}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</TabsContent>
<TabsContent value="audit" className="mt-4">
<h2 className="mb-4 text-lg font-semibold">Audit-Log</h2>
{auditLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : auditEntries.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Audit-Eintraege vorhanden.
</CardContent>
</Card>
) : (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeitstempel</TableHead>
<TableHead>Ereignis</TableHead>
<TableHead>Benutzer</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditEntries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="whitespace-nowrap">
{new Date(entry.timestamp).toLocaleString("de-DE")}
</TableCell>
<TableCell>
<Badge variant="outline">
{entry.event_type}
</Badge>
</TableCell>
<TableCell>{entry.username}</TableCell>
<TableCell className="max-w-xs truncate">
{entry.detail}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{auditTotalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={auditPage <= 1}
onClick={() => loadAudit(auditPage - 1)}
>
Zurueck
</Button>
<span className="text-sm text-muted-foreground">
Seite {auditPage} von {auditTotalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={auditPage >= auditTotalPages}
onClick={() => loadAudit(auditPage + 1)}
>
Weiter
</Button>
</div>
)}
</>
)}
</TabsContent>
{/* ── Import ── */}
<TabsContent value="import" className="mt-4 space-y-4">
<h2 className="text-lg font-semibold">EML / MBOX importieren</h2>
<p className="text-sm text-muted-foreground">
Lade .eml oder .mbox Dateien hoch um sie ins Archiv zu importieren. Duplikate werden automatisch übersprungen.
</p>
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setUploadDragging(true); }}
onDragLeave={() => setUploadDragging(false)}
onDrop={(e) => {
e.preventDefault();
setUploadDragging(false);
const files = Array.from(e.dataTransfer.files);
handleUploadFiles(files);
}}
className={`border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer ${
uploadDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50"
}`}
onClick={() => document.getElementById("upload-file-input")?.click()}
>
<input
id="upload-file-input"
type="file"
multiple
accept=".eml,.mbox"
className="hidden"
onChange={(e) => {
if (e.target.files) handleUploadFiles(Array.from(e.target.files));
e.target.value = "";
}}
/>
<p className="text-sm font-medium">Dateien hierher ziehen oder klicken zum Auswählen</p>
<p className="text-xs text-muted-foreground mt-1">Akzeptiert: .eml, .mbox</p>
</div>
{uploadError && (
<Alert variant="destructive">
<AlertDescription>{uploadError}</AlertDescription>
</Alert>
)}
{/* Progress */}
{(uploadLoading || uploadJob) && uploadJob && (
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{uploadJob.status === "running" ? "Import läuft..." : "Import abgeschlossen"}
</span>
<Badge variant={uploadJob.status === "done" ? "default" : "secondary"}>
{uploadJob.status === "done" ? "Fertig" : "Läuft"}
</Badge>
</div>
{/* Progress bar */}
{uploadJob.total > 0 && (
<div className="space-y-1">
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${Math.min(100, ((uploadJob.imported + uploadJob.skipped + uploadJob.errors) / uploadJob.total) * 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total} verarbeitet
</p>
</div>
)}
{uploadJob.status === "done" && (
<div className="grid grid-cols-3 gap-3 text-center text-sm">
<div className="rounded bg-green-50 dark:bg-green-950 p-2">
<p className="font-bold text-green-700 dark:text-green-400">{uploadJob.imported}</p>
<p className="text-xs text-muted-foreground">Importiert</p>
</div>
<div className="rounded bg-yellow-50 dark:bg-yellow-950 p-2">
<p className="font-bold text-yellow-700 dark:text-yellow-400">{uploadJob.skipped}</p>
<p className="text-xs text-muted-foreground">Übersprungen</p>
</div>
<div className="rounded bg-red-50 dark:bg-red-950 p-2">
<p className="font-bold text-red-700 dark:text-red-400">{uploadJob.errors}</p>
<p className="text-xs text-muted-foreground">Fehler</p>
</div>
</div>
)}
</CardContent>
</Card>
)}
{uploadLoading && !uploadJob && (
<p className="text-sm text-muted-foreground animate-pulse">Upload läuft...</p>
)}
</TabsContent>
{/* ── Security Audit ── */}
<TabsContent value="security" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Security Audit</h2>
{securityAudit && (
<p className="text-xs text-muted-foreground mt-0.5">
Zuletzt geprüft: {new Date(securityAudit.run_at).toLocaleString("de-DE")}
</p>
)}
</div>
<Button onClick={runSecurityAudit} disabled={securityLoading || fixLoading !== null} size="sm">
{securityLoading ? "Prüfe..." : "Jetzt prüfen"}
</Button>
</div>
{securityError && (
<Alert variant="destructive">
<AlertDescription>{securityError}</AlertDescription>
</Alert>
)}
{fixMessage && (
<Alert>
<AlertDescription className="text-green-700">{fixMessage}</AlertDescription>
</Alert>
)}
{!securityAudit && !securityLoading && !securityError && (
<div className="rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground">
Klicke auf &ldquo;Jetzt prüfen&rdquo; um den Security-Audit zu starten.
</div>
)}
{securityLoading && (
<div className="space-y-3">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
)}
{securityAudit && !securityLoading && (() => {
// Map check names to fix actions
const fixActions: Record<string, { action: string; label: string }> = {
"Fail2ban": { action: "install_fail2ban", label: "Installieren & aktivieren" },
"Firewall (nftables)": { action: "enable_firewall", label: "Firewall aktivieren" },
"SSH Passwort-Auth": { action: "fix_ssh_password_auth", label: "Deaktivieren" },
"SSH Root-Login": { action: "fix_ssh_root_login", label: "Auf prohibit-password setzen" },
};
return (
<div className="space-y-2">
{securityAudit.checks.map((check: SecurityCheck, i: number) => {
const fix = check.status !== "ok" ? fixActions[check.name] : undefined;
return (
<Card key={i}>
<CardContent className="p-4 flex items-start gap-3">
<span className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
check.status === "ok" ? "bg-green-500" :
check.status === "warning" ? "bg-yellow-400" : "bg-red-500"
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{check.name}</span>
<Badge
variant={check.status === "ok" ? "default" : check.status === "warning" ? "secondary" : "destructive"}
className={`text-xs ${check.status === "ok" ? "bg-green-100 text-green-800 hover:bg-green-100" : check.status === "warning" ? "bg-yellow-100 text-yellow-800 hover:bg-yellow-100" : ""}`}
>
{check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{check.message}</p>
</div>
{fix && (
<Button
size="sm"
variant="outline"
className="flex-shrink-0 text-xs"
disabled={fixLoading !== null}
onClick={() => runFix(fix.action)}
>
{fixLoading === fix.action ? "Wird behoben..." : fix.label}
</Button>
)}
{check.name === "HTTPS (TLS)" && check.status !== "ok" && (
<a
href="https://certbot.eff.org/instructions?os=debianbuster&tab=standard"
target="_blank"
rel="noopener noreferrer"
>
<Button size="sm" variant="outline" className="flex-shrink-0 text-xs">
Anleitung
</Button>
</a>
)}
</CardContent>
</Card>
);
})}
{/* Summary */}
<div className="mt-4 grid grid-cols-3 gap-3 text-center text-sm">
{[
{ label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length },
{ label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length },
{ label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length },
].map((s) => (
<div key={s.label} className={`rounded p-3 ${s.color}`}>
<p className="text-2xl font-bold">{s.count}</p>
<p className="text-xs">{s.label}</p>
</div>
))}
</div>
</div>
);
})()}
</TabsContent>
{/* ── LDAP ── */}
<TabsContent value="ldap" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">LDAP / Active Directory</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">LDAP aktiviert</span>
<input
type="checkbox"
className="h-4 w-4"
checked={ldapForm.enabled}
onChange={(e) => setLdapForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</div>
</div>
{ldapError && (
<Alert variant="destructive">
<AlertDescription>{ldapError}</AlertDescription>
</Alert>
)}
{ldapLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<form onSubmit={handleSaveLDAP} className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ldap-url">Server-URL</Label>
<Input
id="ldap-url"
placeholder="ldap://server:389 oder ldaps://server:636"
value={ldapForm.url}
onChange={(e) => setLdapForm((f) => ({ ...f, url: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-bind-dn">Bind-DN (Service-Account)</Label>
<Input
id="ldap-bind-dn"
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
value={ldapForm.bind_dn}
onChange={(e) => setLdapForm((f) => ({ ...f, bind_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-pw">Bind-Passwort</Label>
{ldapConfig && !ldapChangePassword ? (
<div className="flex gap-2">
<Input id="ldap-pw" type="password" value="••••••" readOnly className="flex-1" />
<Button type="button" variant="outline" size="sm" onClick={() => setLdapChangePassword(true)}>
Ändern
</Button>
</div>
) : (
<Input
id="ldap-pw"
type="password"
placeholder="Neues Passwort eingeben"
value={ldapForm.bind_password}
onChange={(e) => setLdapForm((f) => ({ ...f, bind_password: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="ldap-base-dn">Base-DN</Label>
<Input
id="ldap-base-dn"
placeholder="DC=example,DC=com"
value={ldapForm.base_dn}
onChange={(e) => setLdapForm((f) => ({ ...f, base_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-filter">User-Filter</Label>
<Input
id="ldap-filter"
placeholder="(sAMAccountName=%s)"
value={ldapForm.user_filter}
onChange={(e) => setLdapForm((f) => ({ ...f, user_filter: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-role">Standard-Rolle</Label>
<Select
value={ldapForm.default_role}
onValueChange={(v) => setLdapForm((f) => ({ ...f, default_role: v }))}
>
<SelectTrigger id="ldap-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={ldapForm.tls}
onChange={(e) => setLdapForm((f) => ({ ...f, tls: e.target.checked }))}
/>
STARTTLS verwenden
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={ldapForm.tls_skip_verify}
onChange={(e) => setLdapForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
/>
TLS-Zertifikat ignorieren
{ldapForm.tls_skip_verify && (
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
)}
</label>
</div>
<Separator />
{/* Group mappings */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Gruppen-Rollenzuordnung</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setLdapForm((f) => ({
...f,
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
}))
}
>
+ Hinzufügen
</Button>
</div>
{ldapForm.group_mappings.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
) : (
<div className="space-y-2">
{ldapForm.group_mappings.map((gm, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
className="flex-1"
placeholder="CN=Archivadmins,OU=Groups,DC=example,DC=com"
value={gm.group_dn}
onChange={(e) => {
const gms = [...ldapForm.group_mappings];
gms[i] = { ...gms[i], group_dn: e.target.value };
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
/>
<Select
value={gm.role}
onValueChange={(v) => {
const gms = [...ldapForm.group_mappings];
gms[i] = { ...gms[i], role: v };
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
const gms = ldapForm.group_mappings.filter((_, j) => j !== i);
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
Entfernen
</Button>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Test result */}
{ldapTestResult && (
<Card>
<CardContent className="pt-4 space-y-2">
<div className="flex items-center gap-2">
<Badge variant={ldapTestResult.ok ? "default" : "destructive"}>
{ldapTestResult.ok ? "Verbunden" : "Fehler"}
</Badge>
<span className="text-sm">{ldapTestResult.message}</span>
{ldapTestResult.latency_ms > 0 && (
<span className="text-xs text-muted-foreground">{ldapTestResult.latency_ms} ms</span>
)}
</div>
{ldapTestResult.server_info && (
<p className="text-xs text-muted-foreground font-mono">{ldapTestResult.server_info}</p>
)}
{ldapTestResult.users_found > 0 && (
<p className="text-sm">{ldapTestResult.users_found} Benutzer gefunden</p>
)}
{ldapTestResult.error_detail && (
<p className="text-xs text-destructive font-mono">{ldapTestResult.error_detail}</p>
)}
</CardContent>
</Card>
)}
{/* Action bar */}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={handleTestLDAP} disabled={ldapTesting || ldapSaving}>
{ldapTesting ? "Teste..." : "Verbindung testen"}
</Button>
<Button type="submit" disabled={ldapSaving || ldapTesting}>
{ldapSaving ? "Speichern..." : "Speichern"}
</Button>
{ldapConfig && (
<Button
type="button"
variant="destructive"
disabled={ldapSaving}
onClick={handleDeleteLDAP}
>
Konfiguration löschen
</Button>
)}
</div>
</form>
)}
{ldapConfig && (
<p className="text-xs text-muted-foreground">
Zuletzt geändert: {ldapConfig.updated_at ? new Date(ldapConfig.updated_at).toLocaleString("de-DE") : ""}
{ldapConfig.updated_by ? ` von ${ldapConfig.updated_by}` : ""}
</p>
)}
</TabsContent>
{/* ── Tenant LDAP (domain_admin) ── */}
<TabsContent value="tenant-ldap" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">LDAP / Active Directory Mandantenkonfiguration</h2>
<p className="text-sm text-muted-foreground mt-1">Konfiguriere den LDAP-Server für deinen Mandanten.</p>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">LDAP aktiviert</span>
<input
type="checkbox"
className="h-4 w-4"
checked={tenantLdapForm.enabled}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</div>
</div>
{tenantLdapError && (
<Alert variant="destructive">
<AlertDescription>{tenantLdapError}</AlertDescription>
</Alert>
)}
{tenantLdapLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<form onSubmit={handleSaveTenantLDAP} className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tldap-url">Server-URL</Label>
<Input
id="tldap-url"
placeholder="ldap://server:389 oder ldaps://server:636"
value={tenantLdapForm.url}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, url: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636
</p>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-bind-dn">Bind-DN (Service-Account)</Label>
<Input
id="tldap-bind-dn"
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
value={tenantLdapForm.bind_dn}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, bind_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-pw">Bind-Passwort</Label>
{tenantLdapConfig && !tenantLdapChangePassword ? (
<div className="flex gap-2">
<Input id="tldap-pw" type="password" value="••••••" readOnly className="flex-1" />
<Button type="button" variant="outline" size="sm" onClick={() => setTenantLdapChangePassword(true)}>
Ändern
</Button>
</div>
) : (
<Input
id="tldap-pw"
type="password"
placeholder="Neues Passwort eingeben"
value={tenantLdapForm.bind_password}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, bind_password: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="tldap-base-dn">Base-DN</Label>
<Input
id="tldap-base-dn"
placeholder="DC=example,DC=com"
value={tenantLdapForm.base_dn}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, base_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-filter">User-Filter</Label>
<Input
id="tldap-filter"
placeholder="(sAMAccountName=%s)"
value={tenantLdapForm.user_filter}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, user_filter: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-role">Standard-Rolle</Label>
<Select
value={tenantLdapForm.default_role}
onValueChange={(v) => setTenantLdapForm((f) => ({ ...f, default_role: v }))}
>
<SelectTrigger id="tldap-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={tenantLdapForm.tls}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, tls: e.target.checked }))}
/>
STARTTLS verwenden
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={tenantLdapForm.tls_skip_verify}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
/>
TLS-Zertifikat ignorieren
{tenantLdapForm.tls_skip_verify && (
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
)}
</label>
</div>
<Separator />
{/* Group mappings -- domain_admin: nur user + auditor */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Gruppen-Rollenzuordnung</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setTenantLdapForm((f) => ({
...f,
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
}))
}
>
+ Hinzufuegen
</Button>
</div>
{tenantLdapForm.group_mappings.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
) : (
<div className="space-y-2">
{tenantLdapForm.group_mappings.map((gm, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
className="flex-1"
placeholder="CN=Archivadmins,OU=Groups,DC=example,DC=com"
value={gm.group_dn}
onChange={(e) => {
const gms = [...tenantLdapForm.group_mappings];
gms[i] = { ...gms[i], group_dn: e.target.value };
setTenantLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
/>
<Select
value={gm.role}
onValueChange={(v) => {
const gms = [...tenantLdapForm.group_mappings];
gms[i] = { ...gms[i], role: v };
setTenantLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
const gms = tenantLdapForm.group_mappings.filter((_, j) => j !== i);
setTenantLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
Entfernen
</Button>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Test result */}
{tenantLdapTestResult && (
<Card>
<CardContent className="pt-4 space-y-2">
<div className="flex items-center gap-2">
<Badge variant={tenantLdapTestResult.ok ? "default" : "destructive"}>
{tenantLdapTestResult.ok ? "Verbunden" : "Fehler"}
</Badge>
<span className="text-sm">{tenantLdapTestResult.message}</span>
{tenantLdapTestResult.latency_ms > 0 && (
<span className="text-xs text-muted-foreground">{tenantLdapTestResult.latency_ms} ms</span>
)}
</div>
{tenantLdapTestResult.server_info && (
<p className="text-xs text-muted-foreground font-mono">{tenantLdapTestResult.server_info}</p>
)}
{tenantLdapTestResult.users_found > 0 && (
<p className="text-sm">{tenantLdapTestResult.users_found} Benutzer gefunden</p>
)}
{tenantLdapTestResult.error_detail && (
<p className="text-xs text-destructive font-mono">{tenantLdapTestResult.error_detail}</p>
)}
</CardContent>
</Card>
)}
{/* Action bar */}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={handleTestTenantLDAP} disabled={tenantLdapTesting || tenantLdapSaving}>
{tenantLdapTesting ? "Teste..." : "Verbindung testen"}
</Button>
<Button type="submit" disabled={tenantLdapSaving || tenantLdapTesting}>
{tenantLdapSaving ? "Speichern..." : "Speichern"}
</Button>
{tenantLdapConfig && (
<Button
type="button"
variant="destructive"
disabled={tenantLdapSaving}
onClick={handleDeleteTenantLDAP}
>
Konfiguration löschen
</Button>
)}
</div>
</form>
)}
{tenantLdapConfig && (
<p className="text-xs text-muted-foreground">
Zuletzt geändert: {tenantLdapConfig.updated_at ? new Date(tenantLdapConfig.updated_at).toLocaleString("de-DE") : ""}
{tenantLdapConfig.updated_by ? ` von ${tenantLdapConfig.updated_by}` : ""}
</p>
)}
</TabsContent>
{/* ── Mandanten ── */}
<TabsContent value="tenants" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Mandantenverwaltung</h2>
<Dialog open={tenantDialogOpen} onOpenChange={setTenantDialogOpen}>
<DialogTrigger asChild>
<Button size="sm">+ Mandant anlegen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuen Mandanten anlegen</DialogTitle>
<DialogDescription>Name und URL-Slug für den neuen Mandanten eingeben.</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateTenant} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tenant-name">Name</Label>
<Input
id="tenant-name"
value={newTenantName}
required
onChange={(e) => {
setNewTenantName(e.target.value);
setNewTenantSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug">Slug</Label>
<Input
id="tenant-slug"
value={newTenantSlug}
required
onChange={(e) => setNewTenantSlug(e.target.value)}
/>
</div>
{tenantCreateError && (
<p className="text-sm text-destructive">{tenantCreateError}</p>
)}
<DialogFooter>
<Button type="submit" disabled={tenantCreateLoading}>
{tenantCreateLoading ? "Erstellen..." : "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{tenantsError && (
<Alert variant="destructive">
<AlertDescription>{tenantsError}</AlertDescription>
</Alert>
)}
{tenantsLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : tenants.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Mandanten vorhanden. Klicke auf &ldquo;+ Mandant anlegen&rdquo; um den ersten Mandanten zu erstellen.
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Slug</TableHead>
<TableHead className="text-center">Domains</TableHead>
<TableHead className="text-center">Nutzer</TableHead>
<TableHead>LDAP</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenants.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{t.slug}</TableCell>
<TableCell className="text-center">{t.domain_count ?? 0}</TableCell>
<TableCell className="text-center">{t.user_count ?? 0}</TableCell>
<TableCell>
{t.ldap_enabled === true ? (
<Badge variant="default" className="bg-green-100 text-green-800 hover:bg-green-100">Aktiv</Badge>
) : t.ldap_url ? (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">Deaktiviert</Badge>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<Badge variant={t.active ? "default" : "secondary"}>
{t.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" onClick={() => openDomainDialog(t)}>
Domains
</Button>
<Button size="sm" variant="outline" onClick={() => openUsersDialog(t)}>
Nutzer
</Button>
<Button size="sm" variant="outline" onClick={() => setTenantLdapDialogId(t.id)}>
LDAP
</Button>
<Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}>
{t.active ? "Deaktivieren" : "Aktivieren"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setTenantDeleteId(t.id)}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
{/* Tenant delete confirmation */}
<Dialog open={tenantDeleteId !== null} onOpenChange={(open) => { if (!open) setTenantDeleteId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mandant löschen</DialogTitle>
<DialogDescription>
Alle Daten dieses Mandanten (Domains, LDAP-Konfiguration) werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setTenantDeleteId(null)}>Abbrechen</Button>
<Button variant="destructive" disabled={tenantDeleteLoading} onClick={handleDeleteTenant}>
{tenantDeleteLoading ? "Löschen..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Domain management dialog */}
<Dialog open={domainDialogTenant !== null} onOpenChange={(open) => { if (!open) setDomainDialogTenant(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Domains: {domainDialogTenant?.name}</DialogTitle>
<DialogDescription>E-Mail-Domains diesem Mandanten zuweisen.</DialogDescription>
</DialogHeader>
{domainError && (
<Alert variant="destructive">
<AlertDescription>{domainError}</AlertDescription>
</Alert>
)}
{domainsLoading ? (
<Skeleton className="h-24 w-full" />
) : (
<div className="space-y-2">
{tenantDomains.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Domains zugewiesen.</p>
) : (
tenantDomains.map((d) => (
<div key={d.id} className="flex items-center justify-between rounded border px-3 py-2">
<span className="font-mono text-sm">{d.domain}</span>
<Button size="sm" variant="destructive" onClick={() => handleRemoveDomain(d.id)}>
Entfernen
</Button>
</div>
))
)}
</div>
)}
<div className="flex gap-2 pt-2">
<Input
placeholder="example.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddDomain(); } }}
/>
<Button onClick={handleAddDomain} disabled={addDomainLoading || !newDomain}>
{addDomainLoading ? "..." : "Hinzufügen"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Tenant users dialog */}
<Dialog open={tenantUsersDialogId !== null} onOpenChange={(open) => { if (!open) setTenantUsersDialogId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nutzer: {tenantUsersDialogName}</DialogTitle>
<DialogDescription>Dem Mandanten zugewiesene Benutzerkonten.</DialogDescription>
</DialogHeader>
{tenantUsersLoading ? (
<Skeleton className="h-24 w-full" />
) : tenantUsers.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">Keine Benutzer diesem Mandanten zugewiesen.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzername</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenantUsers.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell className="text-sm text-muted-foreground">{u.email}</TableCell>
<TableCell><Badge variant="outline">{u.role}</Badge></TableCell>
<TableCell>
<Badge variant={u.active ? "default" : "secondary"}>
{u.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</DialogContent>
</Dialog>
{/* Tenant LDAP dialog (superadmin) */}
{tenantLdapDialogId !== null && (
<TenantLDAPDialog
tenantID={tenantLdapDialogId}
onClose={() => { setTenantLdapDialogId(null); loadTenants(); }}
/>
)}
</TabsContent>
{/* ── Labels (Admin) ── */}
<TabsContent value="labels" className="mt-4 space-y-6">
{adminLabelsError && (
<Alert variant="destructive">
<AlertDescription>{adminLabelsError}</AlertDescription>
</Alert>
)}
{/* Globale Labels */}
<Card>
<CardContent className="pt-6 space-y-4">
<h3 className="text-sm font-semibold">Globale Labels</h3>
<form onSubmit={handleCreateAdminLabel} className="flex items-end gap-3">
<div className="space-y-1">
<Label htmlFor="label-name" className="text-xs">Name</Label>
<Input
id="label-name"
value={newLabelName}
onChange={(e) => setNewLabelName(e.target.value)}
placeholder="Label-Name"
className="h-8 w-48"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Farbe</Label>
<div className="flex gap-1.5">
{["#ef4444","#f97316","#eab308","#22c55e","#06b6d4","#6366f1","#a855f7","#ec4899"].map((c) => (
<button
key={c}
type="button"
className={`h-6 w-6 rounded-full border-2 transition-transform ${
newLabelColor === c ? "border-foreground scale-110" : "border-transparent"
}`}
style={{ backgroundColor: c }}
onClick={() => setNewLabelColor(c)}
aria-label={`Farbe ${c}`}
/>
))}
</div>
</div>
<Button type="submit" size="sm" disabled={labelCreating || !newLabelName.trim()}>
{labelCreating ? "..." : "Anlegen"}
</Button>
</form>
{adminLabelsLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : adminLabels.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine globalen Labels vorhanden.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-24">Farbe</TableHead>
<TableHead className="w-24">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminLabels.map((label) => (
<TableRow key={label.id}>
<TableCell className="font-medium">{label.name}</TableCell>
<TableCell>
<span
className="inline-block h-4 w-4 rounded-full"
style={{ backgroundColor: label.color }}
aria-label={`Farbe ${label.color}`}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-7"
onClick={() => handleDeleteAdminLabel(label.id, label.name)}
>
Loeschen
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Auto-Regeln */}
<Card>
<CardContent className="pt-6 space-y-4">
<h3 className="text-sm font-semibold">Auto-Regeln</h3>
<form onSubmit={handleCreateRule} className="flex items-end gap-3 flex-wrap">
<div className="space-y-1">
<Label htmlFor="rule-field" className="text-xs">Bedingung</Label>
<Select value={newRuleField} onValueChange={setNewRuleField}>
<SelectTrigger id="rule-field" className="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="from_domain">Absender-Domain</SelectItem>
<SelectItem value="source">Import-Quelle</SelectItem>
<SelectItem value="subject_contains">Betreff enthaelt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="rule-value" className="text-xs">Wert</Label>
<Input
id="rule-value"
value={newRuleValue}
onChange={(e) => setNewRuleValue(e.target.value)}
placeholder="z.B. example.com"
className="h-8 w-48"
/>
</div>
<div className="space-y-1">
<Label htmlFor="rule-label" className="text-xs">Label</Label>
<Select
value={newRuleLabelId !== null ? String(newRuleLabelId) : ""}
onValueChange={(v) => setNewRuleLabelId(Number(v))}
>
<SelectTrigger id="rule-label" className="h-8 w-44 text-xs">
<SelectValue placeholder="Label waehlen..." />
</SelectTrigger>
<SelectContent>
{adminLabels.map((l) => (
<SelectItem key={l.id} value={String(l.id)}>
<span className="flex items-center gap-2">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: l.color }}
/>
{l.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={ruleCreating || !newRuleValue.trim() || !newRuleLabelId}>
{ruleCreating ? "..." : "Regel anlegen"}
</Button>
</form>
{labelRulesLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : labelRules.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Regeln vorhanden.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Bedingung</TableHead>
<TableHead>Wert</TableHead>
<TableHead>Label</TableHead>
<TableHead className="w-24">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{labelRules.map((rule) => {
const condLabels: Record<string, string> = {
from_domain: "Absender-Domain",
source: "Import-Quelle",
subject_contains: "Betreff enthaelt",
};
const matchLabel = adminLabels.find((l) => l.id === rule.label_id);
return (
<TableRow key={rule.id}>
<TableCell className="text-sm">{condLabels[rule.condition_field] || rule.condition_field}</TableCell>
<TableCell className="text-sm font-mono">{rule.condition_value}</TableCell>
<TableCell>
{matchLabel ? (
<span className="flex items-center gap-2 text-sm">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: matchLabel.color }}
/>
{matchLabel.name}
</span>
) : (
<span className="text-xs text-muted-foreground">ID {rule.label_id}</span>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-7"
onClick={() => handleDeleteRule(rule.id)}
>
Loeschen
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
{/* ── Zertifikat ── */}
{isSuperAdmin && (
<TabsContent value="cert" className="mt-4 space-y-6">
{/* Aktuelles Zertifikat */}
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Aktuelles Zertifikat</h3>
<Button variant="outline" size="sm" onClick={loadCert} disabled={certLoading}>
Aktualisieren
</Button>
</div>
{certLoading && <div className="text-sm text-muted-foreground">Lade...</div>}
{certError && <Alert variant="destructive"><AlertDescription>{certError}</AlertDescription></Alert>}
{certSuccess && <Alert><AlertDescription>{certSuccess}</AlertDescription></Alert>}
{certInfo && !certLoading && (
certInfo.exists ? (
<div className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Aussteller</span>
<span>{certInfo.issuer}</span>
<span className="text-muted-foreground">Subject</span>
<span>{certInfo.subject}</span>
<span className="text-muted-foreground">Gueltig bis</span>
<span className={certInfo.days_remaining! < 30 ? "text-destructive font-medium" : ""}>
{certInfo.not_after ? new Date(certInfo.not_after).toLocaleDateString("de-DE") : "--"}
{" "}({certInfo.days_remaining} Tage)
</span>
<span className="text-muted-foreground">DNS-Namen</span>
<span>{certInfo.dns_names?.join(", ") || "--"}</span>
<span className="text-muted-foreground">IP-Adressen</span>
<span>{certInfo.ip_addresses?.join(", ") || "--"}</span>
<span className="text-muted-foreground">Typ</span>
<span>{certInfo.is_self_signed ? "Selbstsigniert" : "CA-signiert"}</span>
<span className="text-muted-foreground">SHA-256</span>
<span className="font-mono text-xs break-all">{certInfo.fingerprint_sha256}</span>
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">Kein Zertifikat gefunden unter /etc/ssl/archivmail/</div>
)
)}
</CardContent>
</Card>
{/* Zertifikat hochladen */}
<Card>
<CardContent className="pt-6 space-y-4">
<h3 className="text-lg font-semibold">Zertifikat hochladen</h3>
<p className="text-sm text-muted-foreground">Eigenes CA-signiertes oder Let&#39;s Encrypt Zertifikat hochladen.</p>
<div className="space-y-3">
<div className="space-y-1">
<Label>Zertifikat (.crt / .pem)</Label>
<Input type="file" accept=".crt,.pem,.cer" onChange={e => setCertFile(e.target.files?.[0] ?? null)} />
</div>
<div className="space-y-1">
<Label>Privater Schluessel (.key / .pem)</Label>
<Input type="file" accept=".key,.pem" onChange={e => setKeyFile(e.target.files?.[0] ?? null)} />
</div>
<Button
onClick={async () => {
if (!certFile || !keyFile) return;
setCertUploadLoading(true); setCertError(""); setCertSuccess("");
try {
const res = await uploadCert(certFile, keyFile);
setCertSuccess(res.message);
loadCert();
} catch(e) { setCertError(String(e)); }
finally { setCertUploadLoading(false); }
}}
disabled={!certFile || !keyFile || certUploadLoading}
>
{certUploadLoading ? "Hochladen..." : "Hochladen & nginx neu laden"}
</Button>
</div>
</CardContent>
</Card>
{/* Self-Signed generieren */}
<Card>
<CardContent className="pt-6 space-y-4">
<h3 className="text-lg font-semibold">Self-Signed Zertifikat ausstellen</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Common Name</Label>
<Input value={selfSignedCN} onChange={e => setSelfSignedCN(e.target.value)} placeholder="archivmail" />
</div>
<div className="space-y-1">
<Label>Gueltigkeit</Label>
<Select value={selfSignedYears} onValueChange={setSelfSignedYears}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">1 Jahr</SelectItem>
<SelectItem value="5">5 Jahre</SelectItem>
<SelectItem value="10">10 Jahre</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>DNS-Namen (kommagetrennt)</Label>
<Input value={selfSignedDNS} onChange={e => setSelfSignedDNS(e.target.value)} placeholder="archivmail,mail.intern" />
</div>
<div className="space-y-1">
<Label>IP-Adressen (kommagetrennt)</Label>
<Input value={selfSignedIPs} onChange={e => setSelfSignedIPs(e.target.value)} placeholder="192.168.1.131" />
</div>
</div>
<Button
onClick={async () => {
setSelfSignedLoading(true); setCertError(""); setCertSuccess("");
try {
const res = await generateSelfSignedCert({
common_name: selfSignedCN,
dns_names: selfSignedDNS.split(",").map(s => s.trim()).filter(Boolean),
ip_addresses: selfSignedIPs.split(",").map(s => s.trim()).filter(Boolean),
validity_years: parseInt(selfSignedYears),
});
setCertSuccess("Zertifikat ausgestellt und nginx neu geladen.");
setCertInfo(res);
} catch(e) { setCertError(String(e)); }
finally { setSelfSignedLoading(false); }
}}
disabled={selfSignedLoading || !selfSignedCN}
>
{selfSignedLoading ? "Generiere..." : "Ausstellen & nginx neu laden"}
</Button>
</CardContent>
</Card>
{/* ACME / Let's Encrypt */}
<Card>
<CardContent className="pt-6 space-y-4">
<h3 className="text-lg font-semibold">Let&#39;s Encrypt / ACME</h3>
<p className="text-sm text-muted-foreground">
Oeffentlich erreichbare Domain erforderlich (Port 80 muss von aussen erreichbar sein).
certbot muss auf dem Server installiert sein.
</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Domain</Label>
<Input value={acmeDomain} onChange={e => setAcmeDomain(e.target.value)} placeholder="mail.example.com" />
</div>
<div className="space-y-1">
<Label>E-Mail (fuer Let&#39;s Encrypt)</Label>
<Input value={acmeEmail} onChange={e => setAcmeEmail(e.target.value)} placeholder="admin@example.com" type="email" />
</div>
</div>
{acmeOutput && (
<pre className="text-xs bg-muted p-3 rounded overflow-auto max-h-40 whitespace-pre-wrap">{acmeOutput}</pre>
)}
<Button
onClick={async () => {
setAcmeLoading(true); setCertError(""); setCertSuccess(""); setAcmeOutput("");
try {
const res = await requestACMECert({ domain: acmeDomain, email: acmeEmail });
setCertSuccess("Let's Encrypt Zertifikat ausgestellt.");
setAcmeOutput(res.output);
loadCert();
} catch(e) {
setCertError(String(e));
}
finally { setAcmeLoading(false); }
}}
disabled={acmeLoading || !acmeDomain || !acmeEmail}
>
{acmeLoading ? "Laeuft (kann ~30s dauern)..." : "Zertifikat via Let's Encrypt anfordern"}
</Button>
</CardContent>
</Card>
</TabsContent>
)}
<TabsContent value="modules" className="mt-4">
<ModulesTab />
</TabsContent>
</Tabs>
</>)}
</main>
{/* Passwort-Reset Dialog */}
<Dialog open={resetPasswordUserId !== null} onOpenChange={(open) => { if (!open) setResetPasswordUserId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Passwort zurücksetzen</DialogTitle>
<DialogDescription>
Neues Passwort für den Benutzer festlegen.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleResetPassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-password">Neues Passwort</Label>
<Input
id="new-password"
type="password"
value={resetPasswordValue}
onChange={(e) => setResetPasswordValue(e.target.value)}
required
minLength={8}
placeholder="Mindestens 8 Zeichen"
/>
</div>
{resetPasswordError && (
<p className="text-sm text-destructive">{resetPasswordError}</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setResetPasswordUserId(null)}>
Abbrechen
</Button>
<Button type="submit" disabled={resetPasswordLoading}>
{resetPasswordLoading ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Nutzer-Löschung Dialog */}
<Dialog open={deleteDialogUser !== null} onOpenChange={(open) => { if (!open) setDeleteDialogUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Benutzer entfernen</DialogTitle>
<DialogDescription>
Was soll mit dem Konto <strong>{deleteDialogUser?.username}</strong> passieren?
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800">
<strong>Hinweis (GoBD):</strong> E-Mails bleiben unabhängig von dieser Aktion im Archiv erhalten. Die gesetzliche Aufbewahrungspflicht besteht auch nach Ausscheiden des Mitarbeiters.
</div>
<div className="space-y-3">
<div className="rounded-md border p-3">
<p className="font-medium text-sm">Konto deaktivieren (empfohlen)</p>
<p className="text-xs text-muted-foreground mt-1">
Login wird gesperrt. Konto und IMAP-Verbindungen bleiben erhalten und können reaktiviert werden.
</p>
</div>
<div className="rounded-md border border-destructive/30 p-3">
<p className="font-medium text-sm text-destructive">Konto endgültig löschen</p>
<p className="text-xs text-muted-foreground mt-1">
Account und alle IMAP-Verbindungen werden dauerhaft entfernt. Nicht rückgängig zu machen.
</p>
</div>
</div>
{deleteDialogError && (
<p className="text-sm text-destructive">{deleteDialogError}</p>
)}
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => setDeleteDialogUser(null)} disabled={deleteActionLoading !== null}>
Abbrechen
</Button>
<Button
variant="outline"
onClick={handleDeactivateConfirmed}
disabled={deleteActionLoading !== null || deleteDialogUser?.active === false}
>
{deleteActionLoading === "deactivate" ? "Wird deaktiviert..." : "Deaktivieren"}
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirmed}
disabled={deleteActionLoading !== null}
>
{deleteActionLoading === "delete" ? "Wird gelöscht..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── Module Tab ─────────────────────────────────────────────────────────────
const statusColors: Record<string, string> = {
"Planned": "bg-gray-100 text-gray-700",
"In Progress": "bg-yellow-100 text-yellow-800",
"In Review": "bg-blue-100 text-blue-800",
"Deployed": "bg-green-100 text-green-800",
};
const statusCounts = (list: Feature[]) => ({
total: list.length,
planned: list.filter((f) => f.status === "Planned").length,
inProgress: list.filter((f) => f.status === "In Progress").length,
inReview: list.filter((f) => f.status === "In Review").length,
deployed: list.filter((f) => f.status === "Deployed").length,
});
function ModulesTab() {
const counts = statusCounts(features);
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Modulübersicht</h2>
{/* Summary bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "In Progress", value: counts.inProgress, color: "bg-yellow-100 text-yellow-800" },
{ label: "In Review", value: counts.inReview, color: "bg-blue-100 text-blue-800" },
{ label: "Deployed", value: counts.deployed, color: "bg-green-100 text-green-800" },
{ label: "Geplant", value: counts.planned, color: "bg-gray-100 text-gray-700" },
].map((s) => (
<Card key={s.label}>
<CardContent className="p-4 flex items-center justify-between">
<span className="text-sm text-muted-foreground">{s.label}</span>
<span className={`text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
{s.value}
</span>
</CardContent>
</Card>
))}
</div>
{/* Table */}
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead>Feature</TableHead>
<TableHead className="w-32">Status</TableHead>
<TableHead className="w-24 text-center">Frontend</TableHead>
<TableHead className="w-24 text-center">Backend</TableHead>
<TableHead className="w-32">Aktualisiert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{features.map((f) => (
<TableRow key={f.id}>
<TableCell className="font-mono text-xs text-muted-foreground">
{f.id}
</TableCell>
<TableCell className="font-medium">{f.name}</TableCell>
<TableCell>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
{f.status}
</span>
</TableCell>
<TableCell className="text-center">
{f.frontend
? <span className="text-green-600 font-bold"></span>
: <span className="text-muted-foreground"></span>
}
</TableCell>
<TableCell className="text-center">
{f.backend
? <span className="text-green-600 font-bold"></span>
: <span className="text-muted-foreground"></span>
}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{f.lastUpdated}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
);
}
// ── Tenant LDAP Dialog (superadmin) ─────────────────────────────────────────
function TenantLDAPDialog({ tenantID, onClose }: { tenantID: number; onClose: () => void }) {
const [config, setConfig] = useState<TenantLDAPConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState("");
const [testResult, setTestResult] = useState<LDAPTestResult | null>(null);
const [changePassword, setChangePassword] = useState(false);
const [form, setForm] = useState<TenantLDAPConfig>({
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<TenantLDAPConfig> = { ...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<typeof testAdminTenantLDAPConfig>[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 (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>LDAP-Konfiguration (Mandant #{tenantID})</DialogTitle>
<DialogDescription>LDAP-Server für diesen Mandanten konfigurieren.</DialogDescription>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<form onSubmit={handleSave} className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">LDAP aktiviert</span>
<input
type="checkbox"
className="h-4 w-4"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`atldap-url-${tenantID}`}>Server-URL</Label>
<Input
id={`atldap-url-${tenantID}`}
placeholder="ldap://server:389 oder ldaps://server:636"
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`atldap-bind-dn-${tenantID}`}>Bind-DN (Service-Account)</Label>
<Input
id={`atldap-bind-dn-${tenantID}`}
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
value={form.bind_dn}
onChange={(e) => setForm((f) => ({ ...f, bind_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`atldap-pw-${tenantID}`}>Bind-Passwort</Label>
{config && !changePassword ? (
<div className="flex gap-2">
<Input id={`atldap-pw-${tenantID}`} type="password" value="••••••" readOnly className="flex-1" />
<Button type="button" variant="outline" size="sm" onClick={() => setChangePassword(true)}>
Ändern
</Button>
</div>
) : (
<Input
id={`atldap-pw-${tenantID}`}
type="password"
placeholder="Neues Passwort eingeben"
value={form.bind_password}
onChange={(e) => setForm((f) => ({ ...f, bind_password: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor={`atldap-base-dn-${tenantID}`}>Base-DN</Label>
<Input
id={`atldap-base-dn-${tenantID}`}
placeholder="DC=example,DC=com"
value={form.base_dn}
onChange={(e) => setForm((f) => ({ ...f, base_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`atldap-filter-${tenantID}`}>User-Filter</Label>
<Input
id={`atldap-filter-${tenantID}`}
placeholder="(sAMAccountName=%s)"
value={form.user_filter}
onChange={(e) => setForm((f) => ({ ...f, user_filter: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`atldap-role-${tenantID}`}>Standard-Rolle</Label>
<Select
value={form.default_role}
onValueChange={(v) => setForm((f) => ({ ...f, default_role: v }))}
>
<SelectTrigger id={`atldap-role-${tenantID}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="domain_admin">Domain Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={form.tls}
onChange={(e) => setForm((f) => ({ ...f, tls: e.target.checked }))}
/>
STARTTLS verwenden
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={form.tls_skip_verify}
onChange={(e) => setForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
/>
TLS-Zertifikat ignorieren
{form.tls_skip_verify && (
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
)}
</label>
</div>
<Separator />
{/* Group mappings -- superadmin per tenant: bis domain_admin */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Gruppen-Rollenzuordnung</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setForm((f) => ({
...f,
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
}))
}
>
+ Hinzufuegen
</Button>
</div>
{form.group_mappings.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
) : (
<div className="space-y-2">
{form.group_mappings.map((gm, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
className="flex-1"
placeholder="CN=Archivadmins,OU=Groups,DC=example,DC=com"
value={gm.group_dn}
onChange={(e) => {
const gms = [...form.group_mappings];
gms[i] = { ...gms[i], group_dn: e.target.value };
setForm((f) => ({ ...f, group_mappings: gms }));
}}
/>
<Select
value={gm.role}
onValueChange={(v) => {
const gms = [...form.group_mappings];
gms[i] = { ...gms[i], role: v };
setForm((f) => ({ ...f, group_mappings: gms }));
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="domain_admin">Domain Admin</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
const gms = form.group_mappings.filter((_, j) => j !== i);
setForm((f) => ({ ...f, group_mappings: gms }));
}}
>
Entfernen
</Button>
</div>
))}
</div>
)}
</div>
{/* Test result */}
{testResult && (
<Card>
<CardContent className="pt-4 space-y-2">
<div className="flex items-center gap-2">
<Badge variant={testResult.ok ? "default" : "destructive"}>
{testResult.ok ? "Verbunden" : "Fehler"}
</Badge>
<span className="text-sm">{testResult.message}</span>
{testResult.latency_ms > 0 && (
<span className="text-xs text-muted-foreground">{testResult.latency_ms} ms</span>
)}
</div>
{testResult.server_info && (
<p className="text-xs text-muted-foreground font-mono">{testResult.server_info}</p>
)}
{testResult.users_found > 0 && (
<p className="text-sm">{testResult.users_found} Benutzer gefunden</p>
)}
{testResult.error_detail && (
<p className="text-xs text-destructive font-mono">{testResult.error_detail}</p>
)}
</CardContent>
</Card>
)}
{/* Action bar */}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={handleTest} disabled={testing || saving}>
{testing ? "Teste..." : "Verbindung testen"}
</Button>
<Button type="submit" disabled={saving || testing}>
{saving ? "Speichern..." : "Speichern"}
</Button>
{config && (
<Button
type="button"
variant="destructive"
disabled={saving}
onClick={handleDelete}
>
Konfiguration löschen
</Button>
)}
</div>
{config && (
<p className="text-xs text-muted-foreground">
Zuletzt geändert: {config.updated_at ? new Date(config.updated_at).toLocaleString("de-DE") : ""}
{config.updated_by ? ` von ${config.updated_by}` : ""}
</p>
)}
</form>
)}
</DialogContent>
</Dialog>
);
}