diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d56403b..fff9efc 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,57 +1,19 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useEffect } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useLDAPConfig } from "@/hooks/useLDAPConfig"; import { useTenantLDAPConfig } from "@/hooks/useTenantLDAPConfig"; import { useTenantUsers } from "@/hooks/useTenantUsers"; -import { - getUsers, - createUser, - updateUser, - deleteUser, - getAuditLog, - getSMTPStatus, - getHealth, - getStorageStats, - getServices, - serviceAction, - getSystemStats, - getMailTimeseries, - uploadMailFiles, - getUploadProgress, - getSecurityAudit, - fixSecurityIssue, - getTenants, - createTenant, - updateTenant, - deleteTenant, - getTenantDomains, - addTenantDomain, - removeTenantDomain, - getTenantLogoUrl, - uploadTenantLogo, - deleteTenantLogo, - uploadMyTenantLogo, - deleteMyTenantLogo, - type User, - type AuditEntry, - type SMTPStatus, - type StorageStats, - type ServiceStatus, - type SystemStats, - type TimeseriesPoint, - type UploadJob, - type SecurityAuditResult, - type Tenant, - type TenantDefaultUser, - type TenantDomain, - getCertInfo, - uploadCert, - generateSelfSignedCert, - requestACMECert, - type CertInfo, -} from "@/lib/api"; +import { useAdminDashboard } from "@/hooks/useAdminDashboard"; +import { useAdminUsers } from "@/hooks/useAdminUsers"; +import { useAdminAudit } from "@/hooks/useAdminAudit"; +import { useAdminServices } from "@/hooks/useAdminServices"; +import { useAdminSecurity } from "@/hooks/useAdminSecurity"; +import { useAdminUpload } from "@/hooks/useAdminUpload"; +import { useAdminTenants } from "@/hooks/useAdminTenants"; +import { useTenantLogos } from "@/hooks/useTenantLogos"; +import { useAdminCert } from "@/hooks/useAdminCert"; import { Navbar } from "@/components/navbar"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -75,73 +37,17 @@ import { QuotaTab } from "@/components/admin/tabs/QuotaTab"; import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab"; import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs"; -const AUDIT_PAGE_SIZE = 25; - export default function AdminPage() { const { user, loading: authLoading } = useAuth("domain_admin", "/admin/login"); const isSuperAdmin = user?.role === "superadmin"; - // Dashboard state - const [smtpStatus, setSmtpStatus] = useState(null); - const [storageStats, setStorageStats] = useState(null); - const [systemStats, setSystemStats] = useState(null); - const [timeseries, setTimeseries] = useState([]); - const [apiOnline, setApiOnline] = useState(null); - const [dashLoading, setDashLoading] = useState(true); - const [dashRefreshed, setDashRefreshed] = useState(null); - const [countdown, setCountdown] = useState(30); - - // Services state - const [services, setServices] = useState([]); - const [servicesLoading, setServicesLoading] = useState(false); - const [serviceActionLoading, setServiceActionLoading] = useState(null); - const [serviceError, setServiceError] = useState(""); - - // Users state - const [users, setUsers] = useState([]); - 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(null); - const [resetPasswordUserId, setResetPasswordUserId] = useState(null); - const [resetPasswordValue, setResetPasswordValue] = useState(""); - const [resetPasswordError, setResetPasswordError] = useState(""); - const [resetPasswordLoading, setResetPasswordLoading] = useState(false); - - // Delete confirmation dialog - const [deleteDialogUser, setDeleteDialogUser] = useState(null); - const [deleteActionLoading, setDeleteActionLoading] = useState<"deactivate" | "delete" | null>(null); - const [deleteDialogError, setDeleteDialogError] = useState(""); - - // Audit state - const [auditEntries, setAuditEntries] = useState([]); - const [auditTotal, setAuditTotal] = useState(0); - const [auditPage, setAuditPage] = useState(1); - const [auditLoading, setAuditLoading] = useState(false); - - // Security audit state - const [securityAudit, setSecurityAudit] = useState(null); - const [securityLoading, setSecurityLoading] = useState(false); - const [securityError, setSecurityError] = useState(""); - const [fixLoading, setFixLoading] = useState(null); - const [fixMessage, setFixMessage] = useState(""); - - // Upload state - const [uploadDragging, setUploadDragging] = useState(false); - const [uploadJob, setUploadJob] = useState(null); - const [uploadError, setUploadError] = useState(""); - const [uploadLoading, setUploadLoading] = useState(false); - const uploadPollRef = useRef | null>(null); + const dashboard = useAdminDashboard(!!user); + const usersState = useAdminUsers(); + const audit = useAdminAudit(); + const servicesState = useAdminServices(); + const security = useAdminSecurity(); + const upload = useAdminUpload(); + const cert = useAdminCert(); // LDAP state (global, superadmin) — managed by useLDAPConfig hook const { @@ -161,26 +67,7 @@ export default function AdminPage() { handleDeleteLDAP, } = useLDAPConfig(); - // Tenants state - const [tenants, setTenants] = useState([]); - 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 [tenantCreatedUsers, setTenantCreatedUsers] = useState([]); - const [tenantCreatedName, setTenantCreatedName] = useState(""); - const [tenantCredDialogOpen, setTenantCredDialogOpen] = useState(false); - const [tenantDeleteId, setTenantDeleteId] = useState(null); - const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false); - const [domainDialogTenant, setDomainDialogTenant] = useState(null); - const [tenantDomains, setTenantDomains] = useState([]); - const [domainsLoading, setDomainsLoading] = useState(false); - const [newDomain, setNewDomain] = useState(""); - const [addDomainLoading, setAddDomainLoading] = useState(false); - const [domainError, setDomainError] = useState(""); + const tenantsState = useAdminTenants(); // Tenant LDAP state (domain_admin own tenant) — managed by useTenantLDAPConfig hook const { @@ -206,14 +93,11 @@ export default function AdminPage() { handleDeleteTenantLDAP, } = useTenantLDAPConfig(); - // Superadmin: tenant LDAP dialog - const [tenantLdapDialogId, setTenantLdapDialogId] = useState(null); - - // Logo dialog (superadmin: any tenant) - const [logoDialogTenant, setLogoDialogTenant] = useState(null); - const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); - const [logoUploading, setLogoUploading] = useState(false); - const [logoError, setLogoError] = useState(""); + const logos = useTenantLogos(tenantsState.setTenants, { + setOwnLogoPreviewUrl, + setOwnLogoUploading, + setOwnLogoError, + }); // Tenant users dialog — managed by useTenantUsers hook const { @@ -230,457 +114,16 @@ export default function AdminPage() { handleSyncLDAPUsers, } = useTenantUsers(); - - // Certificate state - const [certInfo, setCertInfo] = useState(null); - const [certLoading, setCertLoading] = useState(false); - const [certError, setCertError] = useState(""); - const [certSuccess, setCertSuccess] = useState(""); - const [certFile, setCertFile] = useState(null); - const [keyFile, setKeyFile] = useState(null); - const [certUploadLoading, setCertUploadLoading] = useState(false); - 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); - 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, ts] = await Promise.allSettled([ - getSMTPStatus(), - getHealth(), - getStorageStats(), - getSystemStats(), - getMailTimeseries(30), - ]); - 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); - setTimeseries(ts.status === "fulfilled" ? ts.value.points : []); - 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); - 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 | null>(null); + const { loadUsers } = usersState; + const { loadAudit } = audit; + const { loadServices } = servicesState; useEffect(() => { if (!user) return; - loadDashboard(); loadUsers(); loadAudit(1); loadServices(); - - setCountdown(30); - dashIntervalRef.current = setInterval(() => { - loadDashboard(); - setCountdown(30); - }, 30_000); - - 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); - await runSecurityAudit(); - } catch (e: unknown) { - setSecurityError(e instanceof Error ? e.message : "Fix fehlgeschlagen."); - } finally { - setFixLoading(null); - } - } - - // 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 { - const result = await createTenant(newTenantName, newTenantSlug); - setTenantDialogOpen(false); - setTenantCreatedName(result.name); - setTenantCreatedUsers(result.default_users ?? []); - setTenantCredDialogOpen(true); - 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."); - } - } - - // Logo handlers (superadmin: any tenant) - async function openLogoDialog(t: Tenant) { - setLogoDialogTenant(t); - setLogoError(""); - setLogoPreviewUrl(null); - if (t.has_logo) { - try { - const res = await fetch(getTenantLogoUrl(t.id), { credentials: "include" }); - if (res.ok) { - const blob = await res.blob(); - setLogoPreviewUrl(URL.createObjectURL(blob)); - } - } catch { - // preview not critical - } - } - } - - async function handleLogoUpload(file: File) { - if (!logoDialogTenant) return; - setLogoUploading(true); - setLogoError(""); - try { - await uploadTenantLogo(logoDialogTenant.id, file); - const res = await fetch(getTenantLogoUrl(logoDialogTenant.id), { credentials: "include" }); - if (res.ok) { - const blob = await res.blob(); - setLogoPreviewUrl(URL.createObjectURL(blob)); - } - setTenants((prev) => prev.map((t) => t.id === logoDialogTenant.id ? { ...t, has_logo: true } : t)); - } catch (err: unknown) { - setLogoError(err instanceof Error ? err.message : "Upload fehlgeschlagen."); - } finally { - setLogoUploading(false); - } - } - - async function handleLogoDelete() { - if (!logoDialogTenant) return; - setLogoUploading(true); - setLogoError(""); - try { - await deleteTenantLogo(logoDialogTenant.id); - setLogoPreviewUrl(null); - setTenants((prev) => prev.map((t) => t.id === logoDialogTenant.id ? { ...t, has_logo: false } : t)); - } catch (err: unknown) { - setLogoError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); - } finally { - setLogoUploading(false); - } - } - - // Logo handlers (domain_admin: own tenant) - async function handleOwnLogoUpload(file: File) { - setOwnLogoUploading(true); - setOwnLogoError(""); - try { - await uploadMyTenantLogo(file); - const res = await fetch(`/api/tenant/logo`, { credentials: "include" }); - if (res.ok) { - const blob = await res.blob(); - setOwnLogoPreviewUrl(URL.createObjectURL(blob)); - } - } catch (err: unknown) { - setOwnLogoError(err instanceof Error ? err.message : "Upload fehlgeschlagen."); - } finally { - setOwnLogoUploading(false); - } - } - - async function handleOwnLogoDelete() { - setOwnLogoUploading(true); - setOwnLogoError(""); - try { - await deleteMyTenantLogo(); - setOwnLogoPreviewUrl(null); - } catch (err: unknown) { - setOwnLogoError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); - } finally { - setOwnLogoUploading(false); - } - } + }, [user, loadUsers, loadAudit, loadServices]); return (
@@ -710,8 +153,8 @@ export default function AdminPage() { IMAP )} {isSuperAdmin && Security} - {isSuperAdmin && Zertifikat} - {isSuperAdmin && Mandanten} + {isSuperAdmin && Zertifikat} + {isSuperAdmin && Mandanten} {isSuperAdmin && Retention} {isSuperAdmin && Regeln} {isSuperAdmin && Quotas} @@ -722,17 +165,17 @@ export default function AdminPage() { { loadDashboard(); setCountdown(30); }} + smtpStatus={dashboard.smtpStatus} + storageStats={dashboard.storageStats} + systemStats={dashboard.systemStats} + timeseries={dashboard.timeseries} + apiOnline={dashboard.apiOnline} + dashLoading={dashboard.dashLoading} + dashRefreshed={dashboard.dashRefreshed} + countdown={dashboard.countdown} + users={usersState.users} + usersLoading={usersState.usersLoading} + onRefresh={dashboard.refresh} /> @@ -740,12 +183,12 @@ export default function AdminPage() { )} @@ -753,65 +196,65 @@ export default function AdminPage() { { - setResetPasswordUserId(userId); - setResetPasswordValue(""); - setResetPasswordError(""); + usersState.setResetPasswordUserId(userId); + usersState.setResetPasswordValue(""); + usersState.setResetPasswordError(""); }} - onOpenDeleteDialog={(u) => { setDeleteDialogUser(u); setDeleteDialogError(""); }} + onOpenDeleteDialog={(u) => { usersState.setDeleteDialogUser(u); usersState.setDeleteDialogError(""); }} /> {isSuperAdmin && ( )} @@ -855,8 +298,8 @@ export default function AdminPage() { onSave={handleSaveTenantLDAP} onTest={handleTestTenantLDAP} onDelete={handleDeleteTenantLDAP} - onOwnLogoUpload={handleOwnLogoUpload} - onOwnLogoDelete={handleOwnLogoDelete} + onOwnLogoUpload={logos.handleOwnLogoUpload} + onOwnLogoDelete={logos.handleOwnLogoDelete} /> )} @@ -864,38 +307,38 @@ export default function AdminPage() { {isSuperAdmin && ( )} @@ -903,37 +346,37 @@ export default function AdminPage() { {isSuperAdmin && ( { setLogoDialogTenant(null); setLogoPreviewUrl(null); }} - tenantLdapDialogId={tenantLdapDialogId} - setTenantLdapDialogId={setTenantLdapDialogId} - onLoadTenants={loadTenants} - onToggleTenant={handleToggleTenant} + logoDialogTenant={logos.logoDialogTenant} + logoPreviewUrl={logos.logoPreviewUrl} + logoUploading={logos.logoUploading} + logoError={logos.logoError} + onOpenLogoDialog={logos.openLogoDialog} + onLogoUpload={logos.handleLogoUpload} + onLogoDelete={logos.handleLogoDelete} + onLogoDialogClose={() => { logos.setLogoDialogTenant(null); logos.setLogoPreviewUrl(null); }} + tenantLdapDialogId={tenantsState.tenantLdapDialogId} + setTenantLdapDialogId={tenantsState.setTenantLdapDialogId} + onLoadTenants={tenantsState.loadTenants} + onToggleTenant={tenantsState.handleToggleTenant} /> )} @@ -997,22 +440,22 @@ export default function AdminPage() { {/* Global dialogs (not tab-specific) */} setResetPasswordUserId(null)} - value={resetPasswordValue} - setValue={setResetPasswordValue} - error={resetPasswordError} - loading={resetPasswordLoading} - onSubmit={handleResetPassword} + open={usersState.resetPasswordUserId !== null} + onClose={() => usersState.setResetPasswordUserId(null)} + value={usersState.resetPasswordValue} + setValue={usersState.setResetPasswordValue} + error={usersState.resetPasswordError} + loading={usersState.resetPasswordLoading} + onSubmit={usersState.handleResetPassword} /> setDeleteDialogUser(null)} - deleteActionLoading={deleteActionLoading} - deleteDialogError={deleteDialogError} - onDeactivate={handleDeactivateConfirmed} - onDelete={handleDeleteConfirmed} + user={usersState.deleteDialogUser} + onClose={() => usersState.setDeleteDialogUser(null)} + deleteActionLoading={usersState.deleteActionLoading} + deleteDialogError={usersState.deleteDialogError} + onDeactivate={usersState.handleDeactivateConfirmed} + onDelete={usersState.handleDeleteConfirmed} />
); diff --git a/src/app/imap/page.tsx b/src/app/imap/page.tsx index fe1515a..a5d2fc7 100644 --- a/src/app/imap/page.tsx +++ b/src/app/imap/page.tsx @@ -1,737 +1,134 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; import { useAuth } from "@/hooks/useAuth"; +import { useImapAccounts } from "@/hooks/useImapAccounts"; import { Navbar } from "@/components/navbar"; -import { - getImapAccounts, - createImapAccount, - deleteImapAccount, - testImapConnection, - startImapImport, - getImapProgress, - triggerImapSync, - updateImapInterval, - updateImapAccount, - type ImapAccount, - type ImapFolder, -} from "@/lib/api"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Card, - CardHeader, - CardContent, -} from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Badge } from "@/components/ui/badge"; -import { Progress } from "@/components/ui/progress"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Separator } from "@/components/ui/separator"; +import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { ImapAccountCard } from "@/components/imap/ImapAccountCard"; +import { ImapAccountDialog } from "@/components/imap/ImapAccountDialog"; +import { ImapEditDialog } from "@/components/imap/ImapEditDialog"; +import { ImapDeleteDialog } from "@/components/imap/ImapDeleteDialog"; export default function ImapPage() { const { user, loading: authLoading } = useAuth(); - const [accounts, setAccounts] = useState([]); - const [loading, setLoading] = useState(true); - const [dialogOpen, setDialogOpen] = useState(false); - const [deleteConfirm, setDeleteConfirm] = useState(null); - - // Edit state - const [editAccount, setEditAccount] = useState(null); - const [editName, setEditName] = useState(""); - const [editHost, setEditHost] = useState(""); - const [editPort, setEditPort] = useState("993"); - const [editTls, setEditTls] = useState("ssl"); - const [editUsername, setEditUsername] = useState(""); - const [editPassword, setEditPassword] = useState(""); - const [editSaving, setEditSaving] = useState(false); - const [editError, setEditError] = useState(""); - - // Form state - const [formName, setFormName] = useState(""); - const [formHost, setFormHost] = useState(""); - const [formPort, setFormPort] = useState("993"); - const [formTls, setFormTls] = useState("ssl"); - const [formUsername, setFormUsername] = useState(""); - const [formPassword, setFormPassword] = useState(""); - - // Test state - const [testing, setTesting] = useState(false); - const [testError, setTestError] = useState(""); - const [testFolders, setTestFolders] = useState(null); - const [excludedFolders, setExcludedFolders] = useState>(new Set()); - - // Saving state - const [saving, setSaving] = useState(false); - - // Import error state - const [importError, setImportError] = useState(""); - - // Polling refs - const pollingRefs = useRef>>(new Map()); - const pollErrorCount = useRef>(new Map()); - - const loadAccounts = useCallback(async () => { - try { - const data = await getImapAccounts(); - setAccounts(data); - } catch { - // ignore - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - if (user) loadAccounts(); - }, [user, loadAccounts]); - - // Start polling for running accounts (import or sync) - useEffect(() => { - for (const acc of accounts) { - const isActive = acc.status === "running" || acc.sync_running; - if (isActive && !pollingRefs.current.has(acc.id)) { - pollErrorCount.current.set(acc.id, 0); - const interval = setInterval(async () => { - try { - const updated = await getImapProgress(acc.id); - pollErrorCount.current.set(acc.id, 0); - setAccounts((prev) => - prev.map((a) => (a.id === updated.id ? updated : a)) - ); - if (updated.status !== "running" && !updated.sync_running) { - clearInterval(pollingRefs.current.get(acc.id)!); - pollingRefs.current.delete(acc.id); - pollErrorCount.current.delete(acc.id); - } - } catch { - // Only stop polling after 5 consecutive failures (tolerates brief network hiccups) - const errors = (pollErrorCount.current.get(acc.id) ?? 0) + 1; - pollErrorCount.current.set(acc.id, errors); - if (errors >= 5) { - clearInterval(pollingRefs.current.get(acc.id)!); - pollingRefs.current.delete(acc.id); - pollErrorCount.current.delete(acc.id); - } - } - }, 2000); - pollingRefs.current.set(acc.id, interval); - } - } - // Cleanup intervals for accounts that are no longer active - for (const [id, interval] of pollingRefs.current) { - const acc = accounts.find((a) => a.id === id); - if (!acc || (acc.status !== "running" && !acc.sync_running)) { - clearInterval(interval); - pollingRefs.current.delete(id); - } - } - }, [accounts]); - - // Cleanup on unmount - useEffect(() => { - return () => { - for (const interval of pollingRefs.current.values()) { - clearInterval(interval); - } - }; - }, []); - - function resetForm() { - setFormName(""); - setFormHost(""); - setFormPort("993"); - setFormTls("ssl"); - setFormUsername(""); - setFormPassword(""); - setTestFolders(null); - setTestError(""); - setExcludedFolders(new Set()); - } - - async function handleTest() { - setTesting(true); - setTestError(""); - setTestFolders(null); - try { - const result = await testImapConnection({ - host: formHost, - port: parseInt(formPort, 10) || 993, - tls: formTls, - username: formUsername, - password: formPassword, - }); - if (result.ok && result.folders) { - setTestFolders(result.folders); - const excluded = new Set(); - for (const f of result.folders) { - if (f.excluded) excluded.add(f.name); - } - setExcludedFolders(excluded); - } else { - setTestError(result.error || "Verbindungstest fehlgeschlagen"); - } - } catch (err) { - setTestError(err instanceof Error ? err.message : "Verbindungstest fehlgeschlagen"); - } finally { - setTesting(false); - } - } - - async function handleSave() { - setSaving(true); - try { - await createImapAccount({ - name: formName, - host: formHost, - port: parseInt(formPort, 10) || 993, - tls: formTls, - username: formUsername, - password: formPassword, - excluded_folders: Array.from(excludedFolders), - }); - setDialogOpen(false); - resetForm(); - await loadAccounts(); - } catch (err) { - setTestError(err instanceof Error ? err.message : "Speichern fehlgeschlagen"); - } finally { - setSaving(false); - } - } - - async function handleStartImport(id: number) { - setImportError(""); - try { - const updated = await startImapImport(id); - setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a))); - } catch (err) { - setImportError(err instanceof Error ? err.message : "Import konnte nicht gestartet werden."); - } - } - - async function handleDelete(id: number) { - try { - await deleteImapAccount(id); - setAccounts((prev) => prev.filter((a) => a.id !== id)); - } catch (err) { - alert("Fehler beim Löschen: " + (err instanceof Error ? err.message : String(err))); - } - setDeleteConfirm(null); - } - - function openEdit(acc: ImapAccount) { - setEditAccount(acc); - setEditName(acc.name); - setEditHost(acc.host); - setEditPort(String(acc.port)); - setEditTls(acc.tls); - setEditUsername(acc.username); - setEditPassword(""); - setEditError(""); - } - - async function handleEditSave() { - if (!editAccount) return; - if (!editName || !editHost || !editUsername) { - setEditError("Name, Host und Benutzername sind Pflichtfelder."); - return; - } - setEditSaving(true); - setEditError(""); - try { - const updated = await updateImapAccount(editAccount.id, { - name: editName, - host: editHost, - port: parseInt(editPort, 10), - tls: editTls, - username: editUsername, - password: editPassword || undefined, - }); - setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a))); - setEditAccount(null); - } catch (err) { - setEditError(err instanceof Error ? err.message : String(err)); - } finally { - setEditSaving(false); - } - } - - async function handleSyncNow(id: number) { - try { - const updated = await triggerImapSync(id); - setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a))); - } catch { - // ignore — conflict means sync already running - } - } - - async function handleIntervalChange(id: number, value: string) { - const intervalMin = parseInt(value, 10); - try { - const updated = await updateImapInterval(id, intervalMin); - setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a))); - } catch { - // ignore - } - } - - function toggleExcluded(folderName: string) { - setExcludedFolders((prev) => { - const next = new Set(prev); - if (next.has(folderName)) { - next.delete(folderName); - } else { - next.add(folderName); - } - return next; - }); - } - - function statusBadge(status: string) { - switch (status) { - case "running": - return Importiert...; - case "error": - return Fehler; - default: - return Bereit; - } - } - - function syncBadge(acc: ImapAccount) { - if (acc.sync_running) { - return Sync laeuft...; - } - if (!acc.sync_status) return null; - if (acc.sync_status === "ok") { - return Sync OK; - } - if (acc.sync_status === "error") { - return Sync Fehler; - } - return null; - } + const imap = useImapAccounts(user); return (
- {(authLoading || !user) ? ( + {authLoading || !user ? (
- ) : (<> -
-

IMAP Import

- -
- - {importError && ( -

{importError}

- )} - - {loading ? ( -
- {[1, 2].map((i) => ( - - ))} -
- ) : accounts.length === 0 ? ( - - - Noch keine IMAP-Konten konfiguriert. Klicken Sie auf "Konto - hinzufuegen", um zu beginnen. - - ) : ( -
- {accounts.map((acc) => ( - - -
-

{acc.name}

-

- {acc.host}:{acc.port} ({acc.tls.toUpperCase()}) · {acc.username} -

-
- {statusBadge(acc.status)} -
- - {acc.status === "running" && acc.progress_total === 0 && ( -
- -

- Zaehle E-Mails auf dem Server... -

-
- )} - {acc.status === "running" && acc.progress_total > 0 && ( -
- -

- {acc.progress_current} von {acc.progress_total} E-Mails -

-
- )} - - {acc.status === "error" && acc.error_msg && ( -

- {acc.error_msg} -

- )} - - {acc.last_import_at && ( -

- Letzter Import:{" "} - {new Date(acc.last_import_at).toLocaleString("de-DE")} ( - {acc.last_import_count} E-Mails) -

- )} - - {/* PROJ-8: Sync status */} - {acc.last_sync_at && ( -
-

- Letzter Sync:{" "} - {new Date(acc.last_sync_at).toLocaleString("de-DE")} ( - {acc.last_sync_count} neu) -

- {syncBadge(acc)} -
- )} - - {acc.sync_running && !acc.last_sync_at && syncBadge(acc)} - - {acc.sync_error_msg && acc.sync_status === "error" && ( -

- Sync-Fehler: {acc.sync_error_msg} -

- )} - - {acc.excluded_folders && acc.excluded_folders.length > 0 && ( -

- Ausgeschlossene Ordner: {acc.excluded_folders.join(", ")} -

- )} - - {/* PROJ-8: Sync interval selector */} -
- - Auto-Sync: - - -
- -
- - - - -
-
-
- ))} -
- )} - - {/* Add Account Dialog */} - - - - IMAP-Konto hinzufuegen - -
-
- - setFormName(e.target.value)} - /> -
-
-
- - setFormHost(e.target.value)} - /> -
-
- - setFormPort(e.target.value)} - /> -
-
-
- - -
-
- - setFormUsername(e.target.value)} - /> -
-
- - setFormPassword(e.target.value)} - /> -
- + <> +
+

