ac91dceac2
PROJ-22 – LDAP Web-GUI Konfiguration & Test: - internal/ldapconfig/store.go: AES-256-GCM Passwortspeicherung, CRUD Upsert (id=1) - internal/ldapauth/client.go: TestConnection (RootDSE, UserCount) + Authenticate (2-step bind) - internal/auth/auth.go: LDAP-Fallback in Login(), Gruppen-Rollenzuordnung, issueToken helper - internal/api/ldap_tenants.go: GET/PUT/DELETE/POST-test /api/admin/ldap mit Audit-Log - go.mod: github.com/go-ldap/ldap/v3 v3.4.8 hinzugefügt - Frontend: LDAPConfig/LDAPTestResult Typen, LDAP-Tab mit Gruppen-Mappings + Testergebnis PROJ-21 Phase 1+6+7 – Multi-Tenancy Grundstruktur: - internal/tenantstore/store.go: tenants, tenant_domains, tenant_ldap Schema; Migration users/audit_log - API: 8 Tenant-Routen (CRUD + Domain-Management) via SetTenants() - cmd/archivmail/main.go: ldapSt + tenantSt initialisiert - Frontend: Mandanten-Tab mit Tabelle, Domain-Dialog, Deaktivieren/Löschen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2199 lines
95 KiB
TypeScript
2199 lines
95 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, useRef } from "react";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { features, type Feature } from "@/data/features";
|
||
import {
|
||
getUsers,
|
||
createUser,
|
||
updateUser,
|
||
deleteUser,
|
||
getAuditLog,
|
||
getSMTPStatus,
|
||
getHealth,
|
||
getStorageStats,
|
||
getServices,
|
||
serviceAction,
|
||
getSystemStats,
|
||
uploadMailFiles,
|
||
getUploadProgress,
|
||
getSecurityAudit,
|
||
fixSecurityIssue,
|
||
getLDAPConfig,
|
||
saveLDAPConfig,
|
||
deleteLDAPConfig,
|
||
testLDAPConfig,
|
||
getTenants,
|
||
createTenant,
|
||
updateTenant,
|
||
deleteTenant,
|
||
getTenantDomains,
|
||
addTenantDomain,
|
||
removeTenantDomain,
|
||
type User,
|
||
type AuditEntry,
|
||
type SMTPStatus,
|
||
type StorageStats,
|
||
type ServiceStatus,
|
||
type SystemStats,
|
||
type UploadJob,
|
||
type SecurityCheck,
|
||
type SecurityAuditResult,
|
||
type LDAPConfig,
|
||
type LDAPTestResult,
|
||
type Tenant,
|
||
type TenantDomain,
|
||
} from "@/lib/api";
|
||
import { Navbar } from "@/components/navbar";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||
|
||
const AUDIT_PAGE_SIZE = 25;
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||
}
|
||
|
||
export default function AdminPage() {
|
||
const { user, loading: authLoading } = useAuth("admin");
|
||
|
||
// Dashboard state
|
||
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
|
||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
|
||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
||
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
|
||
const [dashLoading, setDashLoading] = useState(true);
|
||
const [dashRefreshed, setDashRefreshed] = useState<Date | null>(null);
|
||
const [countdown, setCountdown] = useState(30);
|
||
|
||
// Services state
|
||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||
const [servicesLoading, setServicesLoading] = useState(false);
|
||
const [serviceActionLoading, setServiceActionLoading] = useState<string | null>(null);
|
||
const [serviceError, setServiceError] = useState("");
|
||
|
||
// Users state
|
||
const [users, setUsers] = useState<User[]>([]);
|
||
const [usersLoading, setUsersLoading] = useState(true);
|
||
const [usersError, setUsersError] = useState("");
|
||
|
||
// Create user dialog
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [newUsername, setNewUsername] = useState("");
|
||
const [newEmail, setNewEmail] = useState("");
|
||
const [newPassword, setNewPassword] = useState("");
|
||
const [newRole, setNewRole] = useState("user");
|
||
const [createLoading, setCreateLoading] = useState(false);
|
||
const [createError, setCreateError] = useState("");
|
||
|
||
// User action state
|
||
const [userActionLoading, setUserActionLoading] = useState<number | null>(null);
|
||
const [resetPasswordUserId, setResetPasswordUserId] = useState<number | null>(null);
|
||
const [resetPasswordValue, setResetPasswordValue] = useState("");
|
||
const [resetPasswordError, setResetPasswordError] = useState("");
|
||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||
|
||
// Delete confirmation dialog
|
||
const [deleteDialogUser, setDeleteDialogUser] = useState<User | null>(null);
|
||
const [deleteActionLoading, setDeleteActionLoading] = useState<"deactivate" | "delete" | null>(null);
|
||
const [deleteDialogError, setDeleteDialogError] = useState("");
|
||
|
||
// Audit state
|
||
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
||
const [auditTotal, setAuditTotal] = useState(0);
|
||
const [auditPage, setAuditPage] = useState(1);
|
||
const [auditLoading, setAuditLoading] = useState(false);
|
||
|
||
// Security audit state
|
||
const [securityAudit, setSecurityAudit] = useState<SecurityAuditResult | null>(null);
|
||
const [securityLoading, setSecurityLoading] = useState(false);
|
||
const [securityError, setSecurityError] = useState("");
|
||
const [fixLoading, setFixLoading] = useState<string | null>(null);
|
||
const [fixMessage, setFixMessage] = useState("");
|
||
|
||
// Upload state
|
||
const [uploadDragging, setUploadDragging] = useState(false);
|
||
const [uploadJob, setUploadJob] = useState<UploadJob | null>(null);
|
||
const [uploadError, setUploadError] = useState("");
|
||
const [uploadLoading, setUploadLoading] = useState(false);
|
||
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
// LDAP state
|
||
const [ldapConfig, setLdapConfig] = useState<LDAPConfig | null>(null);
|
||
const [ldapLoading, setLdapLoading] = useState(false);
|
||
const [ldapSaving, setLdapSaving] = useState(false);
|
||
const [ldapTesting, setLdapTesting] = useState(false);
|
||
const [ldapError, setLdapError] = useState("");
|
||
const [ldapTestResult, setLdapTestResult] = useState<LDAPTestResult | null>(null);
|
||
const [ldapForm, setLdapForm] = useState<LDAPConfig>({
|
||
enabled: false,
|
||
url: "ldap://",
|
||
bind_dn: "",
|
||
bind_password: "",
|
||
base_dn: "",
|
||
user_filter: "(sAMAccountName=%s)",
|
||
tls: false,
|
||
tls_skip_verify: false,
|
||
default_role: "user",
|
||
group_mappings: [],
|
||
});
|
||
const [ldapChangePassword, setLdapChangePassword] = useState(false);
|
||
|
||
// Tenants state
|
||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||
const [tenantsLoading, setTenantsLoading] = useState(false);
|
||
const [tenantsError, setTenantsError] = useState("");
|
||
const [tenantDialogOpen, setTenantDialogOpen] = useState(false);
|
||
const [newTenantName, setNewTenantName] = useState("");
|
||
const [newTenantSlug, setNewTenantSlug] = useState("");
|
||
const [tenantCreateLoading, setTenantCreateLoading] = useState(false);
|
||
const [tenantCreateError, setTenantCreateError] = useState("");
|
||
const [tenantDeleteId, setTenantDeleteId] = useState<number | null>(null);
|
||
const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false);
|
||
const [domainDialogTenant, setDomainDialogTenant] = useState<Tenant | null>(null);
|
||
const [tenantDomains, setTenantDomains] = useState<TenantDomain[]>([]);
|
||
const [domainsLoading, setDomainsLoading] = useState(false);
|
||
const [newDomain, setNewDomain] = useState("");
|
||
const [addDomainLoading, setAddDomainLoading] = useState(false);
|
||
const [domainError, setDomainError] = useState("");
|
||
|
||
const loadDashboard = useCallback(async () => {
|
||
setDashLoading(true);
|
||
try {
|
||
const [smtp, health, storage, sysStats] = await Promise.allSettled([
|
||
getSMTPStatus(),
|
||
getHealth(),
|
||
getStorageStats(),
|
||
getSystemStats(),
|
||
]);
|
||
setSmtpStatus(smtp.status === "fulfilled" ? smtp.value : null);
|
||
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
|
||
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
|
||
setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null);
|
||
setDashRefreshed(new Date());
|
||
} finally {
|
||
setDashLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadUsers = useCallback(async () => {
|
||
setUsersLoading(true);
|
||
setUsersError("");
|
||
try {
|
||
const data = await getUsers();
|
||
setUsers(data || []);
|
||
} catch {
|
||
setUsersError("Benutzer konnten nicht geladen werden.");
|
||
} finally {
|
||
setUsersLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadAudit = useCallback(async (p: number) => {
|
||
setAuditLoading(true);
|
||
try {
|
||
const data = await getAuditLog({ page: p, page_size: AUDIT_PAGE_SIZE });
|
||
setAuditEntries(data.entries || []);
|
||
setAuditTotal(data.total);
|
||
setAuditPage(p);
|
||
} catch {
|
||
setAuditEntries([]);
|
||
} finally {
|
||
setAuditLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadServices = useCallback(async () => {
|
||
setServicesLoading(true);
|
||
setServiceError("");
|
||
try {
|
||
const data = await getServices();
|
||
setServices(data || []);
|
||
} catch {
|
||
setServiceError("Dienste konnten nicht abgerufen werden.");
|
||
} finally {
|
||
setServicesLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
async function handleUploadFiles(files: File[]) {
|
||
const valid = files.filter(f => f.name.toLowerCase().endsWith(".eml") || f.name.toLowerCase().endsWith(".mbox"));
|
||
if (valid.length === 0) {
|
||
setUploadError("Nur .eml und .mbox Dateien erlaubt.");
|
||
return;
|
||
}
|
||
setUploadError("");
|
||
setUploadJob(null);
|
||
setUploadLoading(true);
|
||
try {
|
||
const { job_id } = await uploadMailFiles(valid);
|
||
// Start polling
|
||
const poll = setInterval(async () => {
|
||
try {
|
||
const job = await getUploadProgress(job_id);
|
||
setUploadJob(job);
|
||
if (job.status !== "running") {
|
||
clearInterval(poll);
|
||
uploadPollRef.current = null;
|
||
setUploadLoading(false);
|
||
}
|
||
} catch {
|
||
clearInterval(poll);
|
||
uploadPollRef.current = null;
|
||
setUploadLoading(false);
|
||
}
|
||
}, 1500);
|
||
uploadPollRef.current = poll;
|
||
} catch (e: unknown) {
|
||
setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen.");
|
||
setUploadLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleServiceAction(name: string, action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external") {
|
||
setServiceActionLoading(`${name}:${action}`);
|
||
setServiceError("");
|
||
try {
|
||
const updated = await serviceAction(name, action);
|
||
setServices((prev) => prev.map((s) => (s.name === updated.name ? updated : s)));
|
||
} catch (e: unknown) {
|
||
setServiceError(e instanceof Error ? e.message : "Aktion fehlgeschlagen.");
|
||
} finally {
|
||
setServiceActionLoading(null);
|
||
}
|
||
}
|
||
|
||
const dashIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
loadDashboard();
|
||
loadUsers();
|
||
loadAudit(1);
|
||
loadServices();
|
||
|
||
// Auto-Refresh Dashboard alle 30 Sekunden
|
||
setCountdown(30);
|
||
dashIntervalRef.current = setInterval(() => {
|
||
loadDashboard();
|
||
setCountdown(30);
|
||
}, 30_000);
|
||
|
||
// Countdown-Ticker
|
||
const ticker = setInterval(() => {
|
||
setCountdown((c) => (c > 0 ? c - 1 : 0));
|
||
}, 1_000);
|
||
|
||
return () => {
|
||
if (dashIntervalRef.current) clearInterval(dashIntervalRef.current);
|
||
clearInterval(ticker);
|
||
};
|
||
}, [user, loadDashboard, loadUsers, loadAudit, loadServices]);
|
||
|
||
async function handleCreateUser(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setCreateLoading(true);
|
||
setCreateError("");
|
||
try {
|
||
await createUser({
|
||
username: newUsername,
|
||
email: newEmail,
|
||
password: newPassword,
|
||
role: newRole,
|
||
});
|
||
setDialogOpen(false);
|
||
setNewUsername("");
|
||
setNewEmail("");
|
||
setNewPassword("");
|
||
setNewRole("user");
|
||
loadUsers();
|
||
} catch {
|
||
setCreateError("Benutzer konnte nicht erstellt werden.");
|
||
} finally {
|
||
setCreateLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleToggleActive(u: User) {
|
||
setUserActionLoading(u.id);
|
||
try {
|
||
await updateUser(u.id, { active: !u.active });
|
||
loadUsers();
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setUserActionLoading(null);
|
||
}
|
||
}
|
||
|
||
async function handleDeactivateConfirmed() {
|
||
if (!deleteDialogUser) return;
|
||
setDeleteActionLoading("deactivate");
|
||
setDeleteDialogError("");
|
||
try {
|
||
await updateUser(deleteDialogUser.id, { active: false });
|
||
setDeleteDialogUser(null);
|
||
loadUsers();
|
||
} catch {
|
||
setDeleteDialogError("Deaktivierung fehlgeschlagen.");
|
||
} finally {
|
||
setDeleteActionLoading(null);
|
||
}
|
||
}
|
||
|
||
async function handleDeleteConfirmed() {
|
||
if (!deleteDialogUser) return;
|
||
setDeleteActionLoading("delete");
|
||
setDeleteDialogError("");
|
||
try {
|
||
await deleteUser(deleteDialogUser.id);
|
||
setDeleteDialogUser(null);
|
||
loadUsers();
|
||
} catch (err: unknown) {
|
||
setDeleteDialogError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
|
||
} finally {
|
||
setDeleteActionLoading(null);
|
||
}
|
||
}
|
||
|
||
async function handleResetPassword(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!resetPasswordUserId) return;
|
||
setResetPasswordLoading(true);
|
||
setResetPasswordError("");
|
||
try {
|
||
await updateUser(resetPasswordUserId, { password: resetPasswordValue });
|
||
setResetPasswordUserId(null);
|
||
setResetPasswordValue("");
|
||
} catch {
|
||
setResetPasswordError("Passwort konnte nicht geändert werden.");
|
||
} finally {
|
||
setResetPasswordLoading(false);
|
||
}
|
||
}
|
||
|
||
async function runSecurityAudit() {
|
||
setSecurityLoading(true);
|
||
setSecurityError("");
|
||
setFixMessage("");
|
||
try {
|
||
const result = await getSecurityAudit();
|
||
setSecurityAudit(result);
|
||
} catch {
|
||
setSecurityError("Security-Audit konnte nicht ausgeführt werden.");
|
||
} finally {
|
||
setSecurityLoading(false);
|
||
}
|
||
}
|
||
|
||
async function runFix(action: string) {
|
||
setFixLoading(action);
|
||
setFixMessage("");
|
||
setSecurityError("");
|
||
try {
|
||
const res = await fixSecurityIssue(action);
|
||
setFixMessage(res.message);
|
||
// Re-run audit automatically to reflect new state
|
||
await runSecurityAudit();
|
||
} catch (e: unknown) {
|
||
setSecurityError(e instanceof Error ? e.message : "Fix fehlgeschlagen.");
|
||
} finally {
|
||
setFixLoading(null);
|
||
}
|
||
}
|
||
|
||
// LDAP handlers
|
||
const loadLDAP = useCallback(async () => {
|
||
setLdapLoading(true);
|
||
setLdapError("");
|
||
try {
|
||
const cfg = await getLDAPConfig();
|
||
if (cfg) {
|
||
setLdapConfig(cfg);
|
||
setLdapForm({ ...cfg, bind_password: "" });
|
||
setLdapChangePassword(false);
|
||
}
|
||
} catch {
|
||
setLdapError("LDAP-Konfiguration konnte nicht geladen werden.");
|
||
} finally {
|
||
setLdapLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
async function handleSaveLDAP(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setLdapSaving(true);
|
||
setLdapError("");
|
||
try {
|
||
const payload: Partial<LDAPConfig> = { ...ldapForm };
|
||
if (!ldapChangePassword) {
|
||
delete payload.bind_password;
|
||
}
|
||
await saveLDAPConfig(payload);
|
||
await loadLDAP();
|
||
} catch (err: unknown) {
|
||
setLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
|
||
} finally {
|
||
setLdapSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleTestLDAP() {
|
||
setLdapTesting(true);
|
||
setLdapError("");
|
||
setLdapTestResult(null);
|
||
try {
|
||
const payload = ldapConfig
|
||
? { use_saved: true }
|
||
: { use_saved: false, ...ldapForm };
|
||
const result = await testLDAPConfig(payload as Parameters<typeof testLDAPConfig>[0]);
|
||
setLdapTestResult(result);
|
||
} catch (err: unknown) {
|
||
setLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen.");
|
||
} finally {
|
||
setLdapTesting(false);
|
||
}
|
||
}
|
||
|
||
async function handleDeleteLDAP() {
|
||
setLdapSaving(true);
|
||
setLdapError("");
|
||
try {
|
||
await deleteLDAPConfig();
|
||
setLdapConfig(null);
|
||
setLdapForm({
|
||
enabled: false, url: "ldap://", bind_dn: "", bind_password: "",
|
||
base_dn: "", user_filter: "(sAMAccountName=%s)", tls: false,
|
||
tls_skip_verify: false, default_role: "user", group_mappings: [],
|
||
});
|
||
setLdapTestResult(null);
|
||
} catch (err: unknown) {
|
||
setLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
|
||
} finally {
|
||
setLdapSaving(false);
|
||
}
|
||
}
|
||
|
||
// Tenants handlers
|
||
const loadTenants = useCallback(async () => {
|
||
setTenantsLoading(true);
|
||
setTenantsError("");
|
||
try {
|
||
const data = await getTenants();
|
||
setTenants(data || []);
|
||
} catch {
|
||
setTenantsError("Mandanten konnten nicht geladen werden.");
|
||
} finally {
|
||
setTenantsLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
async function handleCreateTenant(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setTenantCreateLoading(true);
|
||
setTenantCreateError("");
|
||
try {
|
||
await createTenant(newTenantName, newTenantSlug);
|
||
setTenantDialogOpen(false);
|
||
setNewTenantName("");
|
||
setNewTenantSlug("");
|
||
await loadTenants();
|
||
} catch (err: unknown) {
|
||
setTenantCreateError(err instanceof Error ? err.message : "Erstellen fehlgeschlagen.");
|
||
} finally {
|
||
setTenantCreateLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleToggleTenant(t: Tenant) {
|
||
try {
|
||
await updateTenant(t.id, { active: !t.active });
|
||
await loadTenants();
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function handleDeleteTenant() {
|
||
if (!tenantDeleteId) return;
|
||
setTenantDeleteLoading(true);
|
||
try {
|
||
await deleteTenant(tenantDeleteId);
|
||
setTenantDeleteId(null);
|
||
await loadTenants();
|
||
} catch { /* ignore */ } finally {
|
||
setTenantDeleteLoading(false);
|
||
}
|
||
}
|
||
|
||
async function openDomainDialog(t: Tenant) {
|
||
setDomainDialogTenant(t);
|
||
setDomainsLoading(true);
|
||
setDomainError("");
|
||
setNewDomain("");
|
||
try {
|
||
const domains = await getTenantDomains(t.id);
|
||
setTenantDomains(domains || []);
|
||
} catch { setDomainError("Domains konnten nicht geladen werden."); }
|
||
finally { setDomainsLoading(false); }
|
||
}
|
||
|
||
async function handleAddDomain() {
|
||
if (!domainDialogTenant || !newDomain) return;
|
||
setAddDomainLoading(true);
|
||
setDomainError("");
|
||
try {
|
||
await addTenantDomain(domainDialogTenant.id, newDomain);
|
||
setNewDomain("");
|
||
const domains = await getTenantDomains(domainDialogTenant.id);
|
||
setTenantDomains(domains || []);
|
||
} catch (err: unknown) {
|
||
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht hinzugefügt werden.");
|
||
} finally {
|
||
setAddDomainLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleRemoveDomain(domainId: number) {
|
||
if (!domainDialogTenant) return;
|
||
setDomainError("");
|
||
try {
|
||
await removeTenantDomain(domainDialogTenant.id, domainId);
|
||
const domains = await getTenantDomains(domainDialogTenant.id);
|
||
setTenantDomains(domains || []);
|
||
} catch (err: unknown) {
|
||
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht entfernt werden.");
|
||
}
|
||
}
|
||
|
||
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||
|
||
return (
|
||
<div className="min-h-screen">
|
||
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
||
<main className="mx-auto max-w-7xl px-4 py-6">
|
||
{(authLoading || !user) ? (
|
||
<div className="space-y-4">
|
||
<Skeleton className="h-8 w-48" />
|
||
<Skeleton className="h-10 w-full max-w-sm" />
|
||
<Skeleton className="h-64 w-full" />
|
||
</div>
|
||
) : (<>
|
||
<h1 className="mb-6 text-2xl font-bold">Administration</h1>
|
||
|
||
<Tabs defaultValue="dashboard">
|
||
<TabsList>
|
||
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||
<TabsTrigger value="services">Dienste</TabsTrigger>
|
||
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
||
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
||
<TabsTrigger value="import">Import</TabsTrigger>
|
||
<TabsTrigger value="ldap" onClick={loadLDAP}>LDAP</TabsTrigger>
|
||
<TabsTrigger value="security">Security</TabsTrigger>
|
||
<TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>
|
||
<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 */}
|
||
<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 ? `${smtpStatus.max_size_mb} MB` : "50 MB"}</span>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">Nicht erreichbar</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* SMTP Statistik (nur live via SMTP-Daemon) */}
|
||
<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}</span>
|
||
<span className="text-muted-foreground">Abgelehnt</span>
|
||
<span className="font-semibold text-red-500">{smtpStatus.rejected}</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>
|
||
|
||
{/* 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: CPU, RAM, Disks, Archivzeitraum */}
|
||
<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 */}
|
||
{smtpStatus && smtpStatus.allowed_ips?.length > 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>
|
||
<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>
|
||
<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="admin">Admin</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 “Jetzt prüfen” um den Security-Audit zu starten.
|
||
</div>
|
||
)}
|
||
|
||
{securityLoading && (
|
||
<div className="space-y-3">
|
||
{Array.from({ length: 7 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{securityAudit && !securityLoading && (() => {
|
||
// Map check names to fix actions
|
||
const fixActions: Record<string, { action: string; label: string }> = {
|
||
"Fail2ban": { action: "install_fail2ban", label: "Installieren & aktivieren" },
|
||
"Firewall (nftables)": { action: "enable_firewall", label: "Firewall aktivieren" },
|
||
"SSH Passwort-Auth": { action: "fix_ssh_password_auth", label: "Deaktivieren" },
|
||
"SSH Root-Login": { action: "fix_ssh_root_login", label: "Auf prohibit-password setzen" },
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{securityAudit.checks.map((check: SecurityCheck, i: number) => {
|
||
const fix = check.status !== "ok" ? fixActions[check.name] : undefined;
|
||
return (
|
||
<Card key={i}>
|
||
<CardContent className="p-4 flex items-start gap-3">
|
||
<span className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
|
||
check.status === "ok" ? "bg-green-500" :
|
||
check.status === "warning" ? "bg-yellow-400" : "bg-red-500"
|
||
}`} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-sm font-medium">{check.name}</span>
|
||
<Badge
|
||
variant={check.status === "ok" ? "default" : check.status === "warning" ? "secondary" : "destructive"}
|
||
className={`text-xs ${check.status === "ok" ? "bg-green-100 text-green-800 hover:bg-green-100" : check.status === "warning" ? "bg-yellow-100 text-yellow-800 hover:bg-yellow-100" : ""}`}
|
||
>
|
||
{check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"}
|
||
</Badge>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{check.message}</p>
|
||
</div>
|
||
{fix && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="flex-shrink-0 text-xs"
|
||
disabled={fixLoading !== null}
|
||
onClick={() => runFix(fix.action)}
|
||
>
|
||
{fixLoading === fix.action ? "Wird behoben..." : fix.label}
|
||
</Button>
|
||
)}
|
||
{check.name === "HTTPS (TLS)" && check.status !== "ok" && (
|
||
<a
|
||
href="https://certbot.eff.org/instructions?os=debianbuster&tab=standard"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<Button size="sm" variant="outline" className="flex-shrink-0 text-xs">
|
||
Anleitung
|
||
</Button>
|
||
</a>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
|
||
{/* Summary */}
|
||
<div className="mt-4 grid grid-cols-3 gap-3 text-center text-sm">
|
||
{[
|
||
{ label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length },
|
||
{ label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length },
|
||
{ label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length },
|
||
].map((s) => (
|
||
<div key={s.label} className={`rounded p-3 ${s.color}`}>
|
||
<p className="text-2xl font-bold">{s.count}</p>
|
||
<p className="text-xs">{s.label}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</TabsContent>
|
||
|
||
{/* ── LDAP ── */}
|
||
<TabsContent value="ldap" className="mt-4 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">LDAP / Active Directory</h2>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-muted-foreground">LDAP aktiviert</span>
|
||
<input
|
||
type="checkbox"
|
||
className="h-4 w-4"
|
||
checked={ldapForm.enabled}
|
||
onChange={(e) => setLdapForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{ldapError && (
|
||
<Alert variant="destructive">
|
||
<AlertDescription>{ldapError}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{ldapLoading ? (
|
||
<div className="space-y-3">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-10 w-full" />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<form onSubmit={handleSaveLDAP} className="space-y-4">
|
||
<Card>
|
||
<CardContent className="pt-6 space-y-4">
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="ldap-url">Server-URL</Label>
|
||
<Input
|
||
id="ldap-url"
|
||
placeholder="ldap://dc.example.com:389"
|
||
value={ldapForm.url}
|
||
onChange={(e) => setLdapForm((f) => ({ ...f, url: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="ldap-bind-dn">Bind-DN (Service-Account)</Label>
|
||
<Input
|
||
id="ldap-bind-dn"
|
||
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
|
||
value={ldapForm.bind_dn}
|
||
onChange={(e) => setLdapForm((f) => ({ ...f, bind_dn: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="ldap-pw">Bind-Passwort</Label>
|
||
{ldapConfig && !ldapChangePassword ? (
|
||
<div className="flex gap-2">
|
||
<Input id="ldap-pw" type="password" value="••••••" readOnly className="flex-1" />
|
||
<Button type="button" variant="outline" size="sm" onClick={() => setLdapChangePassword(true)}>
|
||
Ändern
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<Input
|
||
id="ldap-pw"
|
||
type="password"
|
||
placeholder="Neues Passwort eingeben"
|
||
value={ldapForm.bind_password}
|
||
onChange={(e) => setLdapForm((f) => ({ ...f, bind_password: e.target.value }))}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="ldap-base-dn">Base-DN</Label>
|
||
<Input
|
||
id="ldap-base-dn"
|
||
placeholder="DC=example,DC=com"
|
||
value={ldapForm.base_dn}
|
||
onChange={(e) => setLdapForm((f) => ({ ...f, base_dn: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="ldap-filter">User-Filter</Label>
|
||
<Input
|
||
id="ldap-filter"
|
||
placeholder="(sAMAccountName=%s)"
|
||
value={ldapForm.user_filter}
|
||
onChange={(e) => setLdapForm((f) => ({ ...f, user_filter: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="ldap-role">Standard-Rolle</Label>
|
||
<Select
|
||
value={ldapForm.default_role}
|
||
onValueChange={(v) => setLdapForm((f) => ({ ...f, default_role: v }))}
|
||
>
|
||
<SelectTrigger id="ldap-role">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="user">User</SelectItem>
|
||
<SelectItem value="auditor">Auditor</SelectItem>
|
||
<SelectItem value="admin">Admin</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className="flex flex-wrap gap-6">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
className="h-4 w-4"
|
||
checked={ldapForm.tls}
|
||
onChange={(e) => setLdapForm((f) => ({ ...f, tls: e.target.checked }))}
|
||
/>
|
||
STARTTLS verwenden
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
className="h-4 w-4"
|
||
checked={ldapForm.tls_skip_verify}
|
||
onChange={(e) => setLdapForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
|
||
/>
|
||
TLS-Zertifikat ignorieren
|
||
{ldapForm.tls_skip_verify && (
|
||
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
|
||
)}
|
||
</label>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* Group mappings */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label>Gruppen-Rollenzuordnung</Label>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() =>
|
||
setLdapForm((f) => ({
|
||
...f,
|
||
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
|
||
}))
|
||
}
|
||
>
|
||
+ Hinzufügen
|
||
</Button>
|
||
</div>
|
||
{ldapForm.group_mappings.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{ldapForm.group_mappings.map((gm, i) => (
|
||
<div key={i} className="flex gap-2 items-center">
|
||
<Input
|
||
className="flex-1"
|
||
placeholder="CN=Archivadmins,OU=Groups,DC=example,DC=com"
|
||
value={gm.group_dn}
|
||
onChange={(e) => {
|
||
const gms = [...ldapForm.group_mappings];
|
||
gms[i] = { ...gms[i], group_dn: e.target.value };
|
||
setLdapForm((f) => ({ ...f, group_mappings: gms }));
|
||
}}
|
||
/>
|
||
<Select
|
||
value={gm.role}
|
||
onValueChange={(v) => {
|
||
const gms = [...ldapForm.group_mappings];
|
||
gms[i] = { ...gms[i], role: v };
|
||
setLdapForm((f) => ({ ...f, group_mappings: gms }));
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-32">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="user">User</SelectItem>
|
||
<SelectItem value="auditor">Auditor</SelectItem>
|
||
<SelectItem value="admin">Admin</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
size="sm"
|
||
onClick={() => {
|
||
const gms = ldapForm.group_mappings.filter((_, j) => j !== i);
|
||
setLdapForm((f) => ({ ...f, group_mappings: gms }));
|
||
}}
|
||
>
|
||
Entfernen
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Test result */}
|
||
{ldapTestResult && (
|
||
<Card>
|
||
<CardContent className="pt-4 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant={ldapTestResult.ok ? "default" : "destructive"}>
|
||
{ldapTestResult.ok ? "Verbunden" : "Fehler"}
|
||
</Badge>
|
||
<span className="text-sm">{ldapTestResult.message}</span>
|
||
{ldapTestResult.latency_ms > 0 && (
|
||
<span className="text-xs text-muted-foreground">{ldapTestResult.latency_ms} ms</span>
|
||
)}
|
||
</div>
|
||
{ldapTestResult.server_info && (
|
||
<p className="text-xs text-muted-foreground font-mono">{ldapTestResult.server_info}</p>
|
||
)}
|
||
{ldapTestResult.users_found > 0 && (
|
||
<p className="text-sm">{ldapTestResult.users_found} Benutzer gefunden</p>
|
||
)}
|
||
{ldapTestResult.error_detail && (
|
||
<p className="text-xs text-destructive font-mono">{ldapTestResult.error_detail}</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Action bar */}
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button type="button" variant="outline" onClick={handleTestLDAP} disabled={ldapTesting || ldapSaving}>
|
||
{ldapTesting ? "Teste..." : "Verbindung testen"}
|
||
</Button>
|
||
<Button type="submit" disabled={ldapSaving || ldapTesting}>
|
||
{ldapSaving ? "Speichern..." : "Speichern"}
|
||
</Button>
|
||
{ldapConfig && (
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
disabled={ldapSaving}
|
||
onClick={handleDeleteLDAP}
|
||
>
|
||
Konfiguration löschen
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{ldapConfig && (
|
||
<p className="text-xs text-muted-foreground">
|
||
Zuletzt geändert: {ldapConfig.updated_at ? new Date(ldapConfig.updated_at).toLocaleString("de-DE") : "–"}
|
||
{ldapConfig.updated_by ? ` von ${ldapConfig.updated_by}` : ""}
|
||
</p>
|
||
)}
|
||
</TabsContent>
|
||
|
||
{/* ── Mandanten ── */}
|
||
<TabsContent value="tenants" className="mt-4 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">Mandantenverwaltung</h2>
|
||
<Dialog open={tenantDialogOpen} onOpenChange={setTenantDialogOpen}>
|
||
<DialogTrigger asChild>
|
||
<Button size="sm">+ Mandant anlegen</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Neuen Mandanten anlegen</DialogTitle>
|
||
<DialogDescription>Name und URL-Slug für den neuen Mandanten eingeben.</DialogDescription>
|
||
</DialogHeader>
|
||
<form onSubmit={handleCreateTenant} className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="tenant-name">Name</Label>
|
||
<Input
|
||
id="tenant-name"
|
||
value={newTenantName}
|
||
required
|
||
onChange={(e) => {
|
||
setNewTenantName(e.target.value);
|
||
setNewTenantSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="tenant-slug">Slug</Label>
|
||
<Input
|
||
id="tenant-slug"
|
||
value={newTenantSlug}
|
||
required
|
||
onChange={(e) => setNewTenantSlug(e.target.value)}
|
||
/>
|
||
</div>
|
||
{tenantCreateError && (
|
||
<p className="text-sm text-destructive">{tenantCreateError}</p>
|
||
)}
|
||
<DialogFooter>
|
||
<Button type="submit" disabled={tenantCreateLoading}>
|
||
{tenantCreateLoading ? "Erstellen..." : "Erstellen"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
|
||
{tenantsError && (
|
||
<Alert variant="destructive">
|
||
<AlertDescription>{tenantsError}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{tenantsLoading ? (
|
||
<Card>
|
||
<CardContent className="p-4 space-y-3">
|
||
{Array.from({ length: 4 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-10 w-full" />
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
) : tenants.length === 0 ? (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Keine Mandanten vorhanden. Klicke auf “+ Mandant anlegen” um den ersten Mandanten zu erstellen.
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<Card>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Name</TableHead>
|
||
<TableHead>Slug</TableHead>
|
||
<TableHead className="text-center">Domains</TableHead>
|
||
<TableHead className="text-center">Nutzer</TableHead>
|
||
<TableHead>Status</TableHead>
|
||
<TableHead className="text-right">Aktionen</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{tenants.map((t) => (
|
||
<TableRow key={t.id}>
|
||
<TableCell className="font-medium">{t.name}</TableCell>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">{t.slug}</TableCell>
|
||
<TableCell className="text-center">{t.domain_count ?? 0}</TableCell>
|
||
<TableCell className="text-center">{t.user_count ?? 0}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={t.active ? "default" : "secondary"}>
|
||
{t.active ? "Aktiv" : "Inaktiv"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex justify-end gap-1">
|
||
<Button size="sm" variant="outline" onClick={() => openDomainDialog(t)}>
|
||
Domains
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}>
|
||
{t.active ? "Deaktivieren" : "Aktivieren"}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
onClick={() => setTenantDeleteId(t.id)}
|
||
>
|
||
Löschen
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Tenant delete confirmation */}
|
||
<Dialog open={tenantDeleteId !== null} onOpenChange={(open) => { if (!open) setTenantDeleteId(null); }}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Mandant löschen</DialogTitle>
|
||
<DialogDescription>
|
||
Alle Daten dieses Mandanten (Domains, LDAP-Konfiguration) werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setTenantDeleteId(null)}>Abbrechen</Button>
|
||
<Button variant="destructive" disabled={tenantDeleteLoading} onClick={handleDeleteTenant}>
|
||
{tenantDeleteLoading ? "Löschen..." : "Endgültig löschen"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Domain management dialog */}
|
||
<Dialog open={domainDialogTenant !== null} onOpenChange={(open) => { if (!open) setDomainDialogTenant(null); }}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Domains: {domainDialogTenant?.name}</DialogTitle>
|
||
<DialogDescription>E-Mail-Domains diesem Mandanten zuweisen.</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{domainError && (
|
||
<Alert variant="destructive">
|
||
<AlertDescription>{domainError}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{domainsLoading ? (
|
||
<Skeleton className="h-24 w-full" />
|
||
) : (
|
||
<div className="space-y-2">
|
||
{tenantDomains.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">Keine Domains zugewiesen.</p>
|
||
) : (
|
||
tenantDomains.map((d) => (
|
||
<div key={d.id} className="flex items-center justify-between rounded border px-3 py-2">
|
||
<span className="font-mono text-sm">{d.domain}</span>
|
||
<Button size="sm" variant="destructive" onClick={() => handleRemoveDomain(d.id)}>
|
||
Entfernen
|
||
</Button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2 pt-2">
|
||
<Input
|
||
placeholder="example.com"
|
||
value={newDomain}
|
||
onChange={(e) => setNewDomain(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddDomain(); } }}
|
||
/>
|
||
<Button onClick={handleAddDomain} disabled={addDomainLoading || !newDomain}>
|
||
{addDomainLoading ? "..." : "Hinzufügen"}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="modules" className="mt-4">
|
||
<ModulesTab />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</>)}
|
||
</main>
|
||
|
||
{/* Passwort-Reset Dialog */}
|
||
<Dialog open={resetPasswordUserId !== null} onOpenChange={(open) => { if (!open) setResetPasswordUserId(null); }}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Passwort zurücksetzen</DialogTitle>
|
||
<DialogDescription>
|
||
Neues Passwort für den Benutzer festlegen.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<form onSubmit={handleResetPassword} className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="new-password">Neues Passwort</Label>
|
||
<Input
|
||
id="new-password"
|
||
type="password"
|
||
value={resetPasswordValue}
|
||
onChange={(e) => setResetPasswordValue(e.target.value)}
|
||
required
|
||
minLength={8}
|
||
placeholder="Mindestens 8 Zeichen"
|
||
/>
|
||
</div>
|
||
{resetPasswordError && (
|
||
<p className="text-sm text-destructive">{resetPasswordError}</p>
|
||
)}
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => setResetPasswordUserId(null)}>
|
||
Abbrechen
|
||
</Button>
|
||
<Button type="submit" disabled={resetPasswordLoading}>
|
||
{resetPasswordLoading ? "Speichern..." : "Speichern"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Nutzer-Löschung Dialog */}
|
||
<Dialog open={deleteDialogUser !== null} onOpenChange={(open) => { if (!open) setDeleteDialogUser(null); }}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Benutzer entfernen</DialogTitle>
|
||
<DialogDescription>
|
||
Was soll mit dem Konto <strong>{deleteDialogUser?.username}</strong> passieren?
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800">
|
||
<strong>Hinweis (GoBD):</strong> E-Mails bleiben unabhängig von dieser Aktion im Archiv erhalten. Die gesetzliche Aufbewahrungspflicht besteht auch nach Ausscheiden des Mitarbeiters.
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="rounded-md border p-3">
|
||
<p className="font-medium text-sm">Konto deaktivieren (empfohlen)</p>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Login wird gesperrt. Konto und IMAP-Verbindungen bleiben erhalten und können reaktiviert werden.
|
||
</p>
|
||
</div>
|
||
<div className="rounded-md border border-destructive/30 p-3">
|
||
<p className="font-medium text-sm text-destructive">Konto endgültig löschen</p>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Account und alle IMAP-Verbindungen werden dauerhaft entfernt. Nicht rückgängig zu machen.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{deleteDialogError && (
|
||
<p className="text-sm text-destructive">{deleteDialogError}</p>
|
||
)}
|
||
|
||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||
<Button variant="outline" onClick={() => setDeleteDialogUser(null)} disabled={deleteActionLoading !== null}>
|
||
Abbrechen
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleDeactivateConfirmed}
|
||
disabled={deleteActionLoading !== null || deleteDialogUser?.active === false}
|
||
>
|
||
{deleteActionLoading === "deactivate" ? "Wird deaktiviert..." : "Deaktivieren"}
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={handleDeleteConfirmed}
|
||
disabled={deleteActionLoading !== null}
|
||
>
|
||
{deleteActionLoading === "delete" ? "Wird gelöscht..." : "Endgültig löschen"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Module Tab ─────────────────────────────────────────────────────────────
|
||
|
||
const statusColors: Record<string, string> = {
|
||
"Planned": "bg-gray-100 text-gray-700",
|
||
"In Progress": "bg-yellow-100 text-yellow-800",
|
||
"In Review": "bg-blue-100 text-blue-800",
|
||
"Deployed": "bg-green-100 text-green-800",
|
||
};
|
||
|
||
const statusCounts = (list: Feature[]) => ({
|
||
total: list.length,
|
||
planned: list.filter((f) => f.status === "Planned").length,
|
||
inProgress: list.filter((f) => f.status === "In Progress").length,
|
||
inReview: list.filter((f) => f.status === "In Review").length,
|
||
deployed: list.filter((f) => f.status === "Deployed").length,
|
||
});
|
||
|
||
function ModulesTab() {
|
||
const counts = statusCounts(features);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<h2 className="text-lg font-semibold">Modulübersicht</h2>
|
||
|
||
{/* Summary bar */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
{[
|
||
{ label: "In Progress", value: counts.inProgress, color: "bg-yellow-100 text-yellow-800" },
|
||
{ label: "In Review", value: counts.inReview, color: "bg-blue-100 text-blue-800" },
|
||
{ label: "Deployed", value: counts.deployed, color: "bg-green-100 text-green-800" },
|
||
{ label: "Geplant", value: counts.planned, color: "bg-gray-100 text-gray-700" },
|
||
].map((s) => (
|
||
<Card key={s.label}>
|
||
<CardContent className="p-4 flex items-center justify-between">
|
||
<span className="text-sm text-muted-foreground">{s.label}</span>
|
||
<span className={`text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
|
||
{s.value}
|
||
</span>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<Card>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-20">ID</TableHead>
|
||
<TableHead>Feature</TableHead>
|
||
<TableHead className="w-32">Status</TableHead>
|
||
<TableHead className="w-24 text-center">Frontend</TableHead>
|
||
<TableHead className="w-24 text-center">Backend</TableHead>
|
||
<TableHead className="w-32">Aktualisiert</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{features.map((f) => (
|
||
<TableRow key={f.id}>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||
{f.id}
|
||
</TableCell>
|
||
<TableCell className="font-medium">{f.name}</TableCell>
|
||
<TableCell>
|
||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
|
||
{f.status}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="text-center">
|
||
{f.frontend
|
||
? <span className="text-green-600 font-bold">✓</span>
|
||
: <span className="text-muted-foreground">–</span>
|
||
}
|
||
</TableCell>
|
||
<TableCell className="text-center">
|
||
{f.backend
|
||
? <span className="text-green-600 font-bold">✓</span>
|
||
: <span className="text-muted-foreground">–</span>
|
||
}
|
||
</TableCell>
|
||
<TableCell className="text-sm text-muted-foreground">
|
||
{f.lastUpdated}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|