diff --git a/internal/api/ldap_sync.go b/internal/api/ldap_sync.go new file mode 100644 index 0000000..a32b0bd --- /dev/null +++ b/internal/api/ldap_sync.go @@ -0,0 +1,134 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/archivmail/internal/audit" + "github.com/archivmail/internal/ldapauth" + ldapcfg "github.com/archivmail/internal/ldapconfig" +) + +// syncResult is the JSON response returned by LDAP sync endpoints. +type syncResult struct { + Synced int `json:"synced"` + Errors []string `json:"errors"` +} + +// handleSyncTenantLDAP imports all LDAP users into the tenant (domain_admin). +func (s *Server) handleSyncTenantLDAP(w http.ResponseWriter, r *http.Request) { + if s.tenantLdapStore == nil { + writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.TenantID == nil { + writeError(w, http.StatusBadRequest, "no tenant context") + return + } + res := s.doSyncTenantLDAP(r, *sess.TenantID) + s.audlog.Log(audit.Entry{ + EventType: "tenant_ldap_sync", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: len(res.Errors) == 0, + Detail: fmt.Sprintf("%d Benutzer synchronisiert", res.Synced), + }) + writeJSON(w, http.StatusOK, res) +} + +// handleAdminSyncTenantLDAP imports all LDAP users into any tenant (superadmin). +func (s *Server) handleAdminSyncTenantLDAP(w http.ResponseWriter, r *http.Request) { + if s.tenantLdapStore == nil { + writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") + return + } + id, err := parseTenantID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid tenant id") + return + } + res := s.doSyncTenantLDAP(r, id) + sess := sessionFromCtx(r.Context()) + s.audlog.Log(audit.Entry{ + EventType: "tenant_ldap_sync", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: len(res.Errors) == 0, + Detail: fmt.Sprintf("%d Benutzer synchronisiert (tenant %d)", res.Synced, id), + }) + writeJSON(w, http.StatusOK, res) +} + +// doSyncTenantLDAP is the shared sync logic: fetch all LDAP users and upsert +// them into the local userstore with source='ldap'. +func (s *Server) doSyncTenantLDAP(r *http.Request, tenantID int64) syncResult { + saved, err := s.tenantLdapStore.GetWithPassword(r.Context(), tenantID) + if err != nil || saved == nil { + return syncResult{Errors: []string{"keine LDAP-Konfiguration vorhanden"}} + } + + cfg := ldapauth.Config{ + URL: saved.URL, + BindDN: saved.BindDN, + BindPassword: saved.BindPassword, + BaseDN: saved.BaseDN, + UserFilter: saved.UserFilter, + TLS: saved.TLS, + TLSSkipVerify: saved.TLSSkipVerify, + } + + ldapUsers, err := ldapauth.FetchUsers(cfg) + if err != nil { + return syncResult{Errors: []string{err.Error()}} + } + + defaultRole := saved.DefaultRole + if defaultRole == "" { + defaultRole = "user" + } + + var res syncResult + res.Errors = []string{} + for _, u := range ldapUsers { + mail := u.Mail + if mail == "" { + mail = u.UID + "@ldap.local" + } + if _, uErr := s.users.UpsertLDAPUser(u.UID, mail, defaultRole, &tenantID); uErr != nil { + res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", u.UID, uErr.Error())) + } else { + res.Synced++ + } + } + return res +} + +// buildTenantTestConfig constructs an ldapauth.Config for testing, either from +// the saved tenant config or from the provided request body. +func (s *Server) buildTenantTestConfig(r *http.Request, useSaved bool, tenantID int64, provided ldapcfg.TenantLDAPConfig) *ldapauth.Config { + if useSaved { + saved, err := s.tenantLdapStore.GetWithPassword(r.Context(), tenantID) + if err != nil || saved == nil { + return nil + } + return &ldapauth.Config{ + URL: saved.URL, + BindDN: saved.BindDN, + BindPassword: saved.BindPassword, + BaseDN: saved.BaseDN, + UserFilter: saved.UserFilter, + TLS: saved.TLS, + TLSSkipVerify: saved.TLSSkipVerify, + } + } + return &ldapauth.Config{ + URL: provided.URL, + BindDN: provided.BindDN, + BindPassword: provided.BindPassword, + BaseDN: provided.BaseDN, + UserFilter: provided.UserFilter, + TLS: provided.TLS, + TLSSkipVerify: provided.TLSSkipVerify, + } +} diff --git a/internal/api/ldap_tenants.go b/internal/api/ldap_tenants.go index f65eba5..6cba544 100644 --- a/internal/api/ldap_tenants.go +++ b/internal/api/ldap_tenants.go @@ -771,131 +771,6 @@ func (s *Server) handleAdminTestTenantLDAP(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusOK, result) } -// ── LDAP Sync handlers ─────────────────────────────────────────────────────── - -type syncResult struct { - Synced int `json:"synced"` - Errors []string `json:"errors"` -} - -// handleSyncTenantLDAP imports all LDAP users into the tenant (domain_admin). -func (s *Server) handleSyncTenantLDAP(w http.ResponseWriter, r *http.Request) { - if s.tenantLdapStore == nil { - writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") - return - } - sess := sessionFromCtx(r.Context()) - if sess.TenantID == nil { - writeError(w, http.StatusBadRequest, "no tenant context") - return - } - res := s.doSyncTenantLDAP(r, *sess.TenantID) - s.audlog.Log(audit.Entry{ - EventType: "tenant_ldap_sync", - Username: sess.Username, - IPAddress: s.remoteIP(r), - Success: len(res.Errors) == 0, - Detail: fmt.Sprintf("%d Benutzer synchronisiert", res.Synced), - }) - writeJSON(w, http.StatusOK, res) -} - -// handleAdminSyncTenantLDAP imports all LDAP users into any tenant (superadmin). -func (s *Server) handleAdminSyncTenantLDAP(w http.ResponseWriter, r *http.Request) { - if s.tenantLdapStore == nil { - writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") - return - } - id, err := parseTenantID(r) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid tenant id") - return - } - res := s.doSyncTenantLDAP(r, id) - sess := sessionFromCtx(r.Context()) - s.audlog.Log(audit.Entry{ - EventType: "tenant_ldap_sync", - Username: sess.Username, - IPAddress: s.remoteIP(r), - Success: len(res.Errors) == 0, - Detail: fmt.Sprintf("%d Benutzer synchronisiert (tenant %d)", res.Synced, id), - }) - writeJSON(w, http.StatusOK, res) -} - -// doSyncTenantLDAP is the shared sync logic: fetch all LDAP users and upsert -// them into the local userstore with source='ldap'. -func (s *Server) doSyncTenantLDAP(r *http.Request, tenantID int64) syncResult { - saved, err := s.tenantLdapStore.GetWithPassword(r.Context(), tenantID) - if err != nil || saved == nil { - return syncResult{Errors: []string{"keine LDAP-Konfiguration vorhanden"}} - } - - cfg := ldapauth.Config{ - URL: saved.URL, - BindDN: saved.BindDN, - BindPassword: saved.BindPassword, - BaseDN: saved.BaseDN, - UserFilter: saved.UserFilter, - TLS: saved.TLS, - TLSSkipVerify: saved.TLSSkipVerify, - } - - ldapUsers, err := ldapauth.FetchUsers(cfg) - if err != nil { - return syncResult{Errors: []string{err.Error()}} - } - - defaultRole := saved.DefaultRole - if defaultRole == "" { - defaultRole = "user" - } - - var res syncResult - res.Errors = []string{} - for _, u := range ldapUsers { - mail := u.Mail - if mail == "" { - mail = u.UID + "@ldap.local" - } - if _, uErr := s.users.UpsertLDAPUser(u.UID, mail, defaultRole, &tenantID); uErr != nil { - res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", u.UID, uErr.Error())) - } else { - res.Synced++ - } - } - return res -} - -// buildTenantTestConfig constructs an ldapauth.Config for testing, either from -// the saved tenant config or from the provided request body. -func (s *Server) buildTenantTestConfig(r *http.Request, useSaved bool, tenantID int64, provided ldapcfg.TenantLDAPConfig) *ldapauth.Config { - if useSaved { - saved, err := s.tenantLdapStore.GetWithPassword(r.Context(), tenantID) - if err != nil || saved == nil { - return nil - } - return &ldapauth.Config{ - URL: saved.URL, - BindDN: saved.BindDN, - BindPassword: saved.BindPassword, - BaseDN: saved.BaseDN, - UserFilter: saved.UserFilter, - TLS: saved.TLS, - TLSSkipVerify: saved.TLSSkipVerify, - } - } - return &ldapauth.Config{ - URL: provided.URL, - BindDN: provided.BindDN, - BindPassword: provided.BindPassword, - BaseDN: provided.BaseDN, - UserFilter: provided.UserFilter, - TLS: provided.TLS, - TLSSkipVerify: provided.TLSSkipVerify, - } -} - // ── helpers ────────────────────────────────────────────────────────────────── func parseTenantID(r *http.Request) (int64, error) { diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b0dd2ab..0a85c90 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,6 +2,9 @@ import { useState, useEffect, useCallback, useRef } 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, @@ -18,24 +21,13 @@ import { getUploadProgress, getSecurityAudit, fixSecurityIssue, - getLDAPConfig, - saveLDAPConfig, - deleteLDAPConfig, - testLDAPConfig, getTenants, - getTenantUsers, createTenant, updateTenant, deleteTenant, getTenantDomains, addTenantDomain, removeTenantDomain, - getTenantLDAPConfig, - saveTenantLDAPConfig, - deleteTenantLDAPConfig, - testTenantLDAPConfig, - syncAdminTenantLDAP, - type LDAPSyncResult, getAdminLabels, createAdminLabel, deleteAdminLabel, @@ -55,9 +47,6 @@ import { type SystemStats, type UploadJob, type SecurityAuditResult, - type LDAPConfig, - type LDAPTestResult, - type TenantLDAPConfig, type Tenant, type TenantDefaultUser, type TenantDomain, @@ -155,26 +144,23 @@ export default function AdminPage() { const [uploadLoading, setUploadLoading] = useState(false); const uploadPollRef = useRef | null>(null); - // LDAP state - const [ldapConfig, setLdapConfig] = useState(null); - const [ldapLoading, setLdapLoading] = useState(false); - const [ldapSaving, setLdapSaving] = useState(false); - const [ldapTesting, setLdapTesting] = useState(false); - const [ldapError, setLdapError] = useState(""); - const [ldapTestResult, setLdapTestResult] = useState(null); - const [ldapForm, setLdapForm] = useState({ - enabled: false, - url: "ldap://", - bind_dn: "", - bind_password: "", - base_dn: "", - user_filter: "(sAMAccountName=%s)", - tls: false, - tls_skip_verify: false, - default_role: "user", - group_mappings: [], - }); - const [ldapChangePassword, setLdapChangePassword] = useState(false); + // LDAP state (global, superadmin) — managed by useLDAPConfig hook + const { + ldapConfig, + ldapLoading, + ldapSaving, + ldapTesting, + ldapError, + ldapTestResult, + ldapForm, + setLdapForm, + ldapChangePassword, + setLdapChangePassword, + loadLDAP, + handleSaveLDAP, + handleTestLDAP, + handleDeleteLDAP, + } = useLDAPConfig(); // Tenants state const [tenants, setTenants] = useState([]); @@ -197,26 +183,29 @@ export default function AdminPage() { const [addDomainLoading, setAddDomainLoading] = useState(false); const [domainError, setDomainError] = useState(""); - // Tenant LDAP state (domain_admin own tenant) - const [tenantLdapConfig, setTenantLdapConfig] = useState(null); - const [tenantLdapLoading, setTenantLdapLoading] = useState(false); - const [tenantLdapSaving, setTenantLdapSaving] = useState(false); - const [tenantLdapTesting, setTenantLdapTesting] = useState(false); - const [tenantLdapError, setTenantLdapError] = useState(""); - const [tenantLdapTestResult, setTenantLdapTestResult] = useState(null); - const [tenantLdapForm, setTenantLdapForm] = useState({ - enabled: false, - url: "ldap://", - bind_dn: "", - bind_password: "", - base_dn: "", - user_filter: "(sAMAccountName=%s)", - tls: false, - tls_skip_verify: false, - default_role: "user", - group_mappings: [], - }); - const [tenantLdapChangePassword, setTenantLdapChangePassword] = useState(false); + // Tenant LDAP state (domain_admin own tenant) — managed by useTenantLDAPConfig hook + const { + tenantLdapConfig, + tenantLdapLoading, + tenantLdapSaving, + tenantLdapTesting, + tenantLdapError, + tenantLdapTestResult, + tenantLdapForm, + setTenantLdapForm, + tenantLdapChangePassword, + setTenantLdapChangePassword, + ownLogoPreviewUrl, + setOwnLogoPreviewUrl, + ownLogoUploading, + setOwnLogoUploading, + ownLogoError, + setOwnLogoError, + loadTenantLDAP, + handleSaveTenantLDAP, + handleTestTenantLDAP, + handleDeleteTenantLDAP, + } = useTenantLDAPConfig(); // Superadmin: tenant LDAP dialog const [tenantLdapDialogId, setTenantLdapDialogId] = useState(null); @@ -227,20 +216,20 @@ export default function AdminPage() { const [logoUploading, setLogoUploading] = useState(false); const [logoError, setLogoError] = useState(""); - // Logo for domain_admin own tenant - const [ownLogoPreviewUrl, setOwnLogoPreviewUrl] = useState(null); - const [ownLogoUploading, setOwnLogoUploading] = useState(false); - const [ownLogoError, setOwnLogoError] = useState(""); - - // Tenant users dialog - const [tenantUsersDialogId, setTenantUsersDialogId] = useState(null); - const [tenantUsersDialogName, setTenantUsersDialogName] = useState(""); - const [tenantUsersDialogLdap, setTenantUsersDialogLdap] = useState(false); - const [tenantUsers, setTenantUsers] = useState([]); - const [tenantUsersLoading, setTenantUsersLoading] = useState(false); - const [tenantUsersError, setTenantUsersError] = useState(""); - const [tenantUsersSyncing, setTenantUsersSyncing] = useState(false); - const [tenantUsersSyncResult, setTenantUsersSyncResult] = useState(null); + // Tenant users dialog — managed by useTenantUsers hook + const { + tenantUsersDialogId, + setTenantUsersDialogId, + tenantUsersDialogName, + tenantUsersDialogLdap, + tenantUsers, + tenantUsersLoading, + tenantUsersError, + tenantUsersSyncing, + tenantUsersSyncResult, + openUsersDialog, + handleSyncLDAPUsers, + } = useTenantUsers(); // Labels state const [adminLabels, setAdminLabels] = useState([]); @@ -528,78 +517,6 @@ export default function AdminPage() { } } - // LDAP handlers - const loadLDAP = useCallback(async () => { - setLdapLoading(true); - setLdapError(""); - try { - const cfg = await getLDAPConfig(); - if (cfg) { - setLdapConfig(cfg); - setLdapForm({ ...cfg, bind_password: "" }); - setLdapChangePassword(false); - } - } catch { - setLdapError("LDAP-Konfiguration konnte nicht geladen werden."); - } finally { - setLdapLoading(false); - } - }, []); - - async function handleSaveLDAP(e: React.FormEvent) { - e.preventDefault(); - setLdapSaving(true); - setLdapError(""); - try { - const payload: Partial = { ...ldapForm }; - if (!ldapChangePassword) { - delete payload.bind_password; - } - await saveLDAPConfig(payload); - await loadLDAP(); - } catch (err: unknown) { - setLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen."); - } finally { - setLdapSaving(false); - } - } - - async function handleTestLDAP() { - setLdapTesting(true); - setLdapError(""); - setLdapTestResult(null); - try { - const payload = ldapConfig - ? { use_saved: true } - : { use_saved: false, ...ldapForm }; - const result = await testLDAPConfig(payload as Parameters[0]); - setLdapTestResult(result); - } catch (err: unknown) { - setLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen."); - } finally { - setLdapTesting(false); - } - } - - async function handleDeleteLDAP() { - setLdapSaving(true); - setLdapError(""); - try { - await deleteLDAPConfig(); - setLdapConfig(null); - setLdapForm({ - enabled: false, url: "ldap://", bind_dn: "", bind_password: "", - base_dn: "", user_filter: "(sAMAccountName=%s)", tls: false, - tls_skip_verify: false, default_role: "user", group_mappings: [], - }); - setLdapTestResult(null); - } catch (err: unknown) { - setLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); - } finally { - setLdapSaving(false); - } - } - // Tenants handlers const loadTenants = useCallback(async () => { setTenantsLoading(true); @@ -747,40 +664,6 @@ export default function AdminPage() { finally { setDomainsLoading(false); } } - async function openUsersDialog(t: Tenant) { - setTenantUsersDialogId(t.id); - setTenantUsersDialogName(t.name); - setTenantUsersDialogLdap(t.ldap_enabled === true); - setTenantUsersLoading(true); - setTenantUsers([]); - setTenantUsersError(""); - setTenantUsersSyncResult(null); - try { - const users = await getTenantUsers(t.id); - setTenantUsers(users || []); - } catch (err: unknown) { - setTenantUsersError(err instanceof Error ? err.message : "Nutzer konnten nicht geladen werden."); - } finally { - setTenantUsersLoading(false); - } - } - - async function handleSyncLDAPUsers() { - if (!tenantUsersDialogId) return; - setTenantUsersSyncing(true); - setTenantUsersSyncResult(null); - try { - const result = await syncAdminTenantLDAP(tenantUsersDialogId); - setTenantUsersSyncResult(result); - const users = await getTenantUsers(tenantUsersDialogId); - setTenantUsers(users || []); - } catch (err: unknown) { - setTenantUsersSyncResult({ synced: 0, errors: [err instanceof Error ? err.message : "Sync fehlgeschlagen"] }); - } finally { - setTenantUsersSyncing(false); - } - } - async function handleAddDomain() { if (!domainDialogTenant || !newDomain) return; setAddDomainLoading(true); @@ -892,88 +775,6 @@ export default function AdminPage() { } } - // Tenant LDAP handlers (domain_admin) - const loadTenantLDAP = useCallback(async () => { - setTenantLdapLoading(true); - setTenantLdapError(""); - try { - const cfg = await getTenantLDAPConfig(); - if (cfg) { - setTenantLdapConfig(cfg); - setTenantLdapForm({ ...cfg, bind_password: "" }); - setTenantLdapChangePassword(false); - } - } catch { - setTenantLdapError("LDAP-Konfiguration konnte nicht geladen werden."); - } finally { - setTenantLdapLoading(false); - } - // Load own tenant logo preview - try { - const res = await fetch("/api/tenant/logo", { credentials: "include" }); - if (res.ok) { - const blob = await res.blob(); - setOwnLogoPreviewUrl(URL.createObjectURL(blob)); - } - } catch { - // no logo or not available - } - }, []); - - async function handleSaveTenantLDAP(e: React.FormEvent) { - e.preventDefault(); - setTenantLdapSaving(true); - setTenantLdapError(""); - try { - const payload: Partial = { ...tenantLdapForm }; - if (!tenantLdapChangePassword) { - delete payload.bind_password; - } - await saveTenantLDAPConfig(payload); - await loadTenantLDAP(); - } catch (err: unknown) { - setTenantLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen."); - } finally { - setTenantLdapSaving(false); - } - } - - async function handleTestTenantLDAP() { - setTenantLdapTesting(true); - setTenantLdapError(""); - setTenantLdapTestResult(null); - try { - const payload = tenantLdapConfig - ? { use_saved: true } - : { use_saved: false, ...tenantLdapForm }; - const result = await testTenantLDAPConfig(payload as Parameters[0]); - setTenantLdapTestResult(result); - } catch (err: unknown) { - setTenantLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen."); - } finally { - setTenantLdapTesting(false); - } - } - - async function handleDeleteTenantLDAP() { - setTenantLdapSaving(true); - setTenantLdapError(""); - try { - await deleteTenantLDAPConfig(); - setTenantLdapConfig(null); - setTenantLdapForm({ - enabled: false, url: "ldap://", bind_dn: "", bind_password: "", - base_dn: "", user_filter: "(sAMAccountName=%s)", tls: false, - tls_skip_verify: false, default_role: "user", group_mappings: [], - }); - setTenantLdapTestResult(null); - } catch (err: unknown) { - setTenantLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); - } finally { - setTenantLdapSaving(false); - } - } - return (
diff --git a/src/hooks/useLDAPConfig.ts b/src/hooks/useLDAPConfig.ts new file mode 100644 index 0000000..f0e8fa8 --- /dev/null +++ b/src/hooks/useLDAPConfig.ts @@ -0,0 +1,119 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + getLDAPConfig, + saveLDAPConfig, + deleteLDAPConfig, + testLDAPConfig, + type LDAPConfig, + type LDAPTestResult, +} from "@/lib/api"; + +const defaultLDAPForm = (): LDAPConfig => ({ + enabled: false, + url: "ldap://", + bind_dn: "", + bind_password: "", + base_dn: "", + user_filter: "(sAMAccountName=%s)", + tls: false, + tls_skip_verify: false, + default_role: "user", + group_mappings: [], +}); + +export function useLDAPConfig() { + const [ldapConfig, setLdapConfig] = useState(null); + const [ldapLoading, setLdapLoading] = useState(false); + const [ldapSaving, setLdapSaving] = useState(false); + const [ldapTesting, setLdapTesting] = useState(false); + const [ldapError, setLdapError] = useState(""); + const [ldapTestResult, setLdapTestResult] = useState(null); + const [ldapForm, setLdapForm] = useState(defaultLDAPForm()); + const [ldapChangePassword, setLdapChangePassword] = useState(false); + + const loadLDAP = useCallback(async () => { + setLdapLoading(true); + setLdapError(""); + try { + const cfg = await getLDAPConfig(); + if (cfg) { + setLdapConfig(cfg); + setLdapForm({ ...cfg, bind_password: "" }); + setLdapChangePassword(false); + } + } catch { + setLdapError("LDAP-Konfiguration konnte nicht geladen werden."); + } finally { + setLdapLoading(false); + } + }, []); + + async function handleSaveLDAP(e: React.FormEvent) { + e.preventDefault(); + setLdapSaving(true); + setLdapError(""); + try { + const payload: Partial = { ...ldapForm }; + if (!ldapChangePassword) { + delete payload.bind_password; + } + await saveLDAPConfig(payload); + await loadLDAP(); + } catch (err: unknown) { + setLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen."); + } finally { + setLdapSaving(false); + } + } + + async function handleTestLDAP() { + setLdapTesting(true); + setLdapError(""); + setLdapTestResult(null); + try { + const payload = ldapConfig + ? { use_saved: true } + : { use_saved: false, ...ldapForm }; + const result = await testLDAPConfig(payload as Parameters[0]); + setLdapTestResult(result); + } catch (err: unknown) { + setLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen."); + } finally { + setLdapTesting(false); + } + } + + async function handleDeleteLDAP() { + setLdapSaving(true); + setLdapError(""); + try { + await deleteLDAPConfig(); + setLdapConfig(null); + setLdapForm(defaultLDAPForm()); + setLdapTestResult(null); + } catch (err: unknown) { + setLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); + } finally { + setLdapSaving(false); + } + } + + return { + ldapConfig, + ldapLoading, + ldapSaving, + ldapTesting, + ldapError, + ldapTestResult, + ldapForm, + setLdapForm, + ldapChangePassword, + setLdapChangePassword, + loadLDAP, + handleSaveLDAP, + handleTestLDAP, + handleDeleteLDAP, + }; +} diff --git a/src/hooks/useTenantLDAPConfig.ts b/src/hooks/useTenantLDAPConfig.ts new file mode 100644 index 0000000..08ec6da --- /dev/null +++ b/src/hooks/useTenantLDAPConfig.ts @@ -0,0 +1,140 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + getTenantLDAPConfig, + saveTenantLDAPConfig, + deleteTenantLDAPConfig, + testTenantLDAPConfig, + type TenantLDAPConfig, + type LDAPTestResult, +} from "@/lib/api"; + +const defaultTenantLDAPForm = (): TenantLDAPConfig => ({ + enabled: false, + url: "ldap://", + bind_dn: "", + bind_password: "", + base_dn: "", + user_filter: "(sAMAccountName=%s)", + tls: false, + tls_skip_verify: false, + default_role: "user", + group_mappings: [], +}); + +export function useTenantLDAPConfig() { + const [tenantLdapConfig, setTenantLdapConfig] = useState(null); + const [tenantLdapLoading, setTenantLdapLoading] = useState(false); + const [tenantLdapSaving, setTenantLdapSaving] = useState(false); + const [tenantLdapTesting, setTenantLdapTesting] = useState(false); + const [tenantLdapError, setTenantLdapError] = useState(""); + const [tenantLdapTestResult, setTenantLdapTestResult] = useState(null); + const [tenantLdapForm, setTenantLdapForm] = useState(defaultTenantLDAPForm()); + const [tenantLdapChangePassword, setTenantLdapChangePassword] = useState(false); + + // Own tenant logo state (lives here because it's loaded together with tenant LDAP) + const [ownLogoPreviewUrl, setOwnLogoPreviewUrl] = useState(null); + const [ownLogoUploading, setOwnLogoUploading] = useState(false); + const [ownLogoError, setOwnLogoError] = useState(""); + + const loadTenantLDAP = useCallback(async () => { + setTenantLdapLoading(true); + setTenantLdapError(""); + try { + const cfg = await getTenantLDAPConfig(); + if (cfg) { + setTenantLdapConfig(cfg); + setTenantLdapForm({ ...cfg, bind_password: "" }); + setTenantLdapChangePassword(false); + } + } catch { + setTenantLdapError("LDAP-Konfiguration konnte nicht geladen werden."); + } finally { + setTenantLdapLoading(false); + } + // Load own tenant logo preview + try { + const res = await fetch("/api/tenant/logo", { credentials: "include" }); + if (res.ok) { + const blob = await res.blob(); + setOwnLogoPreviewUrl(URL.createObjectURL(blob)); + } + } catch { + // no logo or not available + } + }, []); + + async function handleSaveTenantLDAP(e: React.FormEvent) { + e.preventDefault(); + setTenantLdapSaving(true); + setTenantLdapError(""); + try { + const payload: Partial = { ...tenantLdapForm }; + if (!tenantLdapChangePassword) { + delete payload.bind_password; + } + await saveTenantLDAPConfig(payload); + await loadTenantLDAP(); + } catch (err: unknown) { + setTenantLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen."); + } finally { + setTenantLdapSaving(false); + } + } + + async function handleTestTenantLDAP() { + setTenantLdapTesting(true); + setTenantLdapError(""); + setTenantLdapTestResult(null); + try { + const payload = tenantLdapConfig + ? { use_saved: true } + : { use_saved: false, ...tenantLdapForm }; + const result = await testTenantLDAPConfig(payload as Parameters[0]); + setTenantLdapTestResult(result); + } catch (err: unknown) { + setTenantLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen."); + } finally { + setTenantLdapTesting(false); + } + } + + async function handleDeleteTenantLDAP() { + setTenantLdapSaving(true); + setTenantLdapError(""); + try { + await deleteTenantLDAPConfig(); + setTenantLdapConfig(null); + setTenantLdapForm(defaultTenantLDAPForm()); + setTenantLdapTestResult(null); + } catch (err: unknown) { + setTenantLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); + } finally { + setTenantLdapSaving(false); + } + } + + return { + tenantLdapConfig, + tenantLdapLoading, + tenantLdapSaving, + tenantLdapTesting, + tenantLdapError, + tenantLdapTestResult, + tenantLdapForm, + setTenantLdapForm, + tenantLdapChangePassword, + setTenantLdapChangePassword, + ownLogoPreviewUrl, + setOwnLogoPreviewUrl, + ownLogoUploading, + setOwnLogoUploading, + ownLogoError, + setOwnLogoError, + loadTenantLDAP, + handleSaveTenantLDAP, + handleTestTenantLDAP, + handleDeleteTenantLDAP, + }; +} diff --git a/src/hooks/useTenantUsers.ts b/src/hooks/useTenantUsers.ts new file mode 100644 index 0000000..a22d333 --- /dev/null +++ b/src/hooks/useTenantUsers.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useState } from "react"; +import { + getTenantUsers, + syncAdminTenantLDAP, + type User, + type Tenant, + type LDAPSyncResult, +} from "@/lib/api"; + +export function useTenantUsers() { + const [tenantUsersDialogId, setTenantUsersDialogId] = useState(null); + const [tenantUsersDialogName, setTenantUsersDialogName] = useState(""); + const [tenantUsersDialogLdap, setTenantUsersDialogLdap] = useState(false); + const [tenantUsers, setTenantUsers] = useState([]); + const [tenantUsersLoading, setTenantUsersLoading] = useState(false); + const [tenantUsersError, setTenantUsersError] = useState(""); + const [tenantUsersSyncing, setTenantUsersSyncing] = useState(false); + const [tenantUsersSyncResult, setTenantUsersSyncResult] = useState(null); + + async function openUsersDialog(t: Tenant) { + setTenantUsersDialogId(t.id); + setTenantUsersDialogName(t.name); + setTenantUsersDialogLdap(t.ldap_enabled === true); + setTenantUsersLoading(true); + setTenantUsers([]); + setTenantUsersError(""); + setTenantUsersSyncResult(null); + try { + const users = await getTenantUsers(t.id); + setTenantUsers(users || []); + } catch (err: unknown) { + setTenantUsersError(err instanceof Error ? err.message : "Nutzer konnten nicht geladen werden."); + } finally { + setTenantUsersLoading(false); + } + } + + async function handleSyncLDAPUsers() { + if (!tenantUsersDialogId) return; + setTenantUsersSyncing(true); + setTenantUsersSyncResult(null); + try { + const result = await syncAdminTenantLDAP(tenantUsersDialogId); + setTenantUsersSyncResult(result); + const users = await getTenantUsers(tenantUsersDialogId); + setTenantUsers(users || []); + } catch (err: unknown) { + setTenantUsersSyncResult({ synced: 0, errors: [err instanceof Error ? err.message : "Sync fehlgeschlagen"] }); + } finally { + setTenantUsersSyncing(false); + } + } + + return { + tenantUsersDialogId, + setTenantUsersDialogId, + tenantUsersDialogName, + tenantUsersDialogLdap, + tenantUsers, + tenantUsersLoading, + tenantUsersError, + tenantUsersSyncing, + tenantUsersSyncResult, + openUsersDialog, + handleSyncLDAPUsers, + }; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 840dc76..6ec3072 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,1085 +1,6 @@ -import { clearAuthCache } from "@/lib/auth-cache"; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; - -async function request( - path: string, - options: RequestInit = {} -): Promise { - const headers: Record = { - "Content-Type": "application/json", - ...(options.headers as Record), - }; - - const res = await fetch(`${API_BASE}${path}`, { - ...options, - headers, - credentials: "include", - }); - - if (res.status === 401) { - clearAuthCache(); - throw new Error("Unauthorized"); - } - - if (!res.ok) { - const body = await res.text(); - throw new Error(body || `Request failed: ${res.status}`); - } - - if (res.status === 204) return {} as T; - - return res.json(); -} - -// Types - -export interface LoginResponse { - user: { - id: number; - username: string; - email: string; - role: string; - }; -} - -export interface User { - id: number; - username: string; - email: string; - role: string; - source?: string; - active: boolean; - tenant_id?: number; -} - -export interface MeResponse { - username: string; - role: string; - email: string; -} - -export interface SMTPStatus { - // global daemon fields (superadmin) - running?: boolean; - enabled?: boolean; - bind?: string; - domain?: string; - tls?: boolean; - max_size_mb?: number; - allowed_ips?: string[]; - received?: number; - rejected?: number; - last_mail_at?: string; - // tenant-scoped fields (domain_admin) - tenant_only?: boolean; - domains?: string[]; - total_mails?: number; - total_bytes?: number; -} - -export interface HealthResponse { - status: string; -} - -export interface SearchHit { - id: string; - score: number; - from?: string; - to?: string; - subject?: string; - date?: string; - size?: number; - has_attachments?: boolean; -} - -export interface SearchResponse { - total: number; - hits: SearchHit[]; -} - -export interface MailAttachment { - index: number; - filename: string; - content_type: string; - size: number; -} - -export interface MailDetail { - id: string; - from: string; - to: string; - cc?: string; - subject: string; - date: string; - size: number; - body_html?: string; - body_plain?: string; - raw_headers: string; - attachments: MailAttachment[]; - verify_ok: boolean | null; - verified_at: string | null; -} - -export interface AuditEntry { - id: string; - timestamp: string; - event_type: string; - username: string; - detail: string; -} - -export interface AuditResponse { - total: number; - entries: AuditEntry[]; -} - -export interface CreateUserRequest { - username: string; - email: string; - password: string; - role: string; -} - -export interface UpdateUserRequest { - email?: string; - role?: string; - active?: boolean; - password?: string; -} - -// API functions - -export async function login( - username: string, - password: string -): Promise { - return request("/api/auth/login", { - method: "POST", - body: JSON.stringify({ username, password }), - }); -} - -export async function getMe(): Promise { - return request("/api/auth/me"); -} - -export async function logout(): Promise { - clearAuthCache(); - await request("/api/auth/logout", { method: "POST" }); -} - -export async function searchEmails(params: { - q?: string; - from?: string; - to?: string; - date_from?: string; - date_to?: string; - sort?: string; - has_attachment?: boolean; - label_id?: number; - page?: number; - page_size?: number; -}): Promise { - const sp = new URLSearchParams(); - if (params.q) sp.set("q", params.q); - if (params.from) sp.set("from", params.from); - if (params.to) sp.set("to", params.to); - if (params.date_from) sp.set("date_from", params.date_from); - if (params.date_to) sp.set("date_to", params.date_to); - if (params.sort) sp.set("sort", params.sort); - if (params.has_attachment !== undefined) sp.set("has_attachment", String(params.has_attachment)); - if (params.label_id !== undefined) sp.set("label_id", String(params.label_id)); - if (params.page) sp.set("page", String(params.page)); - if (params.page_size) sp.set("page_size", String(params.page_size)); - return request(`/api/search?${sp.toString()}`); -} - -export async function getUsers(): Promise { - return request("/api/users"); -} - -export async function createUser(data: CreateUserRequest): Promise { - return request("/api/users", { - method: "POST", - body: JSON.stringify(data), - }); -} - -export async function updateUser(id: number, data: UpdateUserRequest): Promise { - return request(`/api/users/${id}`, { - method: "PATCH", - body: JSON.stringify(data), - }); -} - -export async function deleteUser(id: number): Promise { - await request(`/api/users/${id}`, { method: "DELETE" }); -} - -export interface StorageStats { - total_mails: number; - total_bytes: number; -} - -export async function getStorageStats(): Promise { - return request("/api/admin/storage/stats"); -} - -export async function getSMTPStatus(): Promise { - return request("/api/admin/smtp/status"); -} - -export async function getHealth(): Promise { - return request("/api/health"); -} - -export async function getMail(id: string): Promise { - return request(`/api/mails/${id}`); -} - -export async function downloadMailAttachment( - id: string, - index: number -): Promise<{ blob: Blob; filename: string }> { - const res = await fetch(`${API_BASE}/api/mails/${id}/attachments/${index}`, { - credentials: "include", - }); - if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`); - const disposition = res.headers.get("Content-Disposition") || ""; - const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); - const filename = match ? match[1].replace(/['"]/g, "") : `anhang-${index}`; - return { blob: await res.blob(), filename }; -} - -export async function downloadMailRaw( - id: string -): Promise<{ blob: Blob; filename: string }> { - const res = await fetch(`${API_BASE}/api/mails/${id}/raw`, { - credentials: "include", - }); - if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`); - return { blob: await res.blob(), filename: `${id}.eml` }; -} - -export interface ServiceStatus { - name: string; - display_name: string; - active: string; - sub: string; - enabled: string; - description: string; - external_blocked?: boolean; -} - -export async function getServices(): Promise { - return request("/api/admin/services"); -} - -export async function serviceAction( - name: string, - action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external" -): Promise { - return request(`/api/admin/services/${encodeURIComponent(name)}/action`, { - method: "POST", - body: JSON.stringify({ action }), - }); -} - -export async function getAuditLog(params: { - page?: number; - page_size?: number; - username?: string; - event_type?: string; -}): Promise { - const sp = new URLSearchParams(); - if (params.page) sp.set("page", String(params.page)); - if (params.page_size) sp.set("page_size", String(params.page_size)); - if (params.username) sp.set("username", params.username); - if (params.event_type) sp.set("event_type", params.event_type); - return request(`/api/audit?${sp.toString()}`); -} - -// ── IMAP ────────────────────────────────────────────────────────────────── - -export interface ImapFolder { - name: string; - excluded: boolean; - reason?: string; -} - -export interface ImapAccount { - id: number; - owner: string; - name: string; - host: string; - port: number; - tls: string; - username: string; - excluded_folders: string[]; - status: string; - error_msg: string; - last_import_at?: string; - last_import_count: number; - progress_current: number; - progress_total: number; - created_at: string; - // PROJ-8: Auto-sync fields - sync_interval_min: number; - last_sync_at?: string; - last_sync_count: number; - sync_running: boolean; - sync_status: string; - sync_error_msg: string; -} - -export interface ImapTestResult { - ok: boolean; - folders?: ImapFolder[]; - error?: string; -} - -export async function getImapAccounts(): Promise { - return request("/api/imap"); -} - -export async function createImapAccount(data: { - name: string; - host: string; - port: number; - tls: string; - username: string; - password: string; - excluded_folders: string[]; -}): Promise { - return request("/api/imap", { - method: "POST", - body: JSON.stringify(data), - }); -} - -export async function deleteImapAccount(id: number): Promise { - await request(`/api/imap/${id}`, { method: "DELETE" }); -} - -export async function testImapConnection(data: { - host: string; - port: number; - tls: string; - username: string; - password: string; -}): Promise { - return request("/api/imap/test", { - method: "POST", - body: JSON.stringify(data), - }); -} - -export async function startImapImport(id: number): Promise { - return request(`/api/imap/${id}/import`, { method: "POST" }); -} - -export async function getImapProgress(id: number): Promise { - return request(`/api/imap/${id}/progress`); -} - -export async function triggerImapSync(id: number): Promise { - return request(`/api/imap/${id}/sync`, { method: "POST" }); -} - -export async function updateImapInterval(id: number, intervalMin: number): Promise { - return request(`/api/imap/${id}`, { - method: "PATCH", - body: JSON.stringify({ sync_interval_min: intervalMin }), - }); -} - -export async function updateImapAccount( - id: number, - data: { name: string; host: string; port: number; tls: string; username: string; password?: string } -): Promise { - return request(`/api/imap/${id}`, { - method: "PATCH", - body: JSON.stringify(data), - }); -} - -// ── POP3 ────────────────────────────────────────────────────────────────── - -export interface Pop3Account { - id: number; - owner: string; - name: string; - host: string; - port: number; - tls: string; - tls_skip_verify: boolean; - username: string; - status: string; - error_msg: string; - last_import_at?: string; - last_import_count: number; - progress_current: number; - progress_total: number; - created_at: string; -} - -export interface Pop3TestResult { - ok: boolean; - message: string; - message_count?: number; - total_size_bytes?: number; -} - -export async function getPop3Accounts(): Promise { - return request("/api/pop3"); -} - -export async function createPop3Account(data: { - name: string; - host: string; - port: number; - tls: string; - tls_skip_verify: boolean; - username: string; - password: string; -}): Promise { - return request("/api/pop3", { - method: "POST", - body: JSON.stringify(data), - }); -} - -export async function deletePop3Account(id: number): Promise { - await request(`/api/pop3/${id}`, { method: "DELETE" }); -} - -export async function testPop3Connection(data: { - host: string; - port: number; - tls: string; - tls_skip_verify: boolean; - username: string; - password: string; -}): Promise { - return request("/api/pop3/test", { - method: "POST", - body: JSON.stringify(data), - }); -} - -export async function startPop3Import(id: number): Promise { - return request(`/api/pop3/${id}/import`, { method: "POST" }); -} - -export async function getPop3Progress(id: number): Promise { - return request(`/api/pop3/${id}/progress`); -} - -// ── System Stats ────────────────────────────────────────────────────────── - -export interface SystemStatsCPU { - load1: number; - load5: number; - load15: number; - num_cpu: number; -} - -export interface SystemStatsRAM { - total_bytes: number; - used_bytes: number; - free_bytes: number; - used_pct: number; -} - -export interface SystemStatsDisk { - mount: string; - total_bytes: number; - used_bytes: number; - free_bytes: number; - used_pct: number; - fstype: string; -} - -export interface SystemStatsMailInfo { - id: string; - date: string; - from: string; - subject: string; -} - -export interface SystemStats { - cpu: SystemStatsCPU; - ram: SystemStatsRAM; - disks: SystemStatsDisk[]; - archive: { - first_mail: SystemStatsMailInfo | null; - last_mail: SystemStatsMailInfo | null; - }; -} - -export async function getSystemStats(): Promise { - return request("/api/admin/system/stats"); -} - -// ── Export ──────────────────────────────────────────────────────────────── - -export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> { - const res = await fetch(`${API_BASE}/api/export/pdf/${id}`, { - credentials: "include", - }); - if (!res.ok) throw new Error("PDF export failed"); - const blob = await res.blob(); - const cd = res.headers.get("Content-Disposition") || ""; - const filename = cd.match(/filename="([^"]+)"/)?.[1] || `${id.slice(0, 16)}.pdf`; - return { blob, filename }; -} - -export async function exportMailsZIP(ids: string[], attachments: boolean): Promise<{ blob: Blob }> { - const res = await fetch(`${API_BASE}/api/export/zip`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ ids, attachments }), - }); - if (!res.ok) throw new Error("ZIP export failed"); - return { blob: await res.blob() }; -} - -// ── Upload ──────────────────────────────────────────────────────────────── - -export interface UploadJob { - id: string; - status: "running" | "done" | "error"; - total: number; - imported: number; - skipped: number; - errors: number; - error_msg?: string; -} - -export async function uploadMailFiles(files: File[]): Promise<{ job_id: string }> { - const form = new FormData(); - for (const f of files) form.append("files", f); - const res = await fetch(`${API_BASE}/api/admin/upload`, { - method: "POST", - credentials: "include", - body: form, - }); - if (!res.ok) { - const body = await res.text(); - throw new Error(body || `Upload failed: ${res.status}`); - } - return res.json(); -} - -export async function getUploadProgress(jobID: string): Promise { - return request(`/api/admin/upload/${jobID}/progress`); -} - -export async function uploadMailFilesUser(files: File[]): Promise<{ job_id: string }> { - const form = new FormData(); - for (const f of files) form.append("files", f); - const res = await fetch(`${API_BASE}/api/upload`, { - method: "POST", - credentials: "include", - body: form, - }); - if (!res.ok) { - const body = await res.text(); - throw new Error(body || `Upload failed: ${res.status}`); - } - return res.json(); -} - -export async function getUploadProgressUser(jobID: string): Promise { - return request(`/api/upload/${jobID}/progress`); -} - -// ── Security Audit ──────────────────────────────────────────────────────── - -export interface SecurityCheck { - name: string; - status: "ok" | "warning" | "error"; - message: string; -} - -export interface SecurityAuditResult { - checks: SecurityCheck[]; - run_at: string; -} - -export async function getSecurityAudit(): Promise { - return request("/api/admin/security/audit"); -} - -export async function fixSecurityIssue(action: string): Promise<{ message: string }> { - return request<{ message: string }>("/api/admin/security/fix", { - method: "POST", - body: JSON.stringify({ action }), - }); -} - -// ── LDAP ────────────────────────────────────────────────────────────────── - -export interface LDAPGroupMapping { - group_dn: string; - role: string; -} - -export interface LDAPConfig { - id?: number; - enabled: boolean; - url: string; - bind_dn: string; - bind_password: string; - base_dn: string; - user_filter: string; - tls: boolean; - tls_skip_verify: boolean; - default_role: string; - group_mappings: LDAPGroupMapping[]; - updated_at?: string; - updated_by?: string; -} - -export interface LDAPTestUser { - dn: string; - uid: string; - display_name: string; - mail: string; -} - -export interface LDAPTestResult { - ok: boolean; - message: string; - latency_ms: number; - server_info: string; - users_found: number; - users: LDAPTestUser[]; - error_detail: string; -} - -export async function getLDAPConfig(): Promise { - try { - return await request("/api/admin/ldap"); - } catch (e: unknown) { - if (e instanceof Error && e.message.includes("404")) return null; - if (e instanceof Error && e.message.includes("no ldap config")) return null; - throw e; - } -} - -export async function saveLDAPConfig(cfg: Partial): Promise { - await request("/api/admin/ldap", { - method: "PUT", - body: JSON.stringify(cfg), - }); -} - -export async function deleteLDAPConfig(): Promise { - await request("/api/admin/ldap", { method: "DELETE" }); -} - -export async function testLDAPConfig( - payload: { use_saved: boolean } & Partial -): Promise { - return request("/api/admin/ldap/test", { - method: "POST", - body: JSON.stringify(payload), - }); -} - -// ── Tenants ──────────────────────────────────────────────────────────────── - -export interface Tenant { - id: number; - name: string; - slug: string; - active: boolean; - created_at: string; - domain_count?: number; - user_count?: number; - ldap_enabled?: boolean; - ldap_url?: string; - has_logo?: boolean; -} - -export interface TenantDomain { - id: number; - tenant_id: number; - domain: string; - created_at: string; -} - -export async function getTenants(): Promise { - return request("/api/tenants"); -} - -export async function getTenantUsers(tenantId: number): Promise { - return request(`/api/tenants/${tenantId}/users`); -} - -export interface TenantDefaultUser { - username: string; - password: string; - role: string; -} - -export interface CreateTenantResponse extends Tenant { - default_users: TenantDefaultUser[]; -} - -export async function createTenant(name: string, slug: string): Promise { - return request("/api/tenants", { - method: "POST", - body: JSON.stringify({ name, slug }), - }); -} - -export async function updateTenant( - id: number, - data: { name?: string; active?: boolean } -): Promise { - return request(`/api/tenants/${id}`, { - method: "PATCH", - body: JSON.stringify(data), - }); -} - -export async function deleteTenant(id: number): Promise { - await request(`/api/tenants/${id}`, { method: "DELETE" }); -} - -export async function getTenantDomains(id: number): Promise { - return request(`/api/tenants/${id}/domains`); -} - -export async function addTenantDomain( - tenantId: number, - domain: string -): Promise { - return request(`/api/tenants/${tenantId}/domains`, { - method: "POST", - body: JSON.stringify({ domain }), - }); -} - -export async function removeTenantDomain( - tenantId: number, - domainId: number -): Promise { - await request(`/api/tenants/${tenantId}/domains/${domainId}`, { - method: "DELETE", - }); -} - -// ── Tenant Logo ───────────────────────────────────────────────────────────── - -export function getTenantLogoUrl(tenantId: number): string { - return `${API_BASE}/api/tenants/${tenantId}/logo`; -} - -export async function uploadTenantLogo(tenantId: number, file: File): Promise { - const form = new FormData(); - form.append("logo", file); - const res = await fetch(`${API_BASE}/api/tenants/${tenantId}/logo`, { - method: "POST", - body: form, - credentials: "include", - }); - if (!res.ok) { - const body = await res.text(); - throw new Error(body || `Upload failed: ${res.status}`); - } -} - -export async function deleteTenantLogo(tenantId: number): Promise { - await request(`/api/tenants/${tenantId}/logo`, { method: "DELETE" }); -} - -// domain_admin: own tenant logo -export async function uploadMyTenantLogo(file: File): Promise { - const form = new FormData(); - form.append("logo", file); - const res = await fetch(`${API_BASE}/api/tenant/logo`, { - method: "POST", - body: form, - credentials: "include", - }); - if (!res.ok) { - const body = await res.text(); - throw new Error(body || `Upload failed: ${res.status}`); - } -} - -export async function deleteMyTenantLogo(): Promise { - await request("/api/tenant/logo", { method: "DELETE" }); -} - -// ── PROJ-23: Pro-Mandant LDAP (tenant_ldap) ────────────────────────────── - -export interface TenantLDAPConfig extends LDAPConfig { - tenant_id?: number; -} - -// domain_admin -- eigener Mandant -export async function getTenantLDAPConfig(): Promise { - try { - return await request("/api/tenant/ldap"); - } catch (e: unknown) { - if (e instanceof Error && e.message.includes("404")) return null; - if (e instanceof Error && e.message.includes("no ldap config")) return null; - throw e; - } -} - -export async function saveTenantLDAPConfig(cfg: Partial): Promise { - await request("/api/tenant/ldap", { method: "PUT", body: JSON.stringify(cfg) }); -} - -export async function deleteTenantLDAPConfig(): Promise { - await request("/api/tenant/ldap", { method: "DELETE" }); -} - -export async function testTenantLDAPConfig( - payload: { use_saved: boolean } & Partial -): Promise { - return request("/api/tenant/ldap/test", { - method: "POST", - body: JSON.stringify(payload), - }); -} - -// superadmin -- beliebiger Mandant per ID -export async function getAdminTenantLDAPConfig(tenantID: number): Promise { - try { - return await request(`/api/admin/tenants/${tenantID}/ldap`); - } catch (e: unknown) { - if (e instanceof Error && e.message.includes("404")) return null; - if (e instanceof Error && e.message.includes("no ldap config")) return null; - throw e; - } -} - -export async function saveAdminTenantLDAPConfig(tenantID: number, cfg: Partial): Promise { - await request(`/api/admin/tenants/${tenantID}/ldap`, { method: "PUT", body: JSON.stringify(cfg) }); -} - -export async function deleteAdminTenantLDAPConfig(tenantID: number): Promise { - await request(`/api/admin/tenants/${tenantID}/ldap`, { method: "DELETE" }); -} - -export async function testAdminTenantLDAPConfig( - tenantID: number, - payload: { use_saved: boolean } & Partial -): Promise { - return request(`/api/admin/tenants/${tenantID}/ldap/test`, { - method: "POST", - body: JSON.stringify(payload), - }); -} - -export interface LDAPSyncResult { - synced: number; - errors: string[]; -} - -export async function syncAdminTenantLDAP(tenantID: number): Promise { - return request(`/api/admin/tenants/${tenantID}/ldap/sync`, { method: "POST", body: "{}" }); -} - -// ── Profil-Einstellungen ────────────────────────────────────────────────── - -export async function changePassword( - currentPassword: string, - newPassword: string -): Promise<{ ok: boolean }> { - return request<{ ok: boolean }>("/api/auth/password", { - method: "PATCH", - body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), - }); -} - -export async function changeEmail( - email: string -): Promise<{ ok: boolean; email: string }> { - return request<{ ok: boolean; email: string }>("/api/auth/email", { - method: "PATCH", - body: JSON.stringify({ email }), - }); -} - -// ── TOTP / 2FA ──────────────────────────────────────────────────────────── - -export async function getTOTPSetup(): Promise<{ secret: string; otpauth_url: string; qr_code: string }> { - return request<{ secret: string; otpauth_url: string; qr_code: string }>("/api/auth/totp/setup"); -} - -export async function confirmTOTPSetup(code: string): Promise<{ ok: boolean }> { - return request<{ ok: boolean }>("/api/auth/totp/setup", { - method: "POST", - body: JSON.stringify({ code }), - }); -} - -export async function disableTOTP(code: string): Promise<{ ok: boolean }> { - return request<{ ok: boolean }>("/api/auth/totp", { - method: "DELETE", - body: JSON.stringify({ code }), - }); -} - -// ── Labels ────────────────────────────────────────────────────────────────── - -export interface MailLabel { - id: number; - name: string; - color: string; - owner_id?: number; - tenant_id: number; - is_global: boolean; - created_at: string; -} - -export interface LabelRule { - id: number; - condition_field: "from_domain" | "source" | "subject_contains"; - condition_value: string; - label_id: number; - tenant_id: number; -} - -export async function getLabels(): Promise { - return request("/api/labels"); -} - -export async function createLabel(name: string, color: string): Promise { - return request("/api/labels", { - method: "POST", - body: JSON.stringify({ name, color }), - }); -} - -export async function updateLabel(id: number, name: string, color: string): Promise { - return request(`/api/labels/${id}`, { - method: "PATCH", - body: JSON.stringify({ name, color }), - }); -} - -export async function deleteLabel(id: number): Promise { - return request(`/api/labels/${id}`, { method: "DELETE" }); -} - -export async function assignLabel(emailId: string, labelId: number): Promise { - return request(`/api/mails/${emailId}/labels`, { - method: "POST", - body: JSON.stringify({ label_id: labelId }), - }); -} - -export async function removeLabelFromEmail(emailId: string, labelId: number): Promise { - return request(`/api/mails/${emailId}/labels/${labelId}`, { - method: "DELETE", - }); -} - -export async function getMailLabelIds(emailId: string): Promise { - return request(`/api/mails/${emailId}/labels`); -} - -export async function createAdminLabel(name: string, color: string): Promise { - return request("/api/admin/labels", { - method: "POST", - body: JSON.stringify({ name, color }), - }); -} - -export async function getAdminLabels(): Promise { - return request("/api/admin/labels"); -} - -export async function deleteAdminLabel(id: number): Promise { - return request(`/api/admin/labels/${id}`, { method: "DELETE" }); -} - -export async function getLabelRules(): Promise { - return request("/api/admin/label-rules"); -} - -export async function createLabelRule( - condition_field: string, - condition_value: string, - label_id: number -): Promise { - return request("/api/admin/label-rules", { - method: "POST", - body: JSON.stringify({ condition_field, condition_value, label_id }), - }); -} - -export async function deleteLabelRule(id: number): Promise { - return request(`/api/admin/label-rules/${id}`, { method: "DELETE" }); -} - -// ── Certificate Management ──────────────────────────────────────────────── - -export interface CertInfo { - exists: boolean; - subject?: string; - issuer?: string; - not_before?: string; - not_after?: string; - dns_names?: string[]; - ip_addresses?: string[]; - fingerprint_sha256?: string; - is_self_signed?: boolean; - days_remaining?: number; -} - -export interface SelfSignedRequest { - common_name: string; - dns_names: string[]; - ip_addresses: string[]; - validity_years: number; -} - -export interface ACMERequest { - domain: string; - email: string; -} - -export async function getCertInfo(): Promise { - return request("/api/admin/cert/info"); -} - -export async function uploadCert(cert: File, key: File): Promise<{ ok: boolean; message: string }> { - const form = new FormData(); - form.append("cert", cert); - form.append("key", key); - const res = await fetch(`${API_BASE}/api/admin/cert/upload`, { - method: "POST", - credentials: "include", - body: form, - }); - if (!res.ok) { - const body = await res.text(); - throw new Error(body || `Upload failed: ${res.status}`); - } - return res.json(); -} - -export async function generateSelfSignedCert(req: SelfSignedRequest): Promise { - return request("/api/admin/cert/self-signed", { - method: "POST", - body: JSON.stringify(req), - }); -} - -export async function requestACMECert(req: ACMERequest): Promise<{ ok: boolean; output: string }> { - return request<{ ok: boolean; output: string }>("/api/admin/cert/acme", { - method: "POST", - body: JSON.stringify(req), - }); -} +// This file is kept for backward compatibility. +// All exports have been moved to src/lib/api/ (modular structure). +// Imports from "@/lib/api" resolve to src/lib/api/index.ts automatically +// because Next.js/TypeScript resolves directory index files. +// This file re-exports everything for any tooling that resolves to api.ts directly. +export * from "./api/index"; diff --git a/src/lib/api/core.ts b/src/lib/api/core.ts new file mode 100644 index 0000000..07b8c81 --- /dev/null +++ b/src/lib/api/core.ts @@ -0,0 +1,33 @@ +import { clearAuthCache } from "@/lib/auth-cache"; + +export const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; + +export async function request( + path: string, + options: RequestInit = {} +): Promise { + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record), + }; + + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + credentials: "include", + }); + + if (res.status === 401) { + clearAuthCache(); + throw new Error("Unauthorized"); + } + + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `Request failed: ${res.status}`); + } + + if (res.status === 204) return {} as T; + + return res.json(); +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..5c5b006 --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,161 @@ +// Re-export everything from domain modules so that existing imports +// from "@/lib/api" continue to work without modification. + +export { API_BASE, request } from "./core"; + +export type { + LoginResponse, + User, + MeResponse, + CreateUserRequest, + UpdateUserRequest, +} from "./users"; +export { + login, + getMe, + logout, + changePassword, + changeEmail, + getUsers, + createUser, + updateUser, + deleteUser, + getTOTPSetup, + confirmTOTPSetup, + disableTOTP, +} from "./users"; + +export type { + LDAPGroupMapping, + LDAPConfig, + LDAPTestUser, + LDAPTestResult, +} from "./ldap"; +export { + getLDAPConfig, + saveLDAPConfig, + deleteLDAPConfig, + testLDAPConfig, +} from "./ldap"; + +export type { + Tenant, + TenantDomain, + TenantDefaultUser, + CreateTenantResponse, + TenantLDAPConfig, + LDAPSyncResult, +} from "./tenants"; +export { + getTenants, + getTenantUsers, + createTenant, + updateTenant, + deleteTenant, + getTenantDomains, + addTenantDomain, + removeTenantDomain, + getTenantLogoUrl, + uploadTenantLogo, + deleteTenantLogo, + uploadMyTenantLogo, + deleteMyTenantLogo, + getTenantLDAPConfig, + saveTenantLDAPConfig, + deleteTenantLDAPConfig, + testTenantLDAPConfig, + getAdminTenantLDAPConfig, + saveAdminTenantLDAPConfig, + deleteAdminTenantLDAPConfig, + testAdminTenantLDAPConfig, + syncAdminTenantLDAP, +} from "./tenants"; + +export type { + SearchHit, + SearchResponse, + MailAttachment, + MailDetail, + ImapFolder, + ImapAccount, + ImapTestResult, + Pop3Account, + Pop3TestResult, + UploadJob, +} from "./mail"; +export { + searchEmails, + getMail, + downloadMailAttachment, + downloadMailRaw, + getImapAccounts, + createImapAccount, + deleteImapAccount, + testImapConnection, + startImapImport, + getImapProgress, + triggerImapSync, + updateImapInterval, + updateImapAccount, + getPop3Accounts, + createPop3Account, + deletePop3Account, + testPop3Connection, + startPop3Import, + getPop3Progress, + exportMailPDF, + exportMailsZIP, + uploadMailFiles, + getUploadProgress, + uploadMailFilesUser, + getUploadProgressUser, +} from "./mail"; + +export type { + HealthResponse, + SMTPStatus, + StorageStats, + ServiceStatus, + AuditEntry, + AuditResponse, + SystemStatsCPU, + SystemStatsRAM, + SystemStatsDisk, + SystemStatsMailInfo, + SystemStats, + SecurityCheck, + SecurityAuditResult, + MailLabel, + LabelRule, + CertInfo, + SelfSignedRequest, + ACMERequest, +} from "./system"; +export { + getHealth, + getSMTPStatus, + getStorageStats, + getSystemStats, + getServices, + serviceAction, + getAuditLog, + getSecurityAudit, + fixSecurityIssue, + getLabels, + createLabel, + updateLabel, + deleteLabel, + assignLabel, + removeLabelFromEmail, + getMailLabelIds, + createAdminLabel, + getAdminLabels, + deleteAdminLabel, + getLabelRules, + createLabelRule, + deleteLabelRule, + getCertInfo, + uploadCert, + generateSelfSignedCert, + requestACMECert, +} from "./system"; diff --git a/src/lib/api/ldap.ts b/src/lib/api/ldap.ts new file mode 100644 index 0000000..bd73bf0 --- /dev/null +++ b/src/lib/api/ldap.ts @@ -0,0 +1,73 @@ +import { request } from "./core"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface LDAPGroupMapping { + group_dn: string; + role: string; +} + +export interface LDAPConfig { + id?: number; + enabled: boolean; + url: string; + bind_dn: string; + bind_password: string; + base_dn: string; + user_filter: string; + tls: boolean; + tls_skip_verify: boolean; + default_role: string; + group_mappings: LDAPGroupMapping[]; + updated_at?: string; + updated_by?: string; +} + +export interface LDAPTestUser { + dn: string; + uid: string; + display_name: string; + mail: string; +} + +export interface LDAPTestResult { + ok: boolean; + message: string; + latency_ms: number; + server_info: string; + users_found: number; + users: LDAPTestUser[]; + error_detail: string; +} + +// ── Global LDAP (superadmin) ────────────────────────────────────────────────── + +export async function getLDAPConfig(): Promise { + try { + return await request("/api/admin/ldap"); + } catch (e: unknown) { + if (e instanceof Error && e.message.includes("404")) return null; + if (e instanceof Error && e.message.includes("no ldap config")) return null; + throw e; + } +} + +export async function saveLDAPConfig(cfg: Partial): Promise { + await request("/api/admin/ldap", { + method: "PUT", + body: JSON.stringify(cfg), + }); +} + +export async function deleteLDAPConfig(): Promise { + await request("/api/admin/ldap", { method: "DELETE" }); +} + +export async function testLDAPConfig( + payload: { use_saved: boolean } & Partial +): Promise { + return request("/api/admin/ldap/test", { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/src/lib/api/mail.ts b/src/lib/api/mail.ts new file mode 100644 index 0000000..9a169d3 --- /dev/null +++ b/src/lib/api/mail.ts @@ -0,0 +1,350 @@ +import { API_BASE, request } from "./core"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface SearchHit { + id: string; + score: number; + from?: string; + to?: string; + subject?: string; + date?: string; + size?: number; + has_attachments?: boolean; +} + +export interface SearchResponse { + total: number; + hits: SearchHit[]; +} + +export interface MailAttachment { + index: number; + filename: string; + content_type: string; + size: number; +} + +export interface MailDetail { + id: string; + from: string; + to: string; + cc?: string; + subject: string; + date: string; + size: number; + body_html?: string; + body_plain?: string; + raw_headers: string; + attachments: MailAttachment[]; + verify_ok: boolean | null; + verified_at: string | null; +} + +export interface ImapFolder { + name: string; + excluded: boolean; + reason?: string; +} + +export interface ImapAccount { + id: number; + owner: string; + name: string; + host: string; + port: number; + tls: string; + username: string; + excluded_folders: string[]; + status: string; + error_msg: string; + last_import_at?: string; + last_import_count: number; + progress_current: number; + progress_total: number; + created_at: string; + // PROJ-8: Auto-sync fields + sync_interval_min: number; + last_sync_at?: string; + last_sync_count: number; + sync_running: boolean; + sync_status: string; + sync_error_msg: string; +} + +export interface ImapTestResult { + ok: boolean; + folders?: ImapFolder[]; + error?: string; +} + +export interface Pop3Account { + id: number; + owner: string; + name: string; + host: string; + port: number; + tls: string; + tls_skip_verify: boolean; + username: string; + status: string; + error_msg: string; + last_import_at?: string; + last_import_count: number; + progress_current: number; + progress_total: number; + created_at: string; +} + +export interface Pop3TestResult { + ok: boolean; + message: string; + message_count?: number; + total_size_bytes?: number; +} + +export interface UploadJob { + id: string; + status: "running" | "done" | "error"; + total: number; + imported: number; + skipped: number; + errors: number; + error_msg?: string; +} + +// ── Search ──────────────────────────────────────────────────────────────────── + +export async function searchEmails(params: { + q?: string; + from?: string; + to?: string; + date_from?: string; + date_to?: string; + sort?: string; + has_attachment?: boolean; + label_id?: number; + page?: number; + page_size?: number; +}): Promise { + const sp = new URLSearchParams(); + if (params.q) sp.set("q", params.q); + if (params.from) sp.set("from", params.from); + if (params.to) sp.set("to", params.to); + if (params.date_from) sp.set("date_from", params.date_from); + if (params.date_to) sp.set("date_to", params.date_to); + if (params.sort) sp.set("sort", params.sort); + if (params.has_attachment !== undefined) sp.set("has_attachment", String(params.has_attachment)); + if (params.label_id !== undefined) sp.set("label_id", String(params.label_id)); + if (params.page) sp.set("page", String(params.page)); + if (params.page_size) sp.set("page_size", String(params.page_size)); + return request(`/api/search?${sp.toString()}`); +} + +// ── Mail detail ─────────────────────────────────────────────────────────────── + +export async function getMail(id: string): Promise { + return request(`/api/mails/${id}`); +} + +export async function downloadMailAttachment( + id: string, + index: number +): Promise<{ blob: Blob; filename: string }> { + const res = await fetch(`${API_BASE}/api/mails/${id}/attachments/${index}`, { + credentials: "include", + }); + if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`); + const disposition = res.headers.get("Content-Disposition") || ""; + const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + const filename = match ? match[1].replace(/['"]/g, "") : `anhang-${index}`; + return { blob: await res.blob(), filename }; +} + +export async function downloadMailRaw( + id: string +): Promise<{ blob: Blob; filename: string }> { + const res = await fetch(`${API_BASE}/api/mails/${id}/raw`, { + credentials: "include", + }); + if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`); + return { blob: await res.blob(), filename: `${id}.eml` }; +} + +// ── IMAP ────────────────────────────────────────────────────────────────────── + +export async function getImapAccounts(): Promise { + return request("/api/imap"); +} + +export async function createImapAccount(data: { + name: string; + host: string; + port: number; + tls: string; + username: string; + password: string; + excluded_folders: string[]; +}): Promise { + return request("/api/imap", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function deleteImapAccount(id: number): Promise { + await request(`/api/imap/${id}`, { method: "DELETE" }); +} + +export async function testImapConnection(data: { + host: string; + port: number; + tls: string; + username: string; + password: string; +}): Promise { + return request("/api/imap/test", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function startImapImport(id: number): Promise { + return request(`/api/imap/${id}/import`, { method: "POST" }); +} + +export async function getImapProgress(id: number): Promise { + return request(`/api/imap/${id}/progress`); +} + +export async function triggerImapSync(id: number): Promise { + return request(`/api/imap/${id}/sync`, { method: "POST" }); +} + +export async function updateImapInterval(id: number, intervalMin: number): Promise { + return request(`/api/imap/${id}`, { + method: "PATCH", + body: JSON.stringify({ sync_interval_min: intervalMin }), + }); +} + +export async function updateImapAccount( + id: number, + data: { name: string; host: string; port: number; tls: string; username: string; password?: string } +): Promise { + return request(`/api/imap/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); +} + +// ── POP3 ────────────────────────────────────────────────────────────────────── + +export async function getPop3Accounts(): Promise { + return request("/api/pop3"); +} + +export async function createPop3Account(data: { + name: string; + host: string; + port: number; + tls: string; + tls_skip_verify: boolean; + username: string; + password: string; +}): Promise { + return request("/api/pop3", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function deletePop3Account(id: number): Promise { + await request(`/api/pop3/${id}`, { method: "DELETE" }); +} + +export async function testPop3Connection(data: { + host: string; + port: number; + tls: string; + tls_skip_verify: boolean; + username: string; + password: string; +}): Promise { + return request("/api/pop3/test", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function startPop3Import(id: number): Promise { + return request(`/api/pop3/${id}/import`, { method: "POST" }); +} + +export async function getPop3Progress(id: number): Promise { + return request(`/api/pop3/${id}/progress`); +} + +// ── Export ──────────────────────────────────────────────────────────────────── + +export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> { + const res = await fetch(`${API_BASE}/api/export/pdf/${id}`, { + credentials: "include", + }); + if (!res.ok) throw new Error("PDF export failed"); + const blob = await res.blob(); + const cd = res.headers.get("Content-Disposition") || ""; + const filename = cd.match(/filename="([^"]+)"/)?.[1] || `${id.slice(0, 16)}.pdf`; + return { blob, filename }; +} + +export async function exportMailsZIP(ids: string[], attachments: boolean): Promise<{ blob: Blob }> { + const res = await fetch(`${API_BASE}/api/export/zip`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ ids, attachments }), + }); + if (!res.ok) throw new Error("ZIP export failed"); + return { blob: await res.blob() }; +} + +// ── Upload ──────────────────────────────────────────────────────────────────── + +export async function uploadMailFiles(files: File[]): Promise<{ job_id: string }> { + const form = new FormData(); + for (const f of files) form.append("files", f); + const res = await fetch(`${API_BASE}/api/admin/upload`, { + method: "POST", + credentials: "include", + body: form, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `Upload failed: ${res.status}`); + } + return res.json(); +} + +export async function getUploadProgress(jobID: string): Promise { + return request(`/api/admin/upload/${jobID}/progress`); +} + +export async function uploadMailFilesUser(files: File[]): Promise<{ job_id: string }> { + const form = new FormData(); + for (const f of files) form.append("files", f); + const res = await fetch(`${API_BASE}/api/upload`, { + method: "POST", + credentials: "include", + body: form, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `Upload failed: ${res.status}`); + } + return res.json(); +} + +export async function getUploadProgressUser(jobID: string): Promise { + return request(`/api/upload/${jobID}/progress`); +} diff --git a/src/lib/api/system.ts b/src/lib/api/system.ts new file mode 100644 index 0000000..e8d73b0 --- /dev/null +++ b/src/lib/api/system.ts @@ -0,0 +1,322 @@ +import { API_BASE, request } from "./core"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface HealthResponse { + status: string; +} + +export interface SMTPStatus { + // global daemon fields (superadmin) + running?: boolean; + enabled?: boolean; + bind?: string; + domain?: string; + tls?: boolean; + max_size_mb?: number; + allowed_ips?: string[]; + received?: number; + rejected?: number; + last_mail_at?: string; + // tenant-scoped fields (domain_admin) + tenant_only?: boolean; + domains?: string[]; + total_mails?: number; + total_bytes?: number; +} + +export interface StorageStats { + total_mails: number; + total_bytes: number; +} + +export interface ServiceStatus { + name: string; + display_name: string; + active: string; + sub: string; + enabled: string; + description: string; + external_blocked?: boolean; +} + +export interface AuditEntry { + id: string; + timestamp: string; + event_type: string; + username: string; + detail: string; +} + +export interface AuditResponse { + total: number; + entries: AuditEntry[]; +} + +export interface SystemStatsCPU { + load1: number; + load5: number; + load15: number; + num_cpu: number; +} + +export interface SystemStatsRAM { + total_bytes: number; + used_bytes: number; + free_bytes: number; + used_pct: number; +} + +export interface SystemStatsDisk { + mount: string; + total_bytes: number; + used_bytes: number; + free_bytes: number; + used_pct: number; + fstype: string; +} + +export interface SystemStatsMailInfo { + id: string; + date: string; + from: string; + subject: string; +} + +export interface SystemStats { + cpu: SystemStatsCPU; + ram: SystemStatsRAM; + disks: SystemStatsDisk[]; + archive: { + first_mail: SystemStatsMailInfo | null; + last_mail: SystemStatsMailInfo | null; + }; +} + +export interface SecurityCheck { + name: string; + status: "ok" | "warning" | "error"; + message: string; +} + +export interface SecurityAuditResult { + checks: SecurityCheck[]; + run_at: string; +} + +export interface MailLabel { + id: number; + name: string; + color: string; + owner_id?: number; + tenant_id: number; + is_global: boolean; + created_at: string; +} + +export interface LabelRule { + id: number; + condition_field: "from_domain" | "source" | "subject_contains"; + condition_value: string; + label_id: number; + tenant_id: number; +} + +export interface CertInfo { + exists: boolean; + subject?: string; + issuer?: string; + not_before?: string; + not_after?: string; + dns_names?: string[]; + ip_addresses?: string[]; + fingerprint_sha256?: string; + is_self_signed?: boolean; + days_remaining?: number; +} + +export interface SelfSignedRequest { + common_name: string; + dns_names: string[]; + ip_addresses: string[]; + validity_years: number; +} + +export interface ACMERequest { + domain: string; + email: string; +} + +// ── Health & Stats ──────────────────────────────────────────────────────────── + +export async function getHealth(): Promise { + return request("/api/health"); +} + +export async function getSMTPStatus(): Promise { + return request("/api/admin/smtp/status"); +} + +export async function getStorageStats(): Promise { + return request("/api/admin/storage/stats"); +} + +export async function getSystemStats(): Promise { + return request("/api/admin/system/stats"); +} + +// ── Services ────────────────────────────────────────────────────────────────── + +export async function getServices(): Promise { + return request("/api/admin/services"); +} + +export async function serviceAction( + name: string, + action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external" +): Promise { + return request(`/api/admin/services/${encodeURIComponent(name)}/action`, { + method: "POST", + body: JSON.stringify({ action }), + }); +} + +// ── Audit ───────────────────────────────────────────────────────────────────── + +export async function getAuditLog(params: { + page?: number; + page_size?: number; + username?: string; + event_type?: string; +}): Promise { + const sp = new URLSearchParams(); + if (params.page) sp.set("page", String(params.page)); + if (params.page_size) sp.set("page_size", String(params.page_size)); + if (params.username) sp.set("username", params.username); + if (params.event_type) sp.set("event_type", params.event_type); + return request(`/api/audit?${sp.toString()}`); +} + +// ── Security ────────────────────────────────────────────────────────────────── + +export async function getSecurityAudit(): Promise { + return request("/api/admin/security/audit"); +} + +export async function fixSecurityIssue(action: string): Promise<{ message: string }> { + return request<{ message: string }>("/api/admin/security/fix", { + method: "POST", + body: JSON.stringify({ action }), + }); +} + +// ── Labels ──────────────────────────────────────────────────────────────────── + +export async function getLabels(): Promise { + return request("/api/labels"); +} + +export async function createLabel(name: string, color: string): Promise { + return request("/api/labels", { + method: "POST", + body: JSON.stringify({ name, color }), + }); +} + +export async function updateLabel(id: number, name: string, color: string): Promise { + return request(`/api/labels/${id}`, { + method: "PATCH", + body: JSON.stringify({ name, color }), + }); +} + +export async function deleteLabel(id: number): Promise { + return request(`/api/labels/${id}`, { method: "DELETE" }); +} + +export async function assignLabel(emailId: string, labelId: number): Promise { + return request(`/api/mails/${emailId}/labels`, { + method: "POST", + body: JSON.stringify({ label_id: labelId }), + }); +} + +export async function removeLabelFromEmail(emailId: string, labelId: number): Promise { + return request(`/api/mails/${emailId}/labels/${labelId}`, { + method: "DELETE", + }); +} + +export async function getMailLabelIds(emailId: string): Promise { + return request(`/api/mails/${emailId}/labels`); +} + +export async function createAdminLabel(name: string, color: string): Promise { + return request("/api/admin/labels", { + method: "POST", + body: JSON.stringify({ name, color }), + }); +} + +export async function getAdminLabels(): Promise { + return request("/api/admin/labels"); +} + +export async function deleteAdminLabel(id: number): Promise { + return request(`/api/admin/labels/${id}`, { method: "DELETE" }); +} + +export async function getLabelRules(): Promise { + return request("/api/admin/label-rules"); +} + +export async function createLabelRule( + condition_field: string, + condition_value: string, + label_id: number +): Promise { + return request("/api/admin/label-rules", { + method: "POST", + body: JSON.stringify({ condition_field, condition_value, label_id }), + }); +} + +export async function deleteLabelRule(id: number): Promise { + return request(`/api/admin/label-rules/${id}`, { method: "DELETE" }); +} + +// ── Certificates ────────────────────────────────────────────────────────────── + +export async function getCertInfo(): Promise { + return request("/api/admin/cert/info"); +} + +export async function uploadCert(cert: File, key: File): Promise<{ ok: boolean; message: string }> { + const form = new FormData(); + form.append("cert", cert); + form.append("key", key); + const res = await fetch(`${API_BASE}/api/admin/cert/upload`, { + method: "POST", + credentials: "include", + body: form, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `Upload failed: ${res.status}`); + } + return res.json(); +} + +export async function generateSelfSignedCert(req: SelfSignedRequest): Promise { + return request("/api/admin/cert/self-signed", { + method: "POST", + body: JSON.stringify(req), + }); +} + +export async function requestACMECert(req: ACMERequest): Promise<{ ok: boolean; output: string }> { + return request<{ ok: boolean; output: string }>("/api/admin/cert/acme", { + method: "POST", + body: JSON.stringify(req), + }); +} diff --git a/src/lib/api/tenants.ts b/src/lib/api/tenants.ts new file mode 100644 index 0000000..c3eab76 --- /dev/null +++ b/src/lib/api/tenants.ts @@ -0,0 +1,206 @@ +import { API_BASE, request } from "./core"; +import { User } from "./users"; +import { LDAPConfig, LDAPTestResult } from "./ldap"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface Tenant { + id: number; + name: string; + slug: string; + active: boolean; + created_at: string; + domain_count?: number; + user_count?: number; + ldap_enabled?: boolean; + ldap_url?: string; + has_logo?: boolean; +} + +export interface TenantDomain { + id: number; + tenant_id: number; + domain: string; + created_at: string; +} + +export interface TenantDefaultUser { + username: string; + password: string; + role: string; +} + +export interface CreateTenantResponse extends Tenant { + default_users: TenantDefaultUser[]; +} + +export interface TenantLDAPConfig extends LDAPConfig { + tenant_id?: number; +} + +export interface LDAPSyncResult { + synced: number; + errors: string[]; +} + +// ── Tenant CRUD ─────────────────────────────────────────────────────────────── + +export async function getTenants(): Promise { + return request("/api/tenants"); +} + +export async function getTenantUsers(tenantId: number): Promise { + return request(`/api/tenants/${tenantId}/users`); +} + +export async function createTenant(name: string, slug: string): Promise { + return request("/api/tenants", { + method: "POST", + body: JSON.stringify({ name, slug }), + }); +} + +export async function updateTenant( + id: number, + data: { name?: string; active?: boolean } +): Promise { + return request(`/api/tenants/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); +} + +export async function deleteTenant(id: number): Promise { + await request(`/api/tenants/${id}`, { method: "DELETE" }); +} + +// ── Tenant Domains ──────────────────────────────────────────────────────────── + +export async function getTenantDomains(id: number): Promise { + return request(`/api/tenants/${id}/domains`); +} + +export async function addTenantDomain( + tenantId: number, + domain: string +): Promise { + return request(`/api/tenants/${tenantId}/domains`, { + method: "POST", + body: JSON.stringify({ domain }), + }); +} + +export async function removeTenantDomain( + tenantId: number, + domainId: number +): Promise { + await request(`/api/tenants/${tenantId}/domains/${domainId}`, { + method: "DELETE", + }); +} + +// ── Tenant Logo ─────────────────────────────────────────────────────────────── + +export function getTenantLogoUrl(tenantId: number): string { + return `${API_BASE}/api/tenants/${tenantId}/logo`; +} + +export async function uploadTenantLogo(tenantId: number, file: File): Promise { + const form = new FormData(); + form.append("logo", file); + const res = await fetch(`${API_BASE}/api/tenants/${tenantId}/logo`, { + method: "POST", + body: form, + credentials: "include", + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `Upload failed: ${res.status}`); + } +} + +export async function deleteTenantLogo(tenantId: number): Promise { + await request(`/api/tenants/${tenantId}/logo`, { method: "DELETE" }); +} + +// domain_admin: own tenant logo +export async function uploadMyTenantLogo(file: File): Promise { + const form = new FormData(); + form.append("logo", file); + const res = await fetch(`${API_BASE}/api/tenant/logo`, { + method: "POST", + body: form, + credentials: "include", + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `Upload failed: ${res.status}`); + } +} + +export async function deleteMyTenantLogo(): Promise { + await request("/api/tenant/logo", { method: "DELETE" }); +} + +// ── Per-Tenant LDAP (domain_admin: own tenant) ──────────────────────────────── + +export async function getTenantLDAPConfig(): Promise { + try { + return await request("/api/tenant/ldap"); + } catch (e: unknown) { + if (e instanceof Error && e.message.includes("404")) return null; + if (e instanceof Error && e.message.includes("no ldap config")) return null; + throw e; + } +} + +export async function saveTenantLDAPConfig(cfg: Partial): Promise { + await request("/api/tenant/ldap", { method: "PUT", body: JSON.stringify(cfg) }); +} + +export async function deleteTenantLDAPConfig(): Promise { + await request("/api/tenant/ldap", { method: "DELETE" }); +} + +export async function testTenantLDAPConfig( + payload: { use_saved: boolean } & Partial +): Promise { + return request("/api/tenant/ldap/test", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +// ── Per-Tenant LDAP (superadmin: arbitrary tenant) ──────────────────────────── + +export async function getAdminTenantLDAPConfig(tenantID: number): Promise { + try { + return await request(`/api/admin/tenants/${tenantID}/ldap`); + } catch (e: unknown) { + if (e instanceof Error && e.message.includes("404")) return null; + if (e instanceof Error && e.message.includes("no ldap config")) return null; + throw e; + } +} + +export async function saveAdminTenantLDAPConfig(tenantID: number, cfg: Partial): Promise { + await request(`/api/admin/tenants/${tenantID}/ldap`, { method: "PUT", body: JSON.stringify(cfg) }); +} + +export async function deleteAdminTenantLDAPConfig(tenantID: number): Promise { + await request(`/api/admin/tenants/${tenantID}/ldap`, { method: "DELETE" }); +} + +export async function testAdminTenantLDAPConfig( + tenantID: number, + payload: { use_saved: boolean } & Partial +): Promise { + return request(`/api/admin/tenants/${tenantID}/ldap/test`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function syncAdminTenantLDAP(tenantID: number): Promise { + return request(`/api/admin/tenants/${tenantID}/ldap/sync`, { method: "POST", body: "{}" }); +} diff --git a/src/lib/api/users.ts b/src/lib/api/users.ts new file mode 100644 index 0000000..e577612 --- /dev/null +++ b/src/lib/api/users.ts @@ -0,0 +1,127 @@ +import { clearAuthCache } from "@/lib/auth-cache"; +import { request } from "./core"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface LoginResponse { + user: { + id: number; + username: string; + email: string; + role: string; + }; +} + +export interface User { + id: number; + username: string; + email: string; + role: string; + source?: string; + active: boolean; + tenant_id?: number; +} + +export interface MeResponse { + username: string; + role: string; + email: string; +} + +export interface CreateUserRequest { + username: string; + email: string; + password: string; + role: string; +} + +export interface UpdateUserRequest { + email?: string; + role?: string; + active?: boolean; + password?: string; +} + +// ── Auth functions ──────────────────────────────────────────────────────────── + +export async function login( + username: string, + password: string +): Promise { + return request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export async function getMe(): Promise { + return request("/api/auth/me"); +} + +export async function logout(): Promise { + clearAuthCache(); + await request("/api/auth/logout", { method: "POST" }); +} + +export async function changePassword( + currentPassword: string, + newPassword: string +): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>("/api/auth/password", { + method: "PATCH", + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + }); +} + +export async function changeEmail( + email: string +): Promise<{ ok: boolean; email: string }> { + return request<{ ok: boolean; email: string }>("/api/auth/email", { + method: "PATCH", + body: JSON.stringify({ email }), + }); +} + +// ── User management functions ───────────────────────────────────────────────── + +export async function getUsers(): Promise { + return request("/api/users"); +} + +export async function createUser(data: CreateUserRequest): Promise { + return request("/api/users", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function updateUser(id: number, data: UpdateUserRequest): Promise { + return request(`/api/users/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); +} + +export async function deleteUser(id: number): Promise { + await request(`/api/users/${id}`, { method: "DELETE" }); +} + +// ── TOTP / 2FA ──────────────────────────────────────────────────────────────── + +export async function getTOTPSetup(): Promise<{ secret: string; otpauth_url: string; qr_code: string }> { + return request<{ secret: string; otpauth_url: string; qr_code: string }>("/api/auth/totp/setup"); +} + +export async function confirmTOTPSetup(code: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>("/api/auth/totp/setup", { + method: "POST", + body: JSON.stringify({ code }), + }); +} + +export async function disableTOTP(code: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>("/api/auth/totp", { + method: "DELETE", + body: JSON.stringify({ code }), + }); +}