IMAP Import

- - {testError && ( -

{testError}

- )} - - {testFolders && ( - <> - -
-

- Erkannte Ordner -

-
- {testFolders.map((folder) => ( -
- - toggleExcluded(folder.name) - } - /> - - {folder.excluded && folder.reason && ( - - ({folder.reason === "special_use" - ? "IMAP-Flag" - : "Namens-Erkennung"}) - - )} -
- ))} -
-

- Deaktivierte Ordner werden nicht importiert. -

-
- - )} -
- - - - - -
- - {/* Edit Account Dialog */} - { if (!open) setEditAccount(null); }}> - - - IMAP-Konto bearbeiten - -
-
- - setEditName(e.target.value)} placeholder="z.B. Firmen-Mail" /> -
-
-
- - setEditHost(e.target.value)} placeholder="imap.example.com" /> -
-
- - setEditPort(e.target.value)} type="number" /> -
-
-
- - -
-
- - setEditUsername(e.target.value)} placeholder="user@example.com" /> -
-
- - setEditPassword(e.target.value)} type="password" placeholder="Neues Passwort eingeben" /> -
- {editError &&

{editError}

}
- - - - -
-
- {/* Delete Confirmation Dialog */} - setDeleteConfirm(null)} - > - - - Konto loeschen? - -

- Soll dieses IMAP-Konto wirklich entfernt werden? Bereits - importierte E-Mails bleiben im Archiv erhalten. -

- - - - -
-
- )} + {imap.importError && ( +

+ {imap.importError} +

+ )} + + {imap.loading ? ( +
+ {[1, 2].map((i) => ( + + ))} +
+ ) : imap.accounts.length === 0 ? ( + + + Noch keine IMAP-Konten konfiguriert. Klicken Sie auf "Konto + hinzufuegen", um zu beginnen. + + + ) : ( +
+ {imap.accounts.map((acc) => ( + + ))} +
+ )} + + { + imap.setDialogOpen(false); + imap.resetForm(); + }} + onToggleExcluded={imap.toggleExcluded} + /> + + imap.setEditAccount(null)} + editName={imap.editName} + setEditName={imap.setEditName} + editHost={imap.editHost} + setEditHost={imap.setEditHost} + editPort={imap.editPort} + setEditPort={imap.setEditPort} + editTls={imap.editTls} + setEditTls={imap.setEditTls} + editUsername={imap.editUsername} + setEditUsername={imap.setEditUsername} + editPassword={imap.editPassword} + setEditPassword={imap.setEditPassword} + editSaving={imap.editSaving} + editError={imap.editError} + onSave={imap.handleEditSave} + /> + + imap.setDeleteConfirm(null)} + onConfirm={imap.handleDelete} + /> + + )}
); diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index c046356..b612fc1 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,86 +1,23 @@ "use client"; -import { useState, useCallback, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; -import { searchEmails, exportMailsZIP, exportEDiscovery, uploadMailFilesUser, getUploadProgressUser, listSavedSearches, createSavedSearch, deleteSavedSearch, type SearchHit, type SearchMatchField, type UploadJob, type SavedSearch } from "@/lib/api"; -import { sanitizeSnippet } from "@/lib/sanitize"; +import { useSearch, DEFAULT_PAGE_SIZE } from "@/hooks/useSearch"; +import { useSavedSearches } from "@/hooks/useSavedSearches"; +import { useMailUpload } from "@/hooks/useMailUpload"; +import { exportMailsZIP, exportEDiscovery } from "@/lib/api"; import { Navbar } from "@/components/navbar"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Progress } from "@/components/ui/progress"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { Skeleton } from "@/components/ui/skeleton"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Bookmark, BookmarkPlus, Trash2 } from "lucide-react"; - -const DEFAULT_PAGE_SIZE = 25; - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -const MATCH_FIELD_LABEL: Record = { - subject: "📨 Subject", - body: "✉️ Body", - attachment_text: "📄 PDF-Anhang", - attachment_names: "📎 Dateiname", - from_addr: "👤 Absender", - to_addr: "📧 Empfänger", -}; - -function MatchSourceBadge({ field }: { field: SearchMatchField }) { - return ( - - {MATCH_FIELD_LABEL[field]} - - ); -} - -function SnippetLine({ hit }: { hit: SearchHit }) { - if (!hit.snippet) return null; - return ( -
- {hit.match_field && } - -
- ); -} +import { SearchFilterBar } from "@/components/search/SearchFilterBar"; +import { SearchResultsTable } from "@/components/search/SearchResultsTable"; +import { + ExportZipDialog, + EDiscoveryDialog, + UploadDialog, +} from "@/components/search/ExportDialogs"; export default function SearchPage() { const { user, loading: authLoading } = useAuth(); @@ -88,19 +25,34 @@ export default function SearchPage() { const pageSize = user?.list_page_size ?? DEFAULT_PAGE_SIZE; - const [query, setQuery] = useState(""); - const [fromFilter, setFromFilter] = useState(""); - const [toFilter, setToFilter] = useState(""); - const [dateFrom, setDateFrom] = useState(""); - const [dateTo, setDateTo] = useState(""); - const [sort, setSort] = useState("date_desc"); - const [hasAttachment, setHasAttachment] = useState(undefined); - - const [results, setResults] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [searching, setSearching] = useState(false); - const [searched, setSearched] = useState(false); + const search = useSearch(user, pageSize); + const { + query, + setQuery, + fromFilter, + setFromFilter, + toFilter, + setToFilter, + dateFrom, + setDateFrom, + dateTo, + setDateTo, + sort, + setSort, + hasAttachment, + setHasAttachment, + results, + setResults, + total, + setTotal, + page, + setPage, + searching, + setSearching, + searched, + setSearched, + doSearch, + } = search; // Selection state const [selected, setSelected] = useState>(new Set()); @@ -111,56 +63,35 @@ export default function SearchPage() { const [ediscoveryCaseName, setEdiscoveryCaseName] = useState(""); const [ediscoveryLoading, setEdiscoveryLoading] = useState(false); - // Upload state - const [uploadOpen, setUploadOpen] = useState(false); - const [uploadDragging, setUploadDragging] = useState(false); - const [uploadJob, setUploadJob] = useState(null); - const [uploadError, setUploadError] = useState(""); - const [uploadLoading, setUploadLoading] = useState(false); - const uploadPollRef = useRef | null>(null); + const upload = useMailUpload(() => doSearch(1)); - // Saved searches state - const [savedSearches, setSavedSearches] = useState([]); - const [savedLoading, setSavedLoading] = useState(false); - const [savePopoverOpen, setSavePopoverOpen] = useState(false); - const [saveName, setSaveName] = useState(""); - const [saving, setSaving] = useState(false); - const [savedListOpen, setSavedListOpen] = useState(false); + const saved = useSavedSearches({ + user, + pageSize, + query, + fromFilter, + toFilter, + dateFrom, + dateTo, + hasAttachment, + setQuery, + setFromFilter, + setToFilter, + setDateFrom, + setDateTo, + setHasAttachment, + setResults, + setTotal, + setPage, + setSearching, + setSearched, + }); // Clear selection when results change useEffect(() => { setSelected(new Set()); }, [results]); - const doSearch = useCallback( - async (p: number) => { - setSearching(true); - try { - const res = await searchEmails({ - q: query || undefined, - from: fromFilter || undefined, - to: toFilter || undefined, - date_from: dateFrom || undefined, - date_to: dateTo || undefined, - sort: sort !== "date_desc" ? sort : undefined, - has_attachment: hasAttachment, - page: p, - page_size: pageSize, - }); - setResults(res.hits || []); - setTotal(res.total); - setPage(p); - setSearched(true); - } catch { - setResults([]); - setTotal(0); - } finally { - setSearching(false); - } - }, - [query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, pageSize] - ); - // Superadmin has no mail access — redirect to admin dashboard useEffect(() => { if (user?.role === "superadmin") { @@ -168,24 +99,6 @@ export default function SearchPage() { } }, [user, router]); - // Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure - useEffect(() => { - if (!user || user.role === "superadmin") return; - setSearching(true); - searchEmails({ page: 1, page_size: pageSize }) - .then((res) => { - setResults(res.hits || []); - setTotal(res.total); - setPage(1); - setSearched(true); - }) - .catch(() => { - setResults([]); - setTotal(0); - }) - .finally(() => setSearching(false)); - }, [user, pageSize]); - function handleSubmit(e: React.FormEvent) { e.preventDefault(); doSearch(1); @@ -235,139 +148,8 @@ export default function SearchPage() { } } - async function handleUploadFiles(files: FileList | File[]) { - const allowed = Array.from(files).filter((f) => { - const n = f.name.toLowerCase(); - return n.endsWith(".eml") || n.endsWith(".mbox"); - }); - if (allowed.length === 0) { - setUploadError("Nur .eml und .mbox Dateien erlaubt."); - return; - } - setUploadError(""); - setUploadJob(null); - setUploadLoading(true); - try { - const { job_id } = await uploadMailFilesUser(allowed); - uploadPollRef.current = setInterval(async () => { - try { - const job = await getUploadProgressUser(job_id); - setUploadJob(job); - if (job.status !== "running") { - clearInterval(uploadPollRef.current!); - uploadPollRef.current = null; - setUploadLoading(false); - // Refresh search results after successful import - if (job.status === "done") doSearch(1); - } - } catch { - clearInterval(uploadPollRef.current!); - uploadPollRef.current = null; - setUploadLoading(false); - } - }, 1500); - } catch (e) { - setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen."); - setUploadLoading(false); - } - } - - function handleUploadClose() { - if (uploadPollRef.current) { - clearInterval(uploadPollRef.current); - uploadPollRef.current = null; - } - setUploadOpen(false); - setUploadJob(null); - setUploadError(""); - setUploadLoading(false); - } - - // Load saved searches on mount - useEffect(() => { - if (!user || user.role === "superadmin") return; - setSavedLoading(true); - listSavedSearches() - .then((list) => setSavedSearches(list || [])) - .catch(() => setSavedSearches([])) - .finally(() => setSavedLoading(false)); - }, [user]); - const hasActiveSearch = !!(query || fromFilter || toFilter || dateFrom || dateTo || hasAttachment); - - function buildCurrentQuery(): Record { - const q: Record = {}; - if (query) q.q = query; - if (fromFilter) q.from = fromFilter; - if (toFilter) q.to = toFilter; - if (dateFrom) q.date_from = dateFrom; - if (dateTo) q.date_to = dateTo; - if (hasAttachment) q.has_attachment = "true"; - return q; - } - - async function handleSaveSearch() { - if (!saveName.trim()) return; - setSaving(true); - try { - const saved = await createSavedSearch(saveName.trim(), buildCurrentQuery()); - setSavedSearches((prev) => [saved, ...prev]); - setSaveName(""); - setSavePopoverOpen(false); - } catch (e) { - alert(`Suche speichern fehlgeschlagen: ${e instanceof Error ? e.message : e}`); - } finally { - setSaving(false); - } - } - - function handleApplySavedSearch(s: SavedSearch) { - setQuery(s.query.q || ""); - setFromFilter(s.query.from || ""); - setToFilter(s.query.to || ""); - setDateFrom(s.query.date_from || ""); - setDateTo(s.query.date_to || ""); - setHasAttachment(s.query.has_attachment === "true" ? true : undefined); - setSavedListOpen(false); - // Trigger search after state updates - setTimeout(() => { - // We need to search with the saved query directly since state isn't updated yet - setSearching(true); - searchEmails({ - q: s.query.q || undefined, - from: s.query.from || undefined, - to: s.query.to || undefined, - date_from: s.query.date_from || undefined, - date_to: s.query.date_to || undefined, - has_attachment: s.query.has_attachment === "true" ? true : undefined, - page: 1, - page_size: pageSize, - }) - .then((res) => { - setResults(res.hits || []); - setTotal(res.total); - setPage(1); - setSearched(true); - }) - .catch(() => { - setResults([]); - setTotal(0); - }) - .finally(() => setSearching(false)); - }, 0); - } - - async function handleDeleteSavedSearch(id: number) { - try { - await deleteSavedSearch(id); - setSavedSearches((prev) => prev.filter((s) => s.id !== id)); - } catch (e) { - alert(`Loeschen fehlgeschlagen: ${e instanceof Error ? e.message : e}`); - } - } - const totalPages = Math.ceil(total / pageSize); - const allSelected = results.length > 0 && results.every((h) => selected.has(h.id)); return (
@@ -384,183 +166,38 @@ export default function SearchPage() {
{/* Main content */}
-
-
- setQuery(e.target.value)} - className="w-full flex-1 sm:w-auto" - aria-label="Suchbegriff" - /> - - - {hasActiveSearch && ( - - - - - -
-

Suche speichern

- setSaveName(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleSaveSearch(); }} - aria-label="Name der gespeicherten Suche" - autoFocus - /> -
- - -
-
-
-
- )} - - - - - -

Gespeicherte Suchen

- {savedLoading ? ( -
- - -
- ) : savedSearches.length === 0 ? ( -

- Keine gespeicherten Suchen vorhanden. -

- ) : ( -
- {savedSearches.map((s) => ( -
- - - {new Date(s.created_at).toLocaleDateString("de-DE")} - - -
- ))} -
- )} -
-
-
- -
-
- - setFromFilter(e.target.value)} - aria-label="Absender filtern" - /> -
-
- - setToFilter(e.target.value)} - aria-label="Empfänger filtern" - /> -
-
- - setDateFrom(e.target.value)} - aria-label="Datum von" - /> -
-
- - setDateTo(e.target.value)} - aria-label="Datum bis" - /> -
-
- -
-
- - -
-
- - setHasAttachment(checked ? true : undefined) - } - /> - -
-
-
+ upload.setUploadOpen(true)} + hasActiveSearch={hasActiveSearch} + savePopoverOpen={saved.savePopoverOpen} + setSavePopoverOpen={saved.setSavePopoverOpen} + saveName={saved.saveName} + setSaveName={saved.setSaveName} + saving={saved.saving} + onSaveSearch={saved.handleSaveSearch} + savedListOpen={saved.savedListOpen} + setSavedListOpen={saved.setSavedListOpen} + savedLoading={saved.savedLoading} + savedSearches={saved.savedSearches} + onApplySavedSearch={saved.handleApplySavedSearch} + onDeleteSavedSearch={saved.handleDeleteSavedSearch} + />
{searching ? ( @@ -598,272 +235,56 @@ export default function SearchPage() {
- - - - - - { - if (checked) setSelected(new Set(results.map((h) => h.id))); - else setSelected(new Set()); - }} - aria-label="Alle auswählen" - /> - - Datum - Von - Betreff - An - 📎 - Größe - - - - {results.map((hit) => ( - router.push(`/mail/${hit.id}`)} - role="link" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter") router.push(`/mail/${hit.id}`); - }} - aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`} - > - e.stopPropagation()}> - { - setSelected((prev) => { - const next = new Set(prev); - if (checked) next.add(hit.id); - else next.delete(hit.id); - return next; - }); - }} - aria-label="Mail auswählen" - /> - - - {hit.date - ? new Date(hit.date).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" }) - : "-"} - - {hit.from || "-"} - -
- {hit.subject || "(kein Betreff)"} - {hit.thread_size && hit.thread_size > 1 && ( - - {hit.thread_size} - - )} -
- {/* Absender nur auf Mobile, da Spalte "Von" dort ausgeblendet ist */} - {hit.from && ( -
- {hit.from} -
- )} - -
- {hit.to || "-"} - - {hit.has_attachments ? "📎" : ""} - - - {hit.size ? formatBytes(hit.size) : ""} - -
- ))} -
-
-
- - {totalPages > 1 && ( -
- - - Seite {page} von {totalPages} - - -
- )} + doSearch(p)} + /> ) : null}
{/* end flex-1 */}
{/* end flex gap-6 */} - - - - E-Mails exportieren - - {selected.size} E-Mail{selected.size !== 1 ? "s" : ""} als ZIP herunterladen - - -
- - -
- - - - -
-
- {/* eDiscovery Export Dialog */} - - - - eDiscovery Export - - Exportiert alle Mails der aktuellen Suche als ZIP mit Metadaten-CSV und README. - - -
-
- - setEdiscoveryCaseName(e.target.value)} - /> -
-
-
Aktive Filter:
- {query &&
Suche: {query}
} - {fromFilter &&
Von: {fromFilter}
} - {toFilter &&
An: {toFilter}
} - {dateFrom &&
Von Datum: {dateFrom}
} - {dateTo &&
Bis Datum: {dateTo}
} - {!query && !fromFilter && !toFilter && !dateFrom && !dateTo && ( -
Keine Filter — alle archivierten Mails werden exportiert
- )} -
-
- - - - -
-
+ - {/* Upload Dialog */} - { if (!open) handleUploadClose(); else setUploadOpen(true); }}> - - - E-Mails importieren - - EML- oder MBOX-Dateien in das Archiv hochladen - - + - {!uploadJob && ( -
{ e.preventDefault(); setUploadDragging(true); }} - onDragLeave={() => setUploadDragging(false)} - onDrop={(e) => { - e.preventDefault(); - setUploadDragging(false); - if (e.dataTransfer.files.length > 0) handleUploadFiles(e.dataTransfer.files); - }} - > -

- Dateien hierher ziehen oder auswählen -

- -

.eml · .mbox

-
- )} - - {uploadError && ( -

{uploadError}

- )} - - {uploadJob && ( -
- 0 ? Math.round(((uploadJob.imported + uploadJob.skipped + uploadJob.errors) / uploadJob.total) * 100) : 0} - /> -
-
-
{uploadJob.imported}
-
Importiert
-
-
-
{uploadJob.skipped}
-
Duplikate
-
-
-
{uploadJob.errors}
-
Fehler
-
-
- {uploadJob.status === "running" && ( -

- Verarbeite {uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total} … -

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

Abgeschlossen

- )} -
- )} - - - - -
-
+ { if (!open) upload.handleUploadClose(); else upload.setUploadOpen(true); }} + dragging={upload.uploadDragging} + setDragging={upload.setUploadDragging} + job={upload.uploadJob} + error={upload.uploadError} + loading={upload.uploadLoading} + onUploadFiles={upload.handleUploadFiles} + onClose={upload.handleUploadClose} + /> )} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 4b2eb40..453dd9f 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,236 +1,24 @@ "use client"; -import { useEffect, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { Navbar } from "@/components/navbar"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; -import { Badge } from "@/components/ui/badge"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Server, Lock, Info } from "lucide-react"; -import { - changePassword, - changeEmail, - updatePreferences, - getTOTPSetup, - confirmTOTPSetup, - disableTOTP, - getSystemInfo, - type SystemInfo, -} from "@/lib/api"; +import { usePasswordChange } from "@/hooks/usePasswordChange"; +import { useProfileSettings } from "@/hooks/useProfileSettings"; +import { useTotpSettings } from "@/hooks/useTotpSettings"; +import { useSystemInfo } from "@/hooks/useSystemInfo"; +import { PasswordSection } from "@/components/settings/PasswordSection"; +import { ProfileSection } from "@/components/settings/ProfileSection"; +import { DisplaySection } from "@/components/settings/DisplaySection"; +import { TotpSection } from "@/components/settings/TotpSection"; +import { ImapSection } from "@/components/settings/ImapSection"; export default function SettingsPage() { const { user, loading, refresh } = useAuth(); - // ── Password state ──────────────────────────────────────────────────── - const [currentPw, setCurrentPw] = useState(""); - const [newPw, setNewPw] = useState(""); - const [confirmPw, setConfirmPw] = useState(""); - const [pwError, setPwError] = useState(""); - const [pwSuccess, setPwSuccess] = useState(""); - const [pwLoading, setPwLoading] = useState(false); - - // ── Email state ─────────────────────────────────────────────────────── - const [email, setEmail] = useState(""); - const [emailInitialized, setEmailInitialized] = useState(false); - const [emailError, setEmailError] = useState(""); - const [emailSuccess, setEmailSuccess] = useState(""); - const [emailLoading, setEmailLoading] = useState(false); - - // ── TOTP state ──────────────────────────────────────────────────────── - const [totpEnabled, setTotpEnabled] = useState(false); - const [showSetup, setShowSetup] = useState(false); - const [qrCode, setQrCode] = useState(""); - const [secret, setSecret] = useState(""); - const [totpCode, setTotpCode] = useState(""); - const [totpError, setTotpError] = useState(""); - const [totpSuccess, setTotpSuccess] = useState(""); - const [totpLoading, setTotpLoading] = useState(false); - const [disableDialogOpen, setDisableDialogOpen] = useState(false); - const [disableCode, setDisableCode] = useState(""); - const [disableError, setDisableError] = useState(""); - const [disableLoading, setDisableLoading] = useState(false); - - // ── Listenanzahl (Eintraege pro Seite) ──────────────────────────────── - const [listPageSize, setListPageSize] = useState("25"); - const [listPageSizeInitialized, setListPageSizeInitialized] = useState(false); - const [listPageSizeError, setListPageSizeError] = useState(""); - const [listPageSizeSuccess, setListPageSizeSuccess] = useState(""); - const [listPageSizeLoading, setListPageSizeLoading] = useState(false); - - // ── System info (FQDN + IMAP-Ports) ─────────────────────────────────── - const [systemInfo, setSystemInfo] = useState(null); - const [systemInfoLoading, setSystemInfoLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - setSystemInfoLoading(true); - getSystemInfo() - .then((info) => { - if (!cancelled) setSystemInfo(info); - }) - .catch(() => { - if (!cancelled) setSystemInfo(null); - }) - .finally(() => { - if (!cancelled) setSystemInfoLoading(false); - }); - return () => { - cancelled = true; - }; - }, []); - - // Initialize email from user data once available - if (user && !emailInitialized) { - setEmail(user.email || ""); - setEmailInitialized(true); - } - - // Initialize list page size from user data once available - if (user && !listPageSizeInitialized) { - setListPageSize(String(user.list_page_size || 25)); - setListPageSizeInitialized(true); - } - - // ── Handlers ────────────────────────────────────────────────────────── - - async function handleChangePassword(e: React.FormEvent) { - e.preventDefault(); - setPwError(""); - setPwSuccess(""); - - if (newPw.length < 8) { - setPwError("Das neue Passwort muss mindestens 8 Zeichen lang sein."); - return; - } - if (newPw !== confirmPw) { - setPwError("Die Passwoerter stimmen nicht ueberein."); - return; - } - - setPwLoading(true); - try { - await changePassword(currentPw, newPw); - setPwSuccess("Passwort wurde erfolgreich geaendert."); - setCurrentPw(""); - setNewPw(""); - setConfirmPw(""); - } catch (err) { - setPwError(err instanceof Error ? err.message : "Fehler beim Aendern des Passworts"); - } finally { - setPwLoading(false); - } - } - - async function handleChangeEmail(e: React.FormEvent) { - e.preventDefault(); - setEmailError(""); - setEmailSuccess(""); - - if (!email || !email.includes("@")) { - setEmailError("Bitte eine gueltige E-Mail-Adresse eingeben."); - return; - } - - setEmailLoading(true); - try { - const result = await changeEmail(email); - setEmail(result.email); - setEmailSuccess("E-Mail-Adresse wurde erfolgreich geaendert."); - } catch (err) { - setEmailError(err instanceof Error ? err.message : "Fehler beim Aendern der E-Mail"); - } finally { - setEmailLoading(false); - } - } - - async function handleChangeListPageSize(value: string) { - setListPageSizeError(""); - setListPageSizeSuccess(""); - const parsed = Number(value); - - setListPageSizeLoading(true); - try { - const result = await updatePreferences(parsed); - setListPageSize(String(result.list_page_size)); - setListPageSizeSuccess("Eintraege pro Seite wurden gespeichert."); - await refresh(); - } catch (err) { - setListPageSizeError(err instanceof Error ? err.message : "Fehler beim Speichern"); - } finally { - setListPageSizeLoading(false); - } - } - - async function handleSetupTOTP() { - setTotpError(""); - setTotpSuccess(""); - setTotpLoading(true); - try { - const data = await getTOTPSetup(); - setQrCode(data.qr_code || ""); - setSecret(data.secret); - setShowSetup(true); - setTotpCode(""); - } catch (err) { - setTotpError(err instanceof Error ? err.message : "TOTP-Setup fehlgeschlagen"); - } finally { - setTotpLoading(false); - } - } - - async function handleConfirmTOTP(e: React.FormEvent) { - e.preventDefault(); - setTotpError(""); - setTotpLoading(true); - try { - await confirmTOTPSetup(totpCode); - setTotpEnabled(true); - setShowSetup(false); - setTotpSuccess("2FA wurde erfolgreich aktiviert."); - setQrCode(""); - setSecret(""); - setTotpCode(""); - } catch (err) { - setTotpError(err instanceof Error ? err.message : "Ungueltiger Code"); - } finally { - setTotpLoading(false); - } - } - - async function handleDisableTOTP() { - setDisableError(""); - setDisableLoading(true); - try { - await disableTOTP(disableCode); - setTotpEnabled(false); - setDisableDialogOpen(false); - setDisableCode(""); - setTotpSuccess("2FA wurde deaktiviert."); - } catch (err) { - setDisableError(err instanceof Error ? err.message : "Ungueltiger Code"); - } finally { - setDisableLoading(false); - } - } - - // ── Loading / auth guard ────────────────────────────────────────────── + const password = usePasswordChange(); + const profile = useProfileSettings(user, refresh); + const totp = useTotpSettings(); + const { systemInfo, systemInfoLoading } = useSystemInfo(); if (loading) { return ( @@ -253,388 +41,15 @@ export default function SettingsPage() { Profil & Einstellungen - {/* ── Card 1: Passwort aendern ─────────────────────────────────── */} - - - Passwort aendern - - -
-
- - setCurrentPw(e.target.value)} - required - autoComplete="current-password" - aria-label="Aktuelles Passwort" - /> -
-
- - setNewPw(e.target.value)} - required - minLength={8} - autoComplete="new-password" - aria-label="Neues Passwort" - /> -
-
- - setConfirmPw(e.target.value)} - required - minLength={8} - autoComplete="new-password" - aria-label="Neues Passwort bestaetigen" - /> -
- {pwError && ( - - {pwError} - - )} - {pwSuccess && ( - - {pwSuccess} - - )} - -
-
-
- - {/* ── Card 2: E-Mail aendern ───────────────────────────────────── */} - - - E-Mail-Adresse aendern - - -
-
- - setEmail(e.target.value)} - required - autoComplete="email" - aria-label="E-Mail-Adresse" - /> -
- {emailError && ( - - {emailError} - - )} - {emailSuccess && ( - - {emailSuccess} - - )} - -
-
-
- - {/* ── Card 2b: Listenanzahl ─────────────────────────────────────── */} - - - Listenansicht - - -
- - -
- {listPageSizeError && ( - - {listPageSizeError} - - )} - {listPageSizeSuccess && ( - - {listPageSizeSuccess} - - )} -
-
- - {/* ── Card 3: Zwei-Faktor-Authentifizierung ────────────────────── */} - - - - Zwei-Faktor-Authentifizierung (2FA) - {totpEnabled ? ( - - Aktiv - - ) : ( - Inaktiv - )} - - - - {totpError && ( - - {totpError} - - )} - {totpSuccess && ( - - {totpSuccess} - - )} - - {!totpEnabled && !showSetup && ( -
-

- Schuetzen Sie Ihr Konto mit einem Einmalpasswort (TOTP). - Kompatibel mit Google Authenticator, Authy und anderen - TOTP-Apps. -

- -
- )} - - {showSetup && ( -
-

- Scannen Sie den QR-Code mit Ihrer Authenticator-App und geben - Sie den angezeigten Code ein. -

- {qrCode && ( -
- TOTP QR-Code -
- )} - {secret && ( -
- - - {secret} - -
- )} -
-
- - setTotpCode(e.target.value)} - required - autoComplete="one-time-code" - aria-label="TOTP Bestaetigungscode" - /> -
-
- - -
-
-
- )} - - {totpEnabled && ( -
-

- 2FA ist aktiv. Zum Deaktivieren benoetigen Sie einen - aktuellen Code aus Ihrer Authenticator-App. -

- -
- )} - - {/* Disable TOTP dialog */} - - - - 2FA deaktivieren - -
-

- Geben Sie einen aktuellen Code aus Ihrer Authenticator-App - ein, um 2FA zu deaktivieren. -

-
- - setDisableCode(e.target.value)} - autoComplete="one-time-code" - aria-label="TOTP-Code zum Deaktivieren" - /> -
- {disableError && ( - - {disableError} - - )} -
- - - - -
-
-
-
- {/* ── Card 4: IMAP-Zugang ──────────────────────────────────────── */} - - - - - - -

- Verbinden Sie Ihren Mail-Client (Thunderbird, Outlook, Apple Mail) - mit folgenden Zugangsdaten: -

- -
- - - - {systemInfoLoading - ? "Laden..." - : systemInfo?.fqdn || - (typeof window !== "undefined" - ? window.location.hostname - : "")} - - - - - - {systemInfo?.imap_port ?? 9993} (SSL/TLS) - - - - - - {systemInfo?.imap_port_alt ?? 993} (SSL/TLS) - - - - - SSL/TLS - - Benutzername - {user.username} - - Passwort - Ihr archivmail-Passwort -
- -
-
-
-
+ + + + + ); diff --git a/src/components/imap/ImapAccountCard.tsx b/src/components/imap/ImapAccountCard.tsx new file mode 100644 index 0000000..c4b5fc2 --- /dev/null +++ b/src/components/imap/ImapAccountCard.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { type ImapAccount } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; + +function statusBadge(status: string) { + switch (status) { + case "running": + return Importiert...; + case "error": + return Fehler; + default: + return Bereit; + } +} + +function syncBadge(acc: ImapAccount) { + if (acc.sync_running) { + return Sync laeuft...; + } + if (!acc.sync_status) return null; + if (acc.sync_status === "ok") { + return Sync OK; + } + if (acc.sync_status === "error") { + return Sync Fehler; + } + return null; +} + +interface ImapAccountCardProps { + acc: ImapAccount; + onStartImport: (id: number) => void; + onSyncNow: (id: number) => void; + onEdit: (acc: ImapAccount) => void; + onDelete: (id: number) => void; + onIntervalChange: (id: number, value: string) => void; +} + +export function ImapAccountCard({ + acc, + onStartImport, + onSyncNow, + onEdit, + onDelete, + onIntervalChange, +}: ImapAccountCardProps) { + return ( + + +
+

{acc.name}

+

+ {acc.host}:{acc.port} ({acc.tls.toUpperCase()}) · {acc.username} +

+
+ {statusBadge(acc.status)} +
+ + {acc.status === "running" && acc.progress_total === 0 && ( +
+ +

+ Zaehle E-Mails auf dem Server... +

+
+ )} + {acc.status === "running" && acc.progress_total > 0 && ( +
+ +

+ {acc.progress_current} von {acc.progress_total} E-Mails +

+
+ )} + + {acc.status === "error" && acc.error_msg && ( +

{acc.error_msg}

+ )} + + {acc.last_import_at && ( +

+ Letzter Import:{" "} + {new Date(acc.last_import_at).toLocaleString("de-DE")} ( + {acc.last_import_count} E-Mails) +

+ )} + + {/* PROJ-8: Sync status */} + {acc.last_sync_at && ( +
+

+ Letzter Sync:{" "} + {new Date(acc.last_sync_at).toLocaleString("de-DE")} ( + {acc.last_sync_count} neu) +

+ {syncBadge(acc)} +
+ )} + + {acc.sync_running && !acc.last_sync_at && syncBadge(acc)} + + {acc.sync_error_msg && acc.sync_status === "error" && ( +

+ Sync-Fehler: {acc.sync_error_msg} +

+ )} + + {acc.excluded_folders && acc.excluded_folders.length > 0 && ( +

+ Ausgeschlossene Ordner: {acc.excluded_folders.join(", ")} +

+ )} + + {/* PROJ-8: Sync interval selector */} +
+ + Auto-Sync: + + +
+ +
+ + + + +
+
+
+ ); +} diff --git a/src/components/imap/ImapAccountDialog.tsx b/src/components/imap/ImapAccountDialog.tsx new file mode 100644 index 0000000..58506f2 --- /dev/null +++ b/src/components/imap/ImapAccountDialog.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { type ImapFolder } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; + +interface ImapAccountDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + formName: string; + setFormName: (v: string) => void; + formHost: string; + setFormHost: (v: string) => void; + formPort: string; + setFormPort: (v: string) => void; + formTls: string; + setFormTls: (v: string) => void; + formUsername: string; + setFormUsername: (v: string) => void; + formPassword: string; + setFormPassword: (v: string) => void; + testing: boolean; + testError: string; + testFolders: ImapFolder[] | null; + excludedFolders: Set; + saving: boolean; + onTest: () => void; + onSave: () => void; + onCancel: () => void; + onToggleExcluded: (name: string) => void; +} + +export function ImapAccountDialog({ + open, + onOpenChange, + formName, + setFormName, + formHost, + setFormHost, + formPort, + setFormPort, + formTls, + setFormTls, + formUsername, + setFormUsername, + formPassword, + setFormPassword, + testing, + testError, + testFolders, + excludedFolders, + saving, + onTest, + onSave, + onCancel, + onToggleExcluded, +}: ImapAccountDialogProps) { + return ( + + + + IMAP-Konto hinzufuegen + +
+
+ + setFormName(e.target.value)} + /> +
+
+
+ + setFormHost(e.target.value)} + /> +
+
+ + setFormPort(e.target.value)} + /> +
+
+
+ + +
+
+ + setFormUsername(e.target.value)} + /> +
+
+ + setFormPassword(e.target.value)} + /> +
+ + + + {testError &&

{testError}

} + + {testFolders && ( + <> + +
+

Erkannte Ordner

+
+ {testFolders.map((folder) => ( +
+ onToggleExcluded(folder.name)} + /> + + {folder.excluded && folder.reason && ( + + ({folder.reason === "special_use" + ? "IMAP-Flag" + : "Namens-Erkennung"}) + + )} +
+ ))} +
+

+ Deaktivierte Ordner werden nicht importiert. +

+
+ + )} +
+ + + + +
+
+ ); +} diff --git a/src/components/imap/ImapDeleteDialog.tsx b/src/components/imap/ImapDeleteDialog.tsx new file mode 100644 index 0000000..cd98410 --- /dev/null +++ b/src/components/imap/ImapDeleteDialog.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +interface ImapDeleteDialogProps { + deleteConfirm: number | null; + onCancel: () => void; + onConfirm: (id: number) => void; +} + +export function ImapDeleteDialog({ + deleteConfirm, + onCancel, + onConfirm, +}: ImapDeleteDialogProps) { + return ( + + + + Konto loeschen? + +

+ Soll dieses IMAP-Konto wirklich entfernt werden? Bereits importierte + E-Mails bleiben im Archiv erhalten. +

+ + + + +
+
+ ); +} diff --git a/src/components/imap/ImapEditDialog.tsx b/src/components/imap/ImapEditDialog.tsx new file mode 100644 index 0000000..1a48d3d --- /dev/null +++ b/src/components/imap/ImapEditDialog.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { type ImapAccount } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface ImapEditDialogProps { + editAccount: ImapAccount | null; + onClose: () => void; + editName: string; + setEditName: (v: string) => void; + editHost: string; + setEditHost: (v: string) => void; + editPort: string; + setEditPort: (v: string) => void; + editTls: string; + setEditTls: (v: string) => void; + editUsername: string; + setEditUsername: (v: string) => void; + editPassword: string; + setEditPassword: (v: string) => void; + editSaving: boolean; + editError: string; + onSave: () => void; +} + +export function ImapEditDialog({ + editAccount, + onClose, + editName, + setEditName, + editHost, + setEditHost, + editPort, + setEditPort, + editTls, + setEditTls, + editUsername, + setEditUsername, + editPassword, + setEditPassword, + editSaving, + editError, + onSave, +}: ImapEditDialogProps) { + return ( + { + if (!open) onClose(); + }} + > + + + IMAP-Konto bearbeiten + +
+
+ + setEditName(e.target.value)} + placeholder="z.B. Firmen-Mail" + /> +
+
+
+ + setEditHost(e.target.value)} + placeholder="imap.example.com" + /> +
+
+ + setEditPort(e.target.value)} + type="number" + /> +
+
+
+ + +
+
+ + setEditUsername(e.target.value)} + placeholder="user@example.com" + /> +
+
+ + setEditPassword(e.target.value)} + type="password" + placeholder="Neues Passwort eingeben" + /> +
+ {editError &&

{editError}

} +
+ + + + +
+
+ ); +} diff --git a/src/components/search/ExportDialogs.tsx b/src/components/search/ExportDialogs.tsx new file mode 100644 index 0000000..4df276c --- /dev/null +++ b/src/components/search/ExportDialogs.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { type UploadJob } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +interface ExportZipDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedCount: number; + exportAttachments: boolean; + setExportAttachments: (v: boolean) => void; + exporting: boolean; + onExport: () => void; +} + +export function ExportZipDialog({ + open, + onOpenChange, + selectedCount, + exportAttachments, + setExportAttachments, + exporting, + onExport, +}: ExportZipDialogProps) { + return ( + + + + E-Mails exportieren + + {selectedCount} E-Mail{selectedCount !== 1 ? "s" : ""} als ZIP herunterladen + + +
+ + +
+ + + + +
+
+ ); +} + +interface EDiscoveryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + caseName: string; + setCaseName: (v: string) => void; + loading: boolean; + onExport: () => void; + query: string; + fromFilter: string; + toFilter: string; + dateFrom: string; + dateTo: string; +} + +export function EDiscoveryDialog({ + open, + onOpenChange, + caseName, + setCaseName, + loading, + onExport, + query, + fromFilter, + toFilter, + dateFrom, + dateTo, +}: EDiscoveryDialogProps) { + return ( + + + + eDiscovery Export + + Exportiert alle Mails der aktuellen Suche als ZIP mit Metadaten-CSV und README. + + +
+
+ + setCaseName(e.target.value)} + /> +
+
+
Aktive Filter:
+ {query &&
Suche: {query}
} + {fromFilter &&
Von: {fromFilter}
} + {toFilter &&
An: {toFilter}
} + {dateFrom &&
Von Datum: {dateFrom}
} + {dateTo &&
Bis Datum: {dateTo}
} + {!query && !fromFilter && !toFilter && !dateFrom && !dateTo && ( +
Keine Filter — alle archivierten Mails werden exportiert
+ )} +
+
+ + + + +
+
+ ); +} + +interface UploadDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + dragging: boolean; + setDragging: (v: boolean) => void; + job: UploadJob | null; + error: string; + loading: boolean; + onUploadFiles: (files: FileList | File[]) => void; + onClose: () => void; +} + +export function UploadDialog({ + open, + onOpenChange, + dragging, + setDragging, + job, + error, + loading, + onUploadFiles, + onClose, +}: UploadDialogProps) { + return ( + + + + E-Mails importieren + + EML- oder MBOX-Dateien in das Archiv hochladen + + + + {!job && ( +
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + if (e.dataTransfer.files.length > 0) onUploadFiles(e.dataTransfer.files); + }} + > +

+ Dateien hierher ziehen oder auswählen +

+ +

.eml · .mbox

+
+ )} + + {error && ( +

{error}

+ )} + + {job && ( +
+ 0 ? Math.round(((job.imported + job.skipped + job.errors) / job.total) * 100) : 0} + /> +
+
+
{job.imported}
+
Importiert
+
+
+
{job.skipped}
+
Duplikate
+
+
+
{job.errors}
+
Fehler
+
+
+ {job.status === "running" && ( +

+ Verarbeite {job.imported + job.skipped + job.errors} / {job.total} … +

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

Abgeschlossen

+ )} +
+ )} + + + + +
+
+ ); +} diff --git a/src/components/search/SearchFilterBar.tsx b/src/components/search/SearchFilterBar.tsx new file mode 100644 index 0000000..ebb59b3 --- /dev/null +++ b/src/components/search/SearchFilterBar.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { type SavedSearch } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Bookmark, BookmarkPlus, Trash2 } from "lucide-react"; + +interface SearchFilterBarProps { + query: string; + setQuery: (v: string) => void; + fromFilter: string; + setFromFilter: (v: string) => void; + toFilter: string; + setToFilter: (v: string) => void; + dateFrom: string; + setDateFrom: (v: string) => void; + dateTo: string; + setDateTo: (v: string) => void; + sort: string; + setSort: (v: string) => void; + hasAttachment: boolean | undefined; + setHasAttachment: (v: boolean | undefined) => void; + searching: boolean; + onSubmit: (e: React.FormEvent) => void; + onOpenUpload: () => void; + // Saved searches + hasActiveSearch: boolean; + savePopoverOpen: boolean; + setSavePopoverOpen: (v: boolean) => void; + saveName: string; + setSaveName: (v: string) => void; + saving: boolean; + onSaveSearch: () => void; + savedListOpen: boolean; + setSavedListOpen: (v: boolean) => void; + savedLoading: boolean; + savedSearches: SavedSearch[]; + onApplySavedSearch: (s: SavedSearch) => void; + onDeleteSavedSearch: (id: number) => void; +} + +export function SearchFilterBar(props: SearchFilterBarProps) { + const { + query, + setQuery, + fromFilter, + setFromFilter, + toFilter, + setToFilter, + dateFrom, + setDateFrom, + dateTo, + setDateTo, + sort, + setSort, + hasAttachment, + setHasAttachment, + searching, + onSubmit, + onOpenUpload, + hasActiveSearch, + savePopoverOpen, + setSavePopoverOpen, + saveName, + setSaveName, + saving, + onSaveSearch, + savedListOpen, + setSavedListOpen, + savedLoading, + savedSearches, + onApplySavedSearch, + onDeleteSavedSearch, + } = props; + + return ( +
+
+ setQuery(e.target.value)} + className="w-full flex-1 sm:w-auto" + aria-label="Suchbegriff" + /> + + + {hasActiveSearch && ( + + + + + +
+

Suche speichern

+ setSaveName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") onSaveSearch(); }} + aria-label="Name der gespeicherten Suche" + autoFocus + /> +
+ + +
+
+
+
+ )} + + + + + +

Gespeicherte Suchen

+ {savedLoading ? ( +
+ + +
+ ) : savedSearches.length === 0 ? ( +

+ Keine gespeicherten Suchen vorhanden. +

+ ) : ( +
+ {savedSearches.map((s) => ( +
+ + + {new Date(s.created_at).toLocaleDateString("de-DE")} + + +
+ ))} +
+ )} +
+
+
+ +
+
+ + setFromFilter(e.target.value)} + aria-label="Absender filtern" + /> +
+
+ + setToFilter(e.target.value)} + aria-label="Empfänger filtern" + /> +
+
+ + setDateFrom(e.target.value)} + aria-label="Datum von" + /> +
+
+ + setDateTo(e.target.value)} + aria-label="Datum bis" + /> +
+
+ +
+
+ + +
+
+ + setHasAttachment(checked ? true : undefined) + } + /> + +
+
+
+ ); +} diff --git a/src/components/search/SearchResultsTable.tsx b/src/components/search/SearchResultsTable.tsx new file mode 100644 index 0000000..30ac95f --- /dev/null +++ b/src/components/search/SearchResultsTable.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { type SearchHit, type SearchMatchField } from "@/lib/api"; +import { sanitizeSnippet } from "@/lib/sanitize"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +const MATCH_FIELD_LABEL: Record = { + subject: "📨 Subject", + body: "✉️ Body", + attachment_text: "📄 PDF-Anhang", + attachment_names: "📎 Dateiname", + from_addr: "👤 Absender", + to_addr: "📧 Empfänger", +}; + +function MatchSourceBadge({ field }: { field: SearchMatchField }) { + return ( + + {MATCH_FIELD_LABEL[field]} + + ); +} + +function SnippetLine({ hit }: { hit: SearchHit }) { + if (!hit.snippet) return null; + return ( +
+ {hit.match_field && } + +
+ ); +} + +interface SearchResultsTableProps { + results: SearchHit[]; + selected: Set; + setSelected: React.Dispatch>>; + total: number; + page: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export function SearchResultsTable({ + results, + selected, + setSelected, + page, + totalPages, + onPageChange, +}: SearchResultsTableProps) { + const router = useRouter(); + const allSelected = results.length > 0 && results.every((h) => selected.has(h.id)); + + return ( + <> + + + + + + { + if (checked) setSelected(new Set(results.map((h) => h.id))); + else setSelected(new Set()); + }} + aria-label="Alle auswählen" + /> + + Datum + Von + Betreff + An + 📎 + Größe + + + + {results.map((hit) => ( + router.push(`/mail/${hit.id}`)} + role="link" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/mail/${hit.id}`); + }} + aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`} + > + e.stopPropagation()}> + { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(hit.id); + else next.delete(hit.id); + return next; + }); + }} + aria-label="Mail auswählen" + /> + + + {hit.date + ? new Date(hit.date).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" }) + : "-"} + + {hit.from || "-"} + +
+ {hit.subject || "(kein Betreff)"} + {hit.thread_size && hit.thread_size > 1 && ( + + {hit.thread_size} + + )} +
+ {/* Absender nur auf Mobile, da Spalte "Von" dort ausgeblendet ist */} + {hit.from && ( +
+ {hit.from} +
+ )} + +
+ {hit.to || "-"} + + {hit.has_attachments ? "📎" : ""} + + + {hit.size ? formatBytes(hit.size) : ""} + +
+ ))} +
+
+
+ + {totalPages > 1 && ( +
+ + + Seite {page} von {totalPages} + + +
+ )} + + ); +} diff --git a/src/components/settings/DisplaySection.tsx b/src/components/settings/DisplaySection.tsx new file mode 100644 index 0000000..d53c993 --- /dev/null +++ b/src/components/settings/DisplaySection.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { useProfileSettings } from "@/hooks/useProfileSettings"; + +type DisplaySectionProps = Pick< + ReturnType, + | "listPageSize" + | "listPageSizeError" + | "listPageSizeSuccess" + | "listPageSizeLoading" + | "handleChangeListPageSize" +>; + +export function DisplaySection({ + listPageSize, + listPageSizeError, + listPageSizeSuccess, + listPageSizeLoading, + handleChangeListPageSize, +}: DisplaySectionProps) { + return ( + + + Listenansicht + + +
+ + +
+ {listPageSizeError && ( + + {listPageSizeError} + + )} + {listPageSizeSuccess && ( + + {listPageSizeSuccess} + + )} +
+
+ ); +} diff --git a/src/components/settings/ImapSection.tsx b/src/components/settings/ImapSection.tsx new file mode 100644 index 0000000..c49c2c1 --- /dev/null +++ b/src/components/settings/ImapSection.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Server, Lock, Info } from "lucide-react"; +import type { SystemInfo } from "@/lib/api"; + +interface ImapSectionProps { + systemInfo: SystemInfo | null; + systemInfoLoading: boolean; + username: string; +} + +export function ImapSection({ + systemInfo, + systemInfoLoading, + username, +}: ImapSectionProps) { + return ( + + + + + + +

+ Verbinden Sie Ihren Mail-Client (Thunderbird, Outlook, Apple Mail) + mit folgenden Zugangsdaten: +

+ +
+ + + + {systemInfoLoading + ? "Laden..." + : systemInfo?.fqdn || + (typeof window !== "undefined" + ? window.location.hostname + : "")} + + + + + + {systemInfo?.imap_port ?? 9993} (SSL/TLS) + + + + + + {systemInfo?.imap_port_alt ?? 993} (SSL/TLS) + + + + + SSL/TLS + + Benutzername + {username} + + Passwort + Ihr archivmail-Passwort +
+ +
+
+
+
+ ); +} diff --git a/src/components/settings/PasswordSection.tsx b/src/components/settings/PasswordSection.tsx new file mode 100644 index 0000000..fd5e166 --- /dev/null +++ b/src/components/settings/PasswordSection.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import type { usePasswordChange } from "@/hooks/usePasswordChange"; + +type PasswordSectionProps = ReturnType; + +export function PasswordSection({ + currentPw, + setCurrentPw, + newPw, + setNewPw, + confirmPw, + setConfirmPw, + pwError, + pwSuccess, + pwLoading, + handleChangePassword, +}: PasswordSectionProps) { + return ( + + + Passwort aendern + + +
+
+ + setCurrentPw(e.target.value)} + required + autoComplete="current-password" + aria-label="Aktuelles Passwort" + /> +
+
+ + setNewPw(e.target.value)} + required + minLength={8} + autoComplete="new-password" + aria-label="Neues Passwort" + /> +
+
+ + setConfirmPw(e.target.value)} + required + minLength={8} + autoComplete="new-password" + aria-label="Neues Passwort bestaetigen" + /> +
+ {pwError && ( + + {pwError} + + )} + {pwSuccess && ( + + {pwSuccess} + + )} + +
+
+
+ ); +} diff --git a/src/components/settings/ProfileSection.tsx b/src/components/settings/ProfileSection.tsx new file mode 100644 index 0000000..0508b8b --- /dev/null +++ b/src/components/settings/ProfileSection.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import type { useProfileSettings } from "@/hooks/useProfileSettings"; + +type ProfileSectionProps = Pick< + ReturnType, + "email" | "setEmail" | "emailError" | "emailSuccess" | "emailLoading" | "handleChangeEmail" +>; + +export function ProfileSection({ + email, + setEmail, + emailError, + emailSuccess, + emailLoading, + handleChangeEmail, +}: ProfileSectionProps) { + return ( + + + E-Mail-Adresse aendern + + +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + aria-label="E-Mail-Adresse" + /> +
+ {emailError && ( + + {emailError} + + )} + {emailSuccess && ( + + {emailSuccess} + + )} + +
+
+
+ ); +} diff --git a/src/components/settings/TotpSection.tsx b/src/components/settings/TotpSection.tsx new file mode 100644 index 0000000..822d14f --- /dev/null +++ b/src/components/settings/TotpSection.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import type { useTotpSettings } from "@/hooks/useTotpSettings"; + +type TotpSectionProps = ReturnType; + +export function TotpSection({ + totpEnabled, + showSetup, + qrCode, + secret, + totpCode, + setTotpCode, + totpError, + totpSuccess, + totpLoading, + disableDialogOpen, + setDisableDialogOpen, + disableCode, + setDisableCode, + disableError, + disableLoading, + handleSetupTOTP, + handleConfirmTOTP, + handleDisableTOTP, + cancelSetup, + openDisableDialog, +}: TotpSectionProps) { + return ( + + + + Zwei-Faktor-Authentifizierung (2FA) + {totpEnabled ? ( + + Aktiv + + ) : ( + Inaktiv + )} + + + + {totpError && ( + + {totpError} + + )} + {totpSuccess && ( + + {totpSuccess} + + )} + + {!totpEnabled && !showSetup && ( +
+

+ Schuetzen Sie Ihr Konto mit einem Einmalpasswort (TOTP). + Kompatibel mit Google Authenticator, Authy und anderen + TOTP-Apps. +

+ +
+ )} + + {showSetup && ( +
+

+ Scannen Sie den QR-Code mit Ihrer Authenticator-App und geben + Sie den angezeigten Code ein. +

+ {qrCode && ( +
+ TOTP QR-Code +
+ )} + {secret && ( +
+ + + {secret} + +
+ )} +
+
+ + setTotpCode(e.target.value)} + required + autoComplete="one-time-code" + aria-label="TOTP Bestaetigungscode" + /> +
+
+ + +
+
+
+ )} + + {totpEnabled && ( +
+

+ 2FA ist aktiv. Zum Deaktivieren benoetigen Sie einen + aktuellen Code aus Ihrer Authenticator-App. +

+ +
+ )} + + {/* Disable TOTP dialog */} + + + + 2FA deaktivieren + +
+

+ Geben Sie einen aktuellen Code aus Ihrer Authenticator-App + ein, um 2FA zu deaktivieren. +

+
+ + setDisableCode(e.target.value)} + autoComplete="one-time-code" + aria-label="TOTP-Code zum Deaktivieren" + /> +
+ {disableError && ( + + {disableError} + + )} +
+ + + + +
+
+
+
+ ); +} diff --git a/src/hooks/useAdminAudit.ts b/src/hooks/useAdminAudit.ts new file mode 100644 index 0000000..30643cd --- /dev/null +++ b/src/hooks/useAdminAudit.ts @@ -0,0 +1,35 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { getAuditLog, type AuditEntry } from "@/lib/api"; + +const AUDIT_PAGE_SIZE = 25; + +export function useAdminAudit() { + const [auditEntries, setAuditEntries] = useState([]); + const [auditTotal, setAuditTotal] = useState(0); + const [auditPage, setAuditPage] = useState(1); + const [auditLoading, setAuditLoading] = useState(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); + } + }, []); + + return { + auditEntries, + auditTotal, + auditPage, + auditLoading, + loadAudit, + }; +} diff --git a/src/hooks/useAdminCert.ts b/src/hooks/useAdminCert.ts new file mode 100644 index 0000000..14996ea --- /dev/null +++ b/src/hooks/useAdminCert.ts @@ -0,0 +1,71 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { getCertInfo, type CertInfo } from "@/lib/api"; + +export function useAdminCert() { + const [certInfo, setCertInfo] = useState(null); + const [certLoading, setCertLoading] = useState(false); + const [certError, setCertError] = useState(""); + const [certSuccess, setCertSuccess] = useState(""); + const [certFile, setCertFile] = useState(null); + const [keyFile, setKeyFile] = useState(null); + const [certUploadLoading, setCertUploadLoading] = useState(false); + 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); + const [acmeDomain, setAcmeDomain] = useState(""); + const [acmeEmail, setAcmeEmail] = useState(""); + const [acmeLoading, setAcmeLoading] = useState(false); + const [acmeOutput, setAcmeOutput] = useState(""); + + const loadCert = useCallback(async () => { + setCertLoading(true); + setCertError(""); + try { + const info = await getCertInfo(); + setCertInfo(info); + } catch (e) { + setCertError(String(e)); + } finally { + setCertLoading(false); + } + }, []); + + return { + certInfo, + setCertInfo, + certLoading, + certError, + setCertError, + certSuccess, + setCertSuccess, + certFile, + setCertFile, + keyFile, + setKeyFile, + certUploadLoading, + setCertUploadLoading, + selfSignedCN, + setSelfSignedCN, + selfSignedDNS, + setSelfSignedDNS, + selfSignedIPs, + setSelfSignedIPs, + selfSignedYears, + setSelfSignedYears, + selfSignedLoading, + setSelfSignedLoading, + acmeDomain, + setAcmeDomain, + acmeEmail, + setAcmeEmail, + acmeLoading, + setAcmeLoading, + acmeOutput, + setAcmeOutput, + loadCert, + }; +} diff --git a/src/hooks/useAdminDashboard.ts b/src/hooks/useAdminDashboard.ts new file mode 100644 index 0000000..26c58ee --- /dev/null +++ b/src/hooks/useAdminDashboard.ts @@ -0,0 +1,86 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import { + getSMTPStatus, + getHealth, + getStorageStats, + getSystemStats, + getMailTimeseries, + type SMTPStatus, + type StorageStats, + type SystemStats, + type TimeseriesPoint, +} from "@/lib/api"; + +export function useAdminDashboard(active: boolean) { + const [smtpStatus, setSmtpStatus] = useState(null); + const [storageStats, setStorageStats] = useState(null); + const [systemStats, setSystemStats] = useState(null); + const [timeseries, setTimeseries] = useState([]); + const [apiOnline, setApiOnline] = useState(null); + const [dashLoading, setDashLoading] = useState(true); + const [dashRefreshed, setDashRefreshed] = useState(null); + const [countdown, setCountdown] = useState(30); + + const dashIntervalRef = useRef | null>(null); + + const loadDashboard = useCallback(async () => { + setDashLoading(true); + try { + const [smtp, health, storage, sysStats, ts] = await Promise.allSettled([ + getSMTPStatus(), + getHealth(), + getStorageStats(), + getSystemStats(), + getMailTimeseries(30), + ]); + 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); + setTimeseries(ts.status === "fulfilled" ? ts.value.points : []); + setDashRefreshed(new Date()); + } finally { + setDashLoading(false); + } + }, []); + + useEffect(() => { + if (!active) return; + loadDashboard(); + + setCountdown(30); + dashIntervalRef.current = setInterval(() => { + loadDashboard(); + setCountdown(30); + }, 30_000); + + const ticker = setInterval(() => { + setCountdown((c) => (c > 0 ? c - 1 : 0)); + }, 1_000); + + return () => { + if (dashIntervalRef.current) clearInterval(dashIntervalRef.current); + clearInterval(ticker); + }; + }, [active, loadDashboard]); + + const refresh = useCallback(() => { + loadDashboard(); + setCountdown(30); + }, [loadDashboard]); + + return { + smtpStatus, + storageStats, + systemStats, + timeseries, + apiOnline, + dashLoading, + dashRefreshed, + countdown, + loadDashboard, + refresh, + }; +} diff --git a/src/hooks/useAdminSecurity.ts b/src/hooks/useAdminSecurity.ts new file mode 100644 index 0000000..083a488 --- /dev/null +++ b/src/hooks/useAdminSecurity.ts @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import { + getSecurityAudit, + fixSecurityIssue, + type SecurityAuditResult, +} from "@/lib/api"; + +export function useAdminSecurity() { + const [securityAudit, setSecurityAudit] = useState(null); + const [securityLoading, setSecurityLoading] = useState(false); + const [securityError, setSecurityError] = useState(""); + const [fixLoading, setFixLoading] = useState(null); + const [fixMessage, setFixMessage] = useState(""); + + 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); + await runSecurityAudit(); + } catch (e: unknown) { + setSecurityError(e instanceof Error ? e.message : "Fix fehlgeschlagen."); + } finally { + setFixLoading(null); + } + } + + return { + securityAudit, + securityLoading, + securityError, + fixLoading, + fixMessage, + runSecurityAudit, + runFix, + }; +} diff --git a/src/hooks/useAdminServices.ts b/src/hooks/useAdminServices.ts new file mode 100644 index 0000000..0118782 --- /dev/null +++ b/src/hooks/useAdminServices.ts @@ -0,0 +1,46 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { getServices, serviceAction, type ServiceStatus } from "@/lib/api"; + +export function useAdminServices() { + const [services, setServices] = useState([]); + const [servicesLoading, setServicesLoading] = useState(false); + const [serviceActionLoading, setServiceActionLoading] = useState(null); + const [serviceError, setServiceError] = useState(""); + + 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 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); + } + } + + return { + services, + servicesLoading, + serviceActionLoading, + serviceError, + loadServices, + handleServiceAction, + }; +} diff --git a/src/hooks/useAdminTenants.ts b/src/hooks/useAdminTenants.ts new file mode 100644 index 0000000..4d8b080 --- /dev/null +++ b/src/hooks/useAdminTenants.ts @@ -0,0 +1,171 @@ +"use client"; + +import { useState, useCallback, type Dispatch, type SetStateAction } from "react"; +import { + getTenants, + createTenant, + updateTenant, + deleteTenant, + getTenantDomains, + addTenantDomain, + removeTenantDomain, + type Tenant, + type TenantDefaultUser, + type TenantDomain, +} from "@/lib/api"; + +export function useAdminTenants() { + const [tenants, setTenants] = useState([]); + 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 [tenantCreatedUsers, setTenantCreatedUsers] = useState([]); + const [tenantCreatedName, setTenantCreatedName] = useState(""); + const [tenantCredDialogOpen, setTenantCredDialogOpen] = useState(false); + const [tenantDeleteId, setTenantDeleteId] = useState(null); + const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false); + const [domainDialogTenant, setDomainDialogTenant] = useState(null); + const [tenantDomains, setTenantDomains] = useState([]); + const [domainsLoading, setDomainsLoading] = useState(false); + const [newDomain, setNewDomain] = useState(""); + const [addDomainLoading, setAddDomainLoading] = useState(false); + const [domainError, setDomainError] = useState(""); + + // Superadmin: tenant LDAP dialog + const [tenantLdapDialogId, setTenantLdapDialogId] = useState(null); + + 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 { + const result = await createTenant(newTenantName, newTenantSlug); + setTenantDialogOpen(false); + setTenantCreatedName(result.name); + setTenantCreatedUsers(result.default_users ?? []); + setTenantCredDialogOpen(true); + 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."); + } + } + + return { + tenants, + setTenants: setTenants as Dispatch>, + tenantsLoading, + tenantsError, + tenantDialogOpen, + setTenantDialogOpen, + newTenantName, + setNewTenantName, + newTenantSlug, + setNewTenantSlug, + tenantCreateLoading, + tenantCreateError, + tenantCreatedUsers, + tenantCreatedName, + tenantCredDialogOpen, + setTenantCredDialogOpen, + tenantDeleteId, + setTenantDeleteId, + tenantDeleteLoading, + domainDialogTenant, + setDomainDialogTenant, + tenantDomains, + domainsLoading, + newDomain, + setNewDomain, + addDomainLoading, + domainError, + tenantLdapDialogId, + setTenantLdapDialogId, + loadTenants, + handleCreateTenant, + handleToggleTenant, + handleDeleteTenant, + openDomainDialog, + handleAddDomain, + handleRemoveDomain, + }; +} diff --git a/src/hooks/useAdminUpload.ts b/src/hooks/useAdminUpload.ts new file mode 100644 index 0000000..352942d --- /dev/null +++ b/src/hooks/useAdminUpload.ts @@ -0,0 +1,58 @@ +"use client"; + +import { useState, useRef } from "react"; +import { + uploadMailFiles, + getUploadProgress, + type UploadJob, +} from "@/lib/api"; + +export function useAdminUpload() { + const [uploadDragging, setUploadDragging] = useState(false); + const [uploadJob, setUploadJob] = useState(null); + const [uploadError, setUploadError] = useState(""); + const [uploadLoading, setUploadLoading] = useState(false); + const uploadPollRef = useRef | null>(null); + + 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); + 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); + } + } + + return { + uploadDragging, + setUploadDragging, + uploadJob, + uploadError, + uploadLoading, + handleUploadFiles, + }; +} diff --git a/src/hooks/useAdminUsers.ts b/src/hooks/useAdminUsers.ts new file mode 100644 index 0000000..a6a62c1 --- /dev/null +++ b/src/hooks/useAdminUsers.ts @@ -0,0 +1,169 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + getUsers, + createUser, + updateUser, + deleteUser, + type User, +} from "@/lib/api"; + +export function useAdminUsers() { + const [users, setUsers] = useState([]); + 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(null); + const [resetPasswordUserId, setResetPasswordUserId] = useState(null); + const [resetPasswordValue, setResetPasswordValue] = useState(""); + const [resetPasswordError, setResetPasswordError] = useState(""); + const [resetPasswordLoading, setResetPasswordLoading] = useState(false); + + // Delete confirmation dialog + const [deleteDialogUser, setDeleteDialogUser] = useState(null); + const [deleteActionLoading, setDeleteActionLoading] = useState<"deactivate" | "delete" | null>(null); + const [deleteDialogError, setDeleteDialogError] = useState(""); + + const loadUsers = useCallback(async () => { + setUsersLoading(true); + setUsersError(""); + try { + const data = await getUsers(); + setUsers(data || []); + } catch { + setUsersError("Benutzer konnten nicht geladen werden."); + } finally { + setUsersLoading(false); + } + }, []); + + 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); + } + } + + return { + users, + usersLoading, + usersError, + dialogOpen, + setDialogOpen, + newUsername, + setNewUsername, + newEmail, + setNewEmail, + newPassword, + setNewPassword, + newRole, + setNewRole, + createLoading, + createError, + userActionLoading, + resetPasswordUserId, + setResetPasswordUserId, + resetPasswordValue, + setResetPasswordValue, + resetPasswordError, + setResetPasswordError, + resetPasswordLoading, + deleteDialogUser, + setDeleteDialogUser, + deleteActionLoading, + deleteDialogError, + setDeleteDialogError, + loadUsers, + handleCreateUser, + handleToggleActive, + handleDeactivateConfirmed, + handleDeleteConfirmed, + handleResetPassword, + }; +} diff --git a/src/hooks/useImapAccounts.ts b/src/hooks/useImapAccounts.ts new file mode 100644 index 0000000..70ebe4a --- /dev/null +++ b/src/hooks/useImapAccounts.ts @@ -0,0 +1,334 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + getImapAccounts, + createImapAccount, + deleteImapAccount, + testImapConnection, + startImapImport, + getImapProgress, + triggerImapSync, + updateImapInterval, + updateImapAccount, + type ImapAccount, + type ImapFolder, +} from "@/lib/api"; + +export function useImapAccounts(user: unknown) { + const [accounts, setAccounts] = useState([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + // Edit state + const [editAccount, setEditAccount] = useState(null); + const [editName, setEditName] = useState(""); + const [editHost, setEditHost] = useState(""); + const [editPort, setEditPort] = useState("993"); + const [editTls, setEditTls] = useState("ssl"); + const [editUsername, setEditUsername] = useState(""); + const [editPassword, setEditPassword] = useState(""); + const [editSaving, setEditSaving] = useState(false); + const [editError, setEditError] = useState(""); + + // Form state + const [formName, setFormName] = useState(""); + const [formHost, setFormHost] = useState(""); + const [formPort, setFormPort] = useState("993"); + const [formTls, setFormTls] = useState("ssl"); + const [formUsername, setFormUsername] = useState(""); + const [formPassword, setFormPassword] = useState(""); + + // Test state + const [testing, setTesting] = useState(false); + const [testError, setTestError] = useState(""); + const [testFolders, setTestFolders] = useState(null); + const [excludedFolders, setExcludedFolders] = useState>(new Set()); + + // Saving state + const [saving, setSaving] = useState(false); + + // Import error state + const [importError, setImportError] = useState(""); + + // Polling refs + const pollingRefs = useRef>>(new Map()); + const pollErrorCount = useRef>(new Map()); + + const loadAccounts = useCallback(async () => { + try { + const data = await getImapAccounts(); + setAccounts(data); + } catch { + // ignore + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (user) loadAccounts(); + }, [user, loadAccounts]); + + // Start polling for running accounts (import or sync) + useEffect(() => { + for (const acc of accounts) { + const isActive = acc.status === "running" || acc.sync_running; + if (isActive && !pollingRefs.current.has(acc.id)) { + pollErrorCount.current.set(acc.id, 0); + const interval = setInterval(async () => { + try { + const updated = await getImapProgress(acc.id); + pollErrorCount.current.set(acc.id, 0); + setAccounts((prev) => + prev.map((a) => (a.id === updated.id ? updated : a)) + ); + if (updated.status !== "running" && !updated.sync_running) { + clearInterval(pollingRefs.current.get(acc.id)!); + pollingRefs.current.delete(acc.id); + pollErrorCount.current.delete(acc.id); + } + } catch { + // Only stop polling after 5 consecutive failures (tolerates brief network hiccups) + const errors = (pollErrorCount.current.get(acc.id) ?? 0) + 1; + pollErrorCount.current.set(acc.id, errors); + if (errors >= 5) { + clearInterval(pollingRefs.current.get(acc.id)!); + pollingRefs.current.delete(acc.id); + pollErrorCount.current.delete(acc.id); + } + } + }, 2000); + pollingRefs.current.set(acc.id, interval); + } + } + // Cleanup intervals for accounts that are no longer active + for (const [id, interval] of pollingRefs.current) { + const acc = accounts.find((a) => a.id === id); + if (!acc || (acc.status !== "running" && !acc.sync_running)) { + clearInterval(interval); + pollingRefs.current.delete(id); + } + } + }, [accounts]); + + // Cleanup on unmount + useEffect(() => { + const refs = pollingRefs.current; + return () => { + for (const interval of refs.values()) { + clearInterval(interval); + } + }; + }, []); + + const resetForm = useCallback(() => { + setFormName(""); + setFormHost(""); + setFormPort("993"); + setFormTls("ssl"); + setFormUsername(""); + setFormPassword(""); + setTestFolders(null); + setTestError(""); + setExcludedFolders(new Set()); + }, []); + + async function handleTest() { + setTesting(true); + setTestError(""); + setTestFolders(null); + try { + const result = await testImapConnection({ + host: formHost, + port: parseInt(formPort, 10) || 993, + tls: formTls, + username: formUsername, + password: formPassword, + }); + if (result.ok && result.folders) { + setTestFolders(result.folders); + const excluded = new Set(); + for (const f of result.folders) { + if (f.excluded) excluded.add(f.name); + } + setExcludedFolders(excluded); + } else { + setTestError(result.error || "Verbindungstest fehlgeschlagen"); + } + } catch (err) { + setTestError(err instanceof Error ? err.message : "Verbindungstest fehlgeschlagen"); + } finally { + setTesting(false); + } + } + + async function handleSave() { + setSaving(true); + try { + await createImapAccount({ + name: formName, + host: formHost, + port: parseInt(formPort, 10) || 993, + tls: formTls, + username: formUsername, + password: formPassword, + excluded_folders: Array.from(excludedFolders), + }); + setDialogOpen(false); + resetForm(); + await loadAccounts(); + } catch (err) { + setTestError(err instanceof Error ? err.message : "Speichern fehlgeschlagen"); + } finally { + setSaving(false); + } + } + + async function handleStartImport(id: number) { + setImportError(""); + try { + const updated = await startImapImport(id); + setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a))); + } catch (err) { + setImportError(err instanceof Error ? err.message : "Import konnte nicht gestartet werden."); + } + } + + async function handleDelete(id: number) { + try { + await deleteImapAccount(id); + setAccounts((prev) => prev.filter((a) => a.id !== id)); + } catch (err) { + alert("Fehler beim Löschen: " + (err instanceof Error ? err.message : String(err))); + } + setDeleteConfirm(null); + } + + function openEdit(acc: ImapAccount) { + setEditAccount(acc); + setEditName(acc.name); + setEditHost(acc.host); + setEditPort(String(acc.port)); + setEditTls(acc.tls); + setEditUsername(acc.username); + setEditPassword(""); + setEditError(""); + } + + async function handleEditSave() { + if (!editAccount) return; + if (!editName || !editHost || !editUsername) { + setEditError("Name, Host und Benutzername sind Pflichtfelder."); + return; + } + setEditSaving(true); + setEditError(""); + try { + const updated = await updateImapAccount(editAccount.id, { + name: editName, + host: editHost, + port: parseInt(editPort, 10), + tls: editTls, + username: editUsername, + password: editPassword || undefined, + }); + setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a))); + setEditAccount(null); + } catch (err) { + setEditError(err instanceof Error ? err.message : String(err)); + } finally { + setEditSaving(false); + } + } + + async function handleSyncNow(id: number) { + try { + const updated = await triggerImapSync(id); + setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a))); + } catch { + // ignore — conflict means sync already running + } + } + + async function handleIntervalChange(id: number, value: string) { + const intervalMin = parseInt(value, 10); + try { + const updated = await updateImapInterval(id, intervalMin); + setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a))); + } catch { + // ignore + } + } + + function toggleExcluded(folderName: string) { + setExcludedFolders((prev) => { + const next = new Set(prev); + if (next.has(folderName)) { + next.delete(folderName); + } else { + next.add(folderName); + } + return next; + }); + } + + return { + accounts, + loading, + dialogOpen, + setDialogOpen, + deleteConfirm, + setDeleteConfirm, + // edit + editAccount, + setEditAccount, + editName, + setEditName, + editHost, + setEditHost, + editPort, + setEditPort, + editTls, + setEditTls, + editUsername, + setEditUsername, + editPassword, + setEditPassword, + editSaving, + editError, + // form + formName, + setFormName, + formHost, + setFormHost, + formPort, + setFormPort, + formTls, + setFormTls, + formUsername, + setFormUsername, + formPassword, + setFormPassword, + // test + testing, + testError, + testFolders, + excludedFolders, + // saving + saving, + importError, + // handlers + resetForm, + handleTest, + handleSave, + handleStartImport, + handleDelete, + openEdit, + handleEditSave, + handleSyncNow, + handleIntervalChange, + toggleExcluded, + }; +} diff --git a/src/hooks/useMailUpload.ts b/src/hooks/useMailUpload.ts new file mode 100644 index 0000000..40e4c09 --- /dev/null +++ b/src/hooks/useMailUpload.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useState, useRef } from "react"; +import { + uploadMailFilesUser, + getUploadProgressUser, + type UploadJob, +} from "@/lib/api"; + +export function useMailUpload(onImportDone: () => void) { + const [uploadOpen, setUploadOpen] = useState(false); + const [uploadDragging, setUploadDragging] = useState(false); + const [uploadJob, setUploadJob] = useState(null); + const [uploadError, setUploadError] = useState(""); + const [uploadLoading, setUploadLoading] = useState(false); + const uploadPollRef = useRef | null>(null); + + async function handleUploadFiles(files: FileList | File[]) { + const allowed = Array.from(files).filter((f) => { + const n = f.name.toLowerCase(); + return n.endsWith(".eml") || n.endsWith(".mbox"); + }); + if (allowed.length === 0) { + setUploadError("Nur .eml und .mbox Dateien erlaubt."); + return; + } + setUploadError(""); + setUploadJob(null); + setUploadLoading(true); + try { + const { job_id } = await uploadMailFilesUser(allowed); + uploadPollRef.current = setInterval(async () => { + try { + const job = await getUploadProgressUser(job_id); + setUploadJob(job); + if (job.status !== "running") { + clearInterval(uploadPollRef.current!); + uploadPollRef.current = null; + setUploadLoading(false); + // Refresh search results after successful import + if (job.status === "done") onImportDone(); + } + } catch { + clearInterval(uploadPollRef.current!); + uploadPollRef.current = null; + setUploadLoading(false); + } + }, 1500); + } catch (e) { + setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen."); + setUploadLoading(false); + } + } + + function handleUploadClose() { + if (uploadPollRef.current) { + clearInterval(uploadPollRef.current); + uploadPollRef.current = null; + } + setUploadOpen(false); + setUploadJob(null); + setUploadError(""); + setUploadLoading(false); + } + + return { + uploadOpen, + setUploadOpen, + uploadDragging, + setUploadDragging, + uploadJob, + uploadError, + uploadLoading, + handleUploadFiles, + handleUploadClose, + }; +} diff --git a/src/hooks/usePasswordChange.ts b/src/hooks/usePasswordChange.ts new file mode 100644 index 0000000..6a5b6a5 --- /dev/null +++ b/src/hooks/usePasswordChange.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; +import { changePassword } from "@/lib/api"; + +export function usePasswordChange() { + const [currentPw, setCurrentPw] = useState(""); + const [newPw, setNewPw] = useState(""); + const [confirmPw, setConfirmPw] = useState(""); + const [pwError, setPwError] = useState(""); + const [pwSuccess, setPwSuccess] = useState(""); + const [pwLoading, setPwLoading] = useState(false); + + async function handleChangePassword(e: React.FormEvent) { + e.preventDefault(); + setPwError(""); + setPwSuccess(""); + + if (newPw.length < 8) { + setPwError("Das neue Passwort muss mindestens 8 Zeichen lang sein."); + return; + } + if (newPw !== confirmPw) { + setPwError("Die Passwoerter stimmen nicht ueberein."); + return; + } + + setPwLoading(true); + try { + await changePassword(currentPw, newPw); + setPwSuccess("Passwort wurde erfolgreich geaendert."); + setCurrentPw(""); + setNewPw(""); + setConfirmPw(""); + } catch (err) { + setPwError(err instanceof Error ? err.message : "Fehler beim Aendern des Passworts"); + } finally { + setPwLoading(false); + } + } + + return { + currentPw, + setCurrentPw, + newPw, + setNewPw, + confirmPw, + setConfirmPw, + pwError, + pwSuccess, + pwLoading, + handleChangePassword, + }; +} diff --git a/src/hooks/useProfileSettings.ts b/src/hooks/useProfileSettings.ts new file mode 100644 index 0000000..66d0d3e --- /dev/null +++ b/src/hooks/useProfileSettings.ts @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { changeEmail, updatePreferences, type MeResponse } from "@/lib/api"; + +export function useProfileSettings( + user: MeResponse | null, + refresh: () => Promise | void +) { + // ── Email state ─────────────────────────────────────────────────────── + const [email, setEmail] = useState(""); + const [emailInitialized, setEmailInitialized] = useState(false); + const [emailError, setEmailError] = useState(""); + const [emailSuccess, setEmailSuccess] = useState(""); + const [emailLoading, setEmailLoading] = useState(false); + + // ── Listenanzahl (Eintraege pro Seite) ──────────────────────────────── + const [listPageSize, setListPageSize] = useState("25"); + const [listPageSizeInitialized, setListPageSizeInitialized] = useState(false); + const [listPageSizeError, setListPageSizeError] = useState(""); + const [listPageSizeSuccess, setListPageSizeSuccess] = useState(""); + const [listPageSizeLoading, setListPageSizeLoading] = useState(false); + + // Initialize email from user data once available + if (user && !emailInitialized) { + setEmail(user.email || ""); + setEmailInitialized(true); + } + + // Initialize list page size from user data once available + if (user && !listPageSizeInitialized) { + setListPageSize(String(user.list_page_size || 25)); + setListPageSizeInitialized(true); + } + + async function handleChangeEmail(e: React.FormEvent) { + e.preventDefault(); + setEmailError(""); + setEmailSuccess(""); + + if (!email || !email.includes("@")) { + setEmailError("Bitte eine gueltige E-Mail-Adresse eingeben."); + return; + } + + setEmailLoading(true); + try { + const result = await changeEmail(email); + setEmail(result.email); + setEmailSuccess("E-Mail-Adresse wurde erfolgreich geaendert."); + } catch (err) { + setEmailError(err instanceof Error ? err.message : "Fehler beim Aendern der E-Mail"); + } finally { + setEmailLoading(false); + } + } + + async function handleChangeListPageSize(value: string) { + setListPageSizeError(""); + setListPageSizeSuccess(""); + const parsed = Number(value); + + setListPageSizeLoading(true); + try { + const result = await updatePreferences(parsed); + setListPageSize(String(result.list_page_size)); + setListPageSizeSuccess("Eintraege pro Seite wurden gespeichert."); + await refresh(); + } catch (err) { + setListPageSizeError(err instanceof Error ? err.message : "Fehler beim Speichern"); + } finally { + setListPageSizeLoading(false); + } + } + + return { + email, + setEmail, + emailError, + emailSuccess, + emailLoading, + handleChangeEmail, + listPageSize, + listPageSizeError, + listPageSizeSuccess, + listPageSizeLoading, + handleChangeListPageSize, + }; +} diff --git a/src/hooks/useSavedSearches.ts b/src/hooks/useSavedSearches.ts new file mode 100644 index 0000000..4bd77ca --- /dev/null +++ b/src/hooks/useSavedSearches.ts @@ -0,0 +1,163 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + searchEmails, + listSavedSearches, + createSavedSearch, + deleteSavedSearch, + type SearchHit, + type SavedSearch, +} from "@/lib/api"; + +type SavedSearchUser = { role: string } | null; + +interface UseSavedSearchesParams { + user: SavedSearchUser; + pageSize: number; + query: string; + fromFilter: string; + toFilter: string; + dateFrom: string; + dateTo: string; + hasAttachment: boolean | undefined; + setQuery: (v: string) => void; + setFromFilter: (v: string) => void; + setToFilter: (v: string) => void; + setDateFrom: (v: string) => void; + setDateTo: (v: string) => void; + setHasAttachment: (v: boolean | undefined) => void; + setResults: (v: SearchHit[]) => void; + setTotal: (v: number) => void; + setPage: (v: number) => void; + setSearching: (v: boolean) => void; + setSearched: (v: boolean) => void; +} + +export function useSavedSearches(params: UseSavedSearchesParams) { + const { + user, + pageSize, + query, + fromFilter, + toFilter, + dateFrom, + dateTo, + hasAttachment, + setQuery, + setFromFilter, + setToFilter, + setDateFrom, + setDateTo, + setHasAttachment, + setResults, + setTotal, + setPage, + setSearching, + setSearched, + } = params; + + const [savedSearches, setSavedSearches] = useState([]); + const [savedLoading, setSavedLoading] = useState(false); + const [savePopoverOpen, setSavePopoverOpen] = useState(false); + const [saveName, setSaveName] = useState(""); + const [saving, setSaving] = useState(false); + const [savedListOpen, setSavedListOpen] = useState(false); + + // Load saved searches on mount + useEffect(() => { + if (!user || user.role === "superadmin") return; + setSavedLoading(true); + listSavedSearches() + .then((list) => setSavedSearches(list || [])) + .catch(() => setSavedSearches([])) + .finally(() => setSavedLoading(false)); + }, [user]); + + function buildCurrentQuery(): Record { + const q: Record = {}; + if (query) q.q = query; + if (fromFilter) q.from = fromFilter; + if (toFilter) q.to = toFilter; + if (dateFrom) q.date_from = dateFrom; + if (dateTo) q.date_to = dateTo; + if (hasAttachment) q.has_attachment = "true"; + return q; + } + + async function handleSaveSearch() { + if (!saveName.trim()) return; + setSaving(true); + try { + const saved = await createSavedSearch(saveName.trim(), buildCurrentQuery()); + setSavedSearches((prev) => [saved, ...prev]); + setSaveName(""); + setSavePopoverOpen(false); + } catch (e) { + alert(`Suche speichern fehlgeschlagen: ${e instanceof Error ? e.message : e}`); + } finally { + setSaving(false); + } + } + + function handleApplySavedSearch(s: SavedSearch) { + setQuery(s.query.q || ""); + setFromFilter(s.query.from || ""); + setToFilter(s.query.to || ""); + setDateFrom(s.query.date_from || ""); + setDateTo(s.query.date_to || ""); + setHasAttachment(s.query.has_attachment === "true" ? true : undefined); + setSavedListOpen(false); + // Trigger search after state updates + setTimeout(() => { + // We need to search with the saved query directly since state isn't updated yet + setSearching(true); + searchEmails({ + q: s.query.q || undefined, + from: s.query.from || undefined, + to: s.query.to || undefined, + date_from: s.query.date_from || undefined, + date_to: s.query.date_to || undefined, + has_attachment: s.query.has_attachment === "true" ? true : undefined, + page: 1, + page_size: pageSize, + }) + .then((res) => { + setResults(res.hits || []); + setTotal(res.total); + setPage(1); + setSearched(true); + }) + .catch(() => { + setResults([]); + setTotal(0); + }) + .finally(() => setSearching(false)); + }, 0); + } + + async function handleDeleteSavedSearch(id: number) { + try { + await deleteSavedSearch(id); + setSavedSearches((prev) => prev.filter((s) => s.id !== id)); + } catch (e) { + alert(`Loeschen fehlgeschlagen: ${e instanceof Error ? e.message : e}`); + } + } + + return { + savedSearches, + savedLoading, + savePopoverOpen, + setSavePopoverOpen, + saveName, + setSaveName, + saving, + savedListOpen, + setSavedListOpen, + buildCurrentQuery, + handleSaveSearch, + handleApplySavedSearch, + handleDeleteSavedSearch, + }; +} diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 0000000..ae2b2e3 --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { searchEmails, type SearchHit } from "@/lib/api"; + +const DEFAULT_PAGE_SIZE = 25; + +type SearchUser = { role: string } | null; + +export function useSearch(user: SearchUser, pageSize: number) { + const [query, setQuery] = useState(""); + const [fromFilter, setFromFilter] = useState(""); + const [toFilter, setToFilter] = useState(""); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [sort, setSort] = useState("date_desc"); + const [hasAttachment, setHasAttachment] = useState(undefined); + + const [results, setResults] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [searching, setSearching] = useState(false); + const [searched, setSearched] = useState(false); + + const doSearch = useCallback( + async (p: number) => { + setSearching(true); + try { + const res = await searchEmails({ + q: query || undefined, + from: fromFilter || undefined, + to: toFilter || undefined, + date_from: dateFrom || undefined, + date_to: dateTo || undefined, + sort: sort !== "date_desc" ? sort : undefined, + has_attachment: hasAttachment, + page: p, + page_size: pageSize, + }); + setResults(res.hits || []); + setTotal(res.total); + setPage(p); + setSearched(true); + } catch { + setResults([]); + setTotal(0); + } finally { + setSearching(false); + } + }, + [query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, pageSize] + ); + + // Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure + useEffect(() => { + if (!user || user.role === "superadmin") return; + setSearching(true); + searchEmails({ page: 1, page_size: pageSize }) + .then((res) => { + setResults(res.hits || []); + setTotal(res.total); + setPage(1); + setSearched(true); + }) + .catch(() => { + setResults([]); + setTotal(0); + }) + .finally(() => setSearching(false)); + }, [user, pageSize]); + + return { + query, + setQuery, + fromFilter, + setFromFilter, + toFilter, + setToFilter, + dateFrom, + setDateFrom, + dateTo, + setDateTo, + sort, + setSort, + hasAttachment, + setHasAttachment, + results, + setResults, + total, + setTotal, + page, + setPage, + searching, + setSearching, + searched, + setSearched, + doSearch, + }; +} + +export { DEFAULT_PAGE_SIZE }; diff --git a/src/hooks/useSystemInfo.ts b/src/hooks/useSystemInfo.ts new file mode 100644 index 0000000..59c3fac --- /dev/null +++ b/src/hooks/useSystemInfo.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getSystemInfo, type SystemInfo } from "@/lib/api"; + +export function useSystemInfo() { + const [systemInfo, setSystemInfo] = useState(null); + const [systemInfoLoading, setSystemInfoLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setSystemInfoLoading(true); + getSystemInfo() + .then((info) => { + if (!cancelled) setSystemInfo(info); + }) + .catch(() => { + if (!cancelled) setSystemInfo(null); + }) + .finally(() => { + if (!cancelled) setSystemInfoLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + return { systemInfo, systemInfoLoading }; +} diff --git a/src/hooks/useTenantLogos.ts b/src/hooks/useTenantLogos.ts new file mode 100644 index 0000000..b2735a9 --- /dev/null +++ b/src/hooks/useTenantLogos.ts @@ -0,0 +1,124 @@ +"use client"; + +import { useState, type Dispatch, type SetStateAction } from "react"; +import { + getTenantLogoUrl, + uploadTenantLogo, + deleteTenantLogo, + uploadMyTenantLogo, + deleteMyTenantLogo, + type Tenant, +} from "@/lib/api"; + +interface OwnLogoController { + setOwnLogoPreviewUrl: Dispatch>; + setOwnLogoUploading: Dispatch>; + setOwnLogoError: Dispatch>; +} + +export function useTenantLogos( + setTenants: Dispatch>, + own: OwnLogoController, +) { + // Logo dialog (superadmin: any tenant) + const [logoDialogTenant, setLogoDialogTenant] = useState(null); + const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); + const [logoUploading, setLogoUploading] = useState(false); + const [logoError, setLogoError] = useState(""); + + async function openLogoDialog(t: Tenant) { + setLogoDialogTenant(t); + setLogoError(""); + setLogoPreviewUrl(null); + if (t.has_logo) { + try { + const res = await fetch(getTenantLogoUrl(t.id), { credentials: "include" }); + if (res.ok) { + const blob = await res.blob(); + setLogoPreviewUrl(URL.createObjectURL(blob)); + } + } catch { + // preview not critical + } + } + } + + async function handleLogoUpload(file: File) { + if (!logoDialogTenant) return; + setLogoUploading(true); + setLogoError(""); + try { + await uploadTenantLogo(logoDialogTenant.id, file); + const res = await fetch(getTenantLogoUrl(logoDialogTenant.id), { credentials: "include" }); + if (res.ok) { + const blob = await res.blob(); + setLogoPreviewUrl(URL.createObjectURL(blob)); + } + setTenants((prev) => prev.map((t) => t.id === logoDialogTenant.id ? { ...t, has_logo: true } : t)); + } catch (err: unknown) { + setLogoError(err instanceof Error ? err.message : "Upload fehlgeschlagen."); + } finally { + setLogoUploading(false); + } + } + + async function handleLogoDelete() { + if (!logoDialogTenant) return; + setLogoUploading(true); + setLogoError(""); + try { + await deleteTenantLogo(logoDialogTenant.id); + setLogoPreviewUrl(null); + setTenants((prev) => prev.map((t) => t.id === logoDialogTenant.id ? { ...t, has_logo: false } : t)); + } catch (err: unknown) { + setLogoError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); + } finally { + setLogoUploading(false); + } + } + + // Logo handlers (domain_admin: own tenant) + async function handleOwnLogoUpload(file: File) { + own.setOwnLogoUploading(true); + own.setOwnLogoError(""); + try { + await uploadMyTenantLogo(file); + const res = await fetch(`/api/tenant/logo`, { credentials: "include" }); + if (res.ok) { + const blob = await res.blob(); + own.setOwnLogoPreviewUrl(URL.createObjectURL(blob)); + } + } catch (err: unknown) { + own.setOwnLogoError(err instanceof Error ? err.message : "Upload fehlgeschlagen."); + } finally { + own.setOwnLogoUploading(false); + } + } + + async function handleOwnLogoDelete() { + own.setOwnLogoUploading(true); + own.setOwnLogoError(""); + try { + await deleteMyTenantLogo(); + own.setOwnLogoPreviewUrl(null); + } catch (err: unknown) { + own.setOwnLogoError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); + } finally { + own.setOwnLogoUploading(false); + } + } + + return { + logoDialogTenant, + setLogoDialogTenant, + logoPreviewUrl, + setLogoPreviewUrl, + logoUploading, + logoError, + openLogoDialog, + handleLogoUpload, + handleLogoDelete, + handleOwnLogoUpload, + handleOwnLogoDelete, + }; +} diff --git a/src/hooks/useTotpSettings.ts b/src/hooks/useTotpSettings.ts new file mode 100644 index 0000000..a18c372 --- /dev/null +++ b/src/hooks/useTotpSettings.ts @@ -0,0 +1,108 @@ +"use client"; + +import { useState } from "react"; +import { getTOTPSetup, confirmTOTPSetup, disableTOTP } from "@/lib/api"; + +export function useTotpSettings() { + const [totpEnabled, setTotpEnabled] = useState(false); + const [showSetup, setShowSetup] = useState(false); + const [qrCode, setQrCode] = useState(""); + const [secret, setSecret] = useState(""); + const [totpCode, setTotpCode] = useState(""); + const [totpError, setTotpError] = useState(""); + const [totpSuccess, setTotpSuccess] = useState(""); + const [totpLoading, setTotpLoading] = useState(false); + const [disableDialogOpen, setDisableDialogOpen] = useState(false); + const [disableCode, setDisableCode] = useState(""); + const [disableError, setDisableError] = useState(""); + const [disableLoading, setDisableLoading] = useState(false); + + async function handleSetupTOTP() { + setTotpError(""); + setTotpSuccess(""); + setTotpLoading(true); + try { + const data = await getTOTPSetup(); + setQrCode(data.qr_code || ""); + setSecret(data.secret); + setShowSetup(true); + setTotpCode(""); + } catch (err) { + setTotpError(err instanceof Error ? err.message : "TOTP-Setup fehlgeschlagen"); + } finally { + setTotpLoading(false); + } + } + + async function handleConfirmTOTP(e: React.FormEvent) { + e.preventDefault(); + setTotpError(""); + setTotpLoading(true); + try { + await confirmTOTPSetup(totpCode); + setTotpEnabled(true); + setShowSetup(false); + setTotpSuccess("2FA wurde erfolgreich aktiviert."); + setQrCode(""); + setSecret(""); + setTotpCode(""); + } catch (err) { + setTotpError(err instanceof Error ? err.message : "Ungueltiger Code"); + } finally { + setTotpLoading(false); + } + } + + async function handleDisableTOTP() { + setDisableError(""); + setDisableLoading(true); + try { + await disableTOTP(disableCode); + setTotpEnabled(false); + setDisableDialogOpen(false); + setDisableCode(""); + setTotpSuccess("2FA wurde deaktiviert."); + } catch (err) { + setDisableError(err instanceof Error ? err.message : "Ungueltiger Code"); + } finally { + setDisableLoading(false); + } + } + + function cancelSetup() { + setShowSetup(false); + setQrCode(""); + setSecret(""); + setTotpCode(""); + setTotpError(""); + } + + function openDisableDialog() { + setDisableDialogOpen(true); + setDisableCode(""); + setDisableError(""); + } + + return { + totpEnabled, + showSetup, + qrCode, + secret, + totpCode, + setTotpCode, + totpError, + totpSuccess, + totpLoading, + disableDialogOpen, + setDisableDialogOpen, + disableCode, + setDisableCode, + disableError, + disableLoading, + handleSetupTOTP, + handleConfirmTOTP, + handleDisableTOTP, + cancelSetup, + openDisableDialog, + }; +}