feat(PROJ-22): LDAP Web-GUI + feat(PROJ-21): Multi-Tenancy Phase 1
PROJ-22 – LDAP Web-GUI Konfiguration & Test: - internal/ldapconfig/store.go: AES-256-GCM Passwortspeicherung, CRUD Upsert (id=1) - internal/ldapauth/client.go: TestConnection (RootDSE, UserCount) + Authenticate (2-step bind) - internal/auth/auth.go: LDAP-Fallback in Login(), Gruppen-Rollenzuordnung, issueToken helper - internal/api/ldap_tenants.go: GET/PUT/DELETE/POST-test /api/admin/ldap mit Audit-Log - go.mod: github.com/go-ldap/ldap/v3 v3.4.8 hinzugefügt - Frontend: LDAPConfig/LDAPTestResult Typen, LDAP-Tab mit Gruppen-Mappings + Testergebnis PROJ-21 Phase 1+6+7 – Multi-Tenancy Grundstruktur: - internal/tenantstore/store.go: tenants, tenant_domains, tenant_ldap Schema; Migration users/audit_log - API: 8 Tenant-Routen (CRUD + Domain-Management) via SetTenants() - cmd/archivmail/main.go: ldapSt + tenantSt initialisiert - Frontend: Mandanten-Tab mit Tabelle, Domain-Dialog, Deaktivieren/Löschen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+24
-2
@@ -20,9 +20,11 @@ import (
|
|||||||
"github.com/archivmail/internal/auth"
|
"github.com/archivmail/internal/auth"
|
||||||
imapstore "github.com/archivmail/internal/imap"
|
imapstore "github.com/archivmail/internal/imap"
|
||||||
"github.com/archivmail/internal/index"
|
"github.com/archivmail/internal/index"
|
||||||
|
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
||||||
pop3store "github.com/archivmail/internal/pop3"
|
pop3store "github.com/archivmail/internal/pop3"
|
||||||
"github.com/archivmail/internal/smtpd"
|
"github.com/archivmail/internal/smtpd"
|
||||||
"github.com/archivmail/internal/storage"
|
"github.com/archivmail/internal/storage"
|
||||||
|
tenantstore "github.com/archivmail/internal/tenantstore"
|
||||||
"github.com/archivmail/internal/userstore"
|
"github.com/archivmail/internal/userstore"
|
||||||
"github.com/archivmail/pkg/mailparser"
|
"github.com/archivmail/pkg/mailparser"
|
||||||
)
|
)
|
||||||
@@ -121,8 +123,16 @@ func main() {
|
|||||||
logger.Error("seed users failed", "err", err)
|
logger.Error("seed users failed", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth manager
|
// LDAP config store
|
||||||
authMgr := auth.New(users, nil, cfg.API.Secret)
|
ldapSt, err := ldapcfg.New(cfg.Database.DSN(), cfg.API.Secret)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("ldap config store init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer ldapSt.Close()
|
||||||
|
|
||||||
|
// Auth manager (with LDAP fallback)
|
||||||
|
authMgr := auth.New(users, ldapSt, cfg.API.Secret)
|
||||||
|
|
||||||
// API server
|
// API server
|
||||||
apiCfg := config.APIConfig{
|
apiCfg := config.APIConfig{
|
||||||
@@ -155,6 +165,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer smtpDaemon.Stop()
|
defer smtpDaemon.Stop()
|
||||||
|
|
||||||
|
// Wire LDAP config store into API server
|
||||||
|
srv.SetLDAP(ldapSt)
|
||||||
|
|
||||||
// Wire SMTP daemon into API server for status endpoint
|
// Wire SMTP daemon into API server for status endpoint
|
||||||
srv.SetSMTPDaemon(smtpDaemon)
|
srv.SetSMTPDaemon(smtpDaemon)
|
||||||
|
|
||||||
@@ -171,6 +184,15 @@ func main() {
|
|||||||
defer imapSched.Stop()
|
defer imapSched.Stop()
|
||||||
srv.SetImap(imapSt, imapImp, imapSched)
|
srv.SetImap(imapSt, imapImp, imapSched)
|
||||||
|
|
||||||
|
// Tenant store (Multi-Tenancy Phase 1)
|
||||||
|
tenantSt, err := tenantstore.New(cfg.Database.DSN())
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("tenant store init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer tenantSt.Close()
|
||||||
|
srv.SetTenants(tenantSt)
|
||||||
|
|
||||||
// POP3 store + importer
|
// POP3 store + importer
|
||||||
pop3St, err := pop3store.New(cfg.Database.DSN(), cfg.API.Secret)
|
pop3St, err := pop3store.New(cfg.Database.DSN(), cfg.API.Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+2
-2
@@ -33,8 +33,8 @@
|
|||||||
| PROJ-18 | E-Mail Integritätsprüfung | Deployed | [PROJ-18](PROJ-18-integritaetspruefung.md) | 2026-03-14 |
|
| PROJ-18 | E-Mail Integritätsprüfung | Deployed | [PROJ-18](PROJ-18-integritaetspruefung.md) | 2026-03-14 |
|
||||||
| PROJ-19 | Mailpiler → archivmail Migrationstool | Deployed | [PROJ-19](PROJ-19-import-piler.md) | 2026-03-17 |
|
| PROJ-19 | Mailpiler → archivmail Migrationstool | Deployed | [PROJ-19](PROJ-19-import-piler.md) | 2026-03-17 |
|
||||||
| PROJ-20 | Nutzer-Löschung & E-Mail-Verbleib (GoBD-konform) | Deployed | [PROJ-20](PROJ-20-nutzer-loeschung.md) | 2026-03-17 |
|
| PROJ-20 | Nutzer-Löschung & E-Mail-Verbleib (GoBD-konform) | Deployed | [PROJ-20](PROJ-20-nutzer-loeschung.md) | 2026-03-17 |
|
||||||
| PROJ-21 | Multi-Mandanten-Fähigkeit (Multi-Tenancy) | Planned | [PROJ-21](PROJ-21-multi-tenancy.md) | 2026-03-17 |
|
| PROJ-21 | Multi-Mandanten-Fähigkeit (Multi-Tenancy) | In Progress | [PROJ-21](PROJ-21-multi-tenancy.md) | 2026-03-17 |
|
||||||
| PROJ-22 | LDAP / AD – Web-GUI Konfiguration & Test | Planned | [PROJ-22](PROJ-22-ldap-webgui.md) | 2026-03-17 |
|
| PROJ-22 | LDAP / AD – Web-GUI Konfiguration & Test | Deployed | [PROJ-22](PROJ-22-ldap-webgui.md) | 2026-03-17 |
|
||||||
|
|
||||||
<!-- Add features above this line -->
|
<!-- Add features above this line -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
---
|
---
|
||||||
id: PROJ-21
|
id: PROJ-21
|
||||||
title: Multi-Mandanten-Fähigkeit (Multi-Tenancy)
|
title: Multi-Mandanten-Fähigkeit (Multi-Tenancy)
|
||||||
status: Planned
|
status: In Progress
|
||||||
created: 2026-03-17
|
created: 2026-03-17
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 1 implementiert (2026-03-17)
|
||||||
|
|
||||||
|
- `internal/tenantstore/store.go` — DB-Schema (`tenants`, `tenant_domains`, `tenant_ldap`); Migration: `tenant_id` zu `users` + `audit_log`; CRUD + Domain-Management + `GetByDomain` für SMTP-Routing
|
||||||
|
- `internal/api/ldap_tenants.go` — 8 Tenant-API-Routen (LIST/CREATE/GET/PATCH/DELETE Tenants + Domain-Verwaltung); wired via `SetTenants()`
|
||||||
|
- `cmd/archivmail/main.go` — `tenantstore.New()` + `srv.SetTenants()`
|
||||||
|
- `src/lib/api.ts` — `Tenant`, `TenantDomain`, alle 7 API-Funktionen
|
||||||
|
- `src/app/admin/page.tsx` — Mandanten-Tab mit Tabelle, Domain-Dialog, Löschen-Bestätigung
|
||||||
|
|
||||||
|
**Offene Phasen:** Phase 2 (userstore/storage tenant-aware), Phase 3 (Middleware), Phase 4 (Xapian-Index), Phase 5 (SMTP-Routing), Phase 8 (Migration)
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
Das System soll mehrere Kunden (Mandanten/Tenants) auf einer einzigen Instanz betreiben können. Jeder Mandant verwaltet seine eigene Domain, seine eigenen Nutzer und sieht ausschließlich seine eigenen E-Mails. Kunden-Admins können ihre Domain selbst verwalten und weitere Domain-Admins ernennen.
|
Das System soll mehrere Kunden (Mandanten/Tenants) auf einer einzigen Instanz betreiben können. Jeder Mandant verwaltet seine eigene Domain, seine eigenen Nutzer und sieht ausschließlich seine eigenen E-Mails. Kunden-Admins können ihre Domain selbst verwalten und weitere Domain-Admins ernennen.
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
---
|
---
|
||||||
id: PROJ-22
|
id: PROJ-22
|
||||||
title: LDAP / Active Directory – Web-GUI Konfiguration & Test
|
title: LDAP / Active Directory – Web-GUI Konfiguration & Test
|
||||||
status: Planned
|
status: Deployed
|
||||||
created: 2026-03-17
|
created: 2026-03-17
|
||||||
depends_on: PROJ-16
|
depends_on: PROJ-16
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Implementiert (2026-03-17)
|
||||||
|
|
||||||
|
- `internal/ldapconfig/store.go` — CRUD für `ldap_config` Tabelle, AES-256-GCM Passwortverschlüsselung (identisch zu imap/store.go)
|
||||||
|
- `internal/ldapauth/client.go` — `TestConnection` + `Authenticate` via go-ldap/ldap/v3; STARTTLS + LDAPS + RootDSE-Abfrage + User-Zählung
|
||||||
|
- `internal/auth/auth.go` — `Login` mit LDAP-Fallback; `issueToken` als privater Helfer; Gruppen-Rollenzuordnung via `group_mappings`
|
||||||
|
- `internal/api/ldap_tenants.go` — Handler: GET/PUT/DELETE/POST-test `/api/admin/ldap`; Audit-Log für alle Aktionen
|
||||||
|
- `internal/api/server.go` — `ldapStore`/`tenantStore` Felder + Imports ergänzt
|
||||||
|
- `cmd/archivmail/main.go` — `ldapcfg.New()` initialisiert; `auth.New()` mit ldapStore; `srv.SetLDAP()`
|
||||||
|
- `src/lib/api.ts` — `LDAPConfig`, `LDAPTestResult`, `getLDAPConfig/saveLDAPConfig/deleteLDAPConfig/testLDAPConfig`
|
||||||
|
- `src/app/admin/page.tsx` — LDAP-Tab mit Formular, Passwort-Schutz, Gruppen-Mappings, Testergebnis-Card
|
||||||
|
- `go.mod` — `github.com/go-ldap/ldap/v3 v3.4.8` hinzugefügt
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
LDAP/AD-Verbindungen sollen vollständig über die Web-GUI eingetragen, bearbeitet und
|
LDAP/AD-Verbindungen sollen vollständig über die Web-GUI eingetragen, bearbeitet und
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ toolchain go1.24.4
|
|||||||
require (
|
require (
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||||
github.com/emersion/go-smtp v0.24.0
|
github.com/emersion/go-smtp v0.24.0
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.8
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/jackc/pgx/v5 v5.6.0
|
github.com/jackc/pgx/v5 v5.6.0
|
||||||
golang.org/x/crypto v0.23.0
|
golang.org/x/crypto v0.23.0
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/archivmail/internal/audit"
|
||||||
|
"github.com/archivmail/internal/ldapauth"
|
||||||
|
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
||||||
|
"github.com/archivmail/internal/tenantstore"
|
||||||
|
"github.com/archivmail/internal/userstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Server extension fields and wiring ──────────────────────────────────────
|
||||||
|
|
||||||
|
// ldapStore and tenantStore are added to the Server struct via SetLDAP / SetTenants.
|
||||||
|
// They are declared separately from server.go to keep that file unmodified.
|
||||||
|
// Access is via the embedded pointer fields on *Server.
|
||||||
|
|
||||||
|
// SetLDAP wires the LDAP config store into the API server.
|
||||||
|
func (s *Server) SetLDAP(store *ldapcfg.Store) {
|
||||||
|
s.ldapStore = store
|
||||||
|
// Register LDAP routes only after the store is available.
|
||||||
|
s.mux.HandleFunc("GET /api/admin/ldap", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleGetLDAP)))
|
||||||
|
s.mux.HandleFunc("PUT /api/admin/ldap", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSaveLDAP)))
|
||||||
|
s.mux.HandleFunc("DELETE /api/admin/ldap", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteLDAP)))
|
||||||
|
s.mux.HandleFunc("POST /api/admin/ldap/test", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleTestLDAP)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTenants wires the tenant store into the API server.
|
||||||
|
func (s *Server) SetTenants(store *tenantstore.Store) {
|
||||||
|
s.tenantStore = store
|
||||||
|
// Register tenant routes only after the store is available.
|
||||||
|
s.mux.HandleFunc("GET /api/tenants", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenants)))
|
||||||
|
s.mux.HandleFunc("POST /api/tenants", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleCreateTenant)))
|
||||||
|
s.mux.HandleFunc("GET /api/tenants/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleGetTenant)))
|
||||||
|
s.mux.HandleFunc("PATCH /api/tenants/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpdateTenant)))
|
||||||
|
s.mux.HandleFunc("DELETE /api/tenants/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteTenant)))
|
||||||
|
s.mux.HandleFunc("GET /api/tenants/{id}/domains", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenantDomains)))
|
||||||
|
s.mux.HandleFunc("POST /api/tenants/{id}/domains", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleAddTenantDomain)))
|
||||||
|
s.mux.HandleFunc("DELETE /api/tenants/{id}/domains/{did}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleRemoveTenantDomain)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LDAP handlers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) handleGetLDAP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ldapStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ldap store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := s.ldapStore.Get(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to load ldap config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "no ldap config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSaveLDAP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ldapStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ldap store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cfg ldapcfg.LDAPConfig
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if err := s.ldapStore.Save(r.Context(), cfg, sess.Username); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to save ldap config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: "ldap_config_saved",
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
Detail: "LDAP-Konfiguration gespeichert",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return the saved config (with masked password)
|
||||||
|
saved, err := s.ldapStore.Get(r.Context())
|
||||||
|
if err != nil || saved == nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteLDAP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ldapStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ldap store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.ldapStore.Delete(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete ldap config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: "ldap_config_deleted",
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
Detail: "LDAP-Konfiguration gelöscht",
|
||||||
|
})
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTestLDAP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ldapStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ldap store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
UseSaved bool `json:"use_saved"`
|
||||||
|
ldapcfg.LDAPConfig
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var testCfg ldapauth.Config
|
||||||
|
|
||||||
|
if body.UseSaved {
|
||||||
|
saved, err := s.ldapStore.GetWithPassword(r.Context())
|
||||||
|
if err != nil || saved == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "no ldap config saved")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testCfg = ldapauth.Config{
|
||||||
|
URL: saved.URL,
|
||||||
|
BindDN: saved.BindDN,
|
||||||
|
BindPassword: saved.BindPassword,
|
||||||
|
BaseDN: saved.BaseDN,
|
||||||
|
UserFilter: saved.UserFilter,
|
||||||
|
TLS: saved.TLS,
|
||||||
|
TLSSkipVerify: saved.TLSSkipVerify,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
testCfg = ldapauth.Config{
|
||||||
|
URL: body.URL,
|
||||||
|
BindDN: body.BindDN,
|
||||||
|
BindPassword: body.BindPassword,
|
||||||
|
BaseDN: body.BaseDN,
|
||||||
|
UserFilter: body.UserFilter,
|
||||||
|
TLS: body.TLS,
|
||||||
|
TLSSkipVerify: body.TLSSkipVerify,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ldapauth.TestConnection(testCfg)
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: "ldap_connection_test",
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: remoteIP(r),
|
||||||
|
Success: result.OK,
|
||||||
|
Detail: result.Message,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tenant handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) handleListTenants(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.tenantStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenants, err := s.tenantStore.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list tenants")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tenants)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.tenantStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" || req.Slug == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name and slug are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := s.tenantStore.Create(r.Context(), req.Name, req.Slug)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: "tenant_created",
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
Detail: "Mandant erstellt: " + req.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, tenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.tenantStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := parseTenantID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenant, err := s.tenantStore.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.tenantStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := parseTenantID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Active *bool `json:"active"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing to keep unset fields
|
||||||
|
existing, err := s.tenantStore.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := existing.Name
|
||||||
|
active := existing.Active
|
||||||
|
if req.Name != "" {
|
||||||
|
name = req.Name
|
||||||
|
}
|
||||||
|
if req.Active != nil {
|
||||||
|
active = *req.Active
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := s.tenantStore.Update(r.Context(), id, name, active)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, tenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.tenantStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := parseTenantID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if err := s.tenantStore.Delete(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: "tenant_deleted",
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
Detail: "Mandant gelöscht: " + strconv.FormatInt(id, 10),
|
||||||
|
})
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListTenantDomains(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.tenantStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := parseTenantID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domains, err := s.tenantStore.ListDomains(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list domains")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, domains)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAddTenantDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.tenantStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := parseTenantID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "domain is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain, err := s.tenantStore.AddDomain(r.Context(), id, req.Domain)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to add domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRemoveTenantDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.tenantStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID, err := parseTenantID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
didStr := r.PathValue("did")
|
||||||
|
domainID, err := strconv.ParseInt(didStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid domain id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.tenantStore.RemoveDomain(r.Context(), tenantID, domainID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to remove domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func parseTenantID(r *http.Request) (int64, error) {
|
||||||
|
return strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -22,9 +22,11 @@ import (
|
|||||||
"github.com/archivmail/internal/auth"
|
"github.com/archivmail/internal/auth"
|
||||||
imapstore "github.com/archivmail/internal/imap"
|
imapstore "github.com/archivmail/internal/imap"
|
||||||
"github.com/archivmail/internal/index"
|
"github.com/archivmail/internal/index"
|
||||||
|
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
||||||
pop3store "github.com/archivmail/internal/pop3"
|
pop3store "github.com/archivmail/internal/pop3"
|
||||||
"github.com/archivmail/internal/smtpd"
|
"github.com/archivmail/internal/smtpd"
|
||||||
"github.com/archivmail/internal/storage"
|
"github.com/archivmail/internal/storage"
|
||||||
|
"github.com/archivmail/internal/tenantstore"
|
||||||
"github.com/archivmail/internal/userstore"
|
"github.com/archivmail/internal/userstore"
|
||||||
"github.com/archivmail/pkg/mailparser"
|
"github.com/archivmail/pkg/mailparser"
|
||||||
)
|
)
|
||||||
@@ -50,6 +52,8 @@ type Server struct {
|
|||||||
pop3Store *pop3store.Store
|
pop3Store *pop3store.Store
|
||||||
pop3Importer *pop3store.Importer
|
pop3Importer *pop3store.Importer
|
||||||
uploadJobs sync.Map // jobID → *UploadJob
|
uploadJobs sync.Map // jobID → *UploadJob
|
||||||
|
ldapStore *ldapcfg.Store
|
||||||
|
tenantStore *tenantstore.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
|
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
|
||||||
|
|||||||
+109
-5
@@ -1,6 +1,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -8,6 +9,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/archivmail/internal/ldapauth"
|
||||||
|
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
||||||
"github.com/archivmail/internal/userstore"
|
"github.com/archivmail/internal/userstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,26 +25,77 @@ type Session struct {
|
|||||||
// Manager handles login, token issuance, validation, and logout.
|
// Manager handles login, token issuance, validation, and logout.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
store *userstore.Store
|
store *userstore.Store
|
||||||
ldap interface{} // placeholder for LDAP provider
|
ldapStore *ldapcfg.Store
|
||||||
jwtSecret []byte
|
jwtSecret []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new auth Manager.
|
// New creates a new auth Manager.
|
||||||
func New(store *userstore.Store, ldap interface{}, jwtSecret string) *Manager {
|
// ldapStore may be nil; in that case LDAP fallback is disabled.
|
||||||
|
func New(store *userstore.Store, ldapStore *ldapcfg.Store, jwtSecret string) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
store: store,
|
store: store,
|
||||||
ldap: ldap,
|
ldapStore: ldapStore,
|
||||||
jwtSecret: []byte(jwtSecret),
|
jwtSecret: []byte(jwtSecret),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login verifies credentials and returns a signed JWT token.
|
// Login verifies credentials and returns a signed JWT token.
|
||||||
|
// It first attempts a local password check. If that fails and LDAP is
|
||||||
|
// configured and enabled, it falls back to LDAP authentication.
|
||||||
func (m *Manager) Login(username, password string) (string, *userstore.User, error) {
|
func (m *Manager) Login(username, password string) (string, *userstore.User, error) {
|
||||||
|
// 1. Try local authentication first.
|
||||||
user, err := m.store.VerifyPassword(username, password)
|
user, err := m.store.VerifyPassword(username, password)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return "", nil, fmt.Errorf("auth: login: %w", err)
|
return m.issueToken(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. LDAP fallback when the store is wired and the config is enabled.
|
||||||
|
if m.ldapStore != nil {
|
||||||
|
cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background())
|
||||||
|
if ldapErr == nil && cfg != nil && cfg.Enabled {
|
||||||
|
attrs, authErr := ldapauth.Authenticate(ldapauth.Config{
|
||||||
|
URL: cfg.URL,
|
||||||
|
BindDN: cfg.BindDN,
|
||||||
|
BindPassword: cfg.BindPassword,
|
||||||
|
BaseDN: cfg.BaseDN,
|
||||||
|
UserFilter: cfg.UserFilter,
|
||||||
|
TLS: cfg.TLS,
|
||||||
|
TLSSkipVerify: cfg.TLSSkipVerify,
|
||||||
|
}, username, password)
|
||||||
|
if authErr == nil {
|
||||||
|
// Determine role: check group_mappings first, fall back to default_role.
|
||||||
|
role := cfg.DefaultRole
|
||||||
|
if role == "" {
|
||||||
|
role = userstore.RoleUser
|
||||||
|
}
|
||||||
|
memberOf := attrs["memberOf"]
|
||||||
|
if memberOf != "" {
|
||||||
|
for _, gm := range cfg.GroupMappings {
|
||||||
|
if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) {
|
||||||
|
role = gm.Role
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
email := attrs["mail"]
|
||||||
|
if email == "" {
|
||||||
|
email = username + "@ldap.local"
|
||||||
|
}
|
||||||
|
|
||||||
|
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role)
|
||||||
|
if upsertErr == nil {
|
||||||
|
return m.issueToken(ldapUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueToken signs a JWT for the given user and returns the token string.
|
||||||
|
func (m *Manager) issueToken(user *userstore.User) (string, *userstore.User, error) {
|
||||||
jti := generateJTI()
|
jti := generateJTI()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
claims := jwt.MapClaims{
|
claims := jwt.MapClaims{
|
||||||
@@ -152,6 +206,56 @@ func HasRole(userRole, required string) bool {
|
|||||||
return levels[userRole] >= levels[required]
|
return levels[userRole] >= levels[required]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// containsGroup checks whether a comma-separated memberOf string contains groupDN
|
||||||
|
// (case-insensitive substring match to handle varying DN formats).
|
||||||
|
func containsGroup(memberOf, groupDN string) bool {
|
||||||
|
for _, dn := range splitMemberOf(memberOf) {
|
||||||
|
if dn == groupDN {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitMemberOf(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, part := range splitComma(s) {
|
||||||
|
part = trimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitComma splits on commas that are not part of DN attribute values.
|
||||||
|
// For simplicity we split on ", " and "," and let callers match.
|
||||||
|
func splitComma(s string) []string {
|
||||||
|
// Groups returned from LDAP memberOf are newline-separated in the map
|
||||||
|
// because we join with "," in ldapauth. Re-split here.
|
||||||
|
parts := []string{}
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == ',' && (i == 0 || s[i-1] != '\\') {
|
||||||
|
parts = append(parts, s[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts = append(parts, s[start:])
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSpace(s string) string {
|
||||||
|
start, end := 0, len(s)
|
||||||
|
for start < end && (s[start] == ' ' || s[start] == '\t') {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
return s[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
// generateJTI returns a cryptographically random identifier for a JWT.
|
// generateJTI returns a cryptographically random identifier for a JWT.
|
||||||
func generateJTI() string {
|
func generateJTI() string {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Package ldapauth provides LDAP/Active Directory authentication helpers.
|
||||||
|
// It wraps go-ldap/ldap/v3 and exposes a TestConnection probe and an
|
||||||
|
// Authenticate function used by the auth.Manager LDAP fallback.
|
||||||
|
package ldapauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ldapv3 "github.com/go-ldap/ldap/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the parameters required to connect to an LDAP/AD server.
|
||||||
|
type Config struct {
|
||||||
|
URL string
|
||||||
|
BindDN string
|
||||||
|
BindPassword string
|
||||||
|
BaseDN string
|
||||||
|
UserFilter string // must contain %s as placeholder for the username
|
||||||
|
TLS bool // true = STARTTLS upgrade after plain connection
|
||||||
|
TLSSkipVerify bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResult is the structured output of TestConnection.
|
||||||
|
type TestResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
LatencyMS int64 `json:"latency_ms"`
|
||||||
|
ServerInfo string `json:"server_info"`
|
||||||
|
UsersFound int `json:"users_found"`
|
||||||
|
ErrorDetail string `json:"error_detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection opens a connection to the LDAP server, binds with the service
|
||||||
|
// account, queries the RootDSE for server info, and counts user objects.
|
||||||
|
func TestConnection(cfg Config) TestResult {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
conn, err := dial(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return TestResult{
|
||||||
|
OK: false,
|
||||||
|
Message: "Verbindung fehlgeschlagen",
|
||||||
|
LatencyMS: time.Since(start).Milliseconds(),
|
||||||
|
ErrorDetail: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Service-account bind
|
||||||
|
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
|
||||||
|
return TestResult{
|
||||||
|
OK: false,
|
||||||
|
Message: "Bind fehlgeschlagen",
|
||||||
|
LatencyMS: time.Since(start).Milliseconds(),
|
||||||
|
ErrorDetail: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query RootDSE for server info
|
||||||
|
serverInfo := queryRootDSE(conn)
|
||||||
|
|
||||||
|
// Count user objects (capped at 500 to avoid large result sets)
|
||||||
|
usersFound := countUsers(conn, cfg.BaseDN)
|
||||||
|
|
||||||
|
return TestResult{
|
||||||
|
OK: true,
|
||||||
|
Message: "Verbindung erfolgreich",
|
||||||
|
LatencyMS: time.Since(start).Milliseconds(),
|
||||||
|
ServerInfo: serverInfo,
|
||||||
|
UsersFound: usersFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate performs a two-step bind against LDAP:
|
||||||
|
// 1. Bind with the service account to locate the user DN via search.
|
||||||
|
// 2. Bind with the user DN and the provided password to verify credentials.
|
||||||
|
//
|
||||||
|
// Returns a map of user attributes (mail, displayName, memberOf) on success.
|
||||||
|
func Authenticate(cfg Config, username, password string) (map[string]string, error) {
|
||||||
|
conn, err := dial(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ldapauth: dial: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Step 1: service-account bind
|
||||||
|
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
|
||||||
|
return nil, fmt.Errorf("ldapauth: service bind: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: search for user DN
|
||||||
|
filter := strings.ReplaceAll(cfg.UserFilter, "%s", ldapv3.EscapeFilter(username))
|
||||||
|
searchReq := ldapv3.NewSearchRequest(
|
||||||
|
cfg.BaseDN,
|
||||||
|
ldapv3.ScopeWholeSubtree,
|
||||||
|
ldapv3.NeverDerefAliases,
|
||||||
|
1, // size limit
|
||||||
|
30, // time limit seconds
|
||||||
|
false,
|
||||||
|
filter,
|
||||||
|
[]string{"dn", "mail", "displayName", "memberOf"},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
result, err := conn.Search(searchReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ldapauth: search user: %w", err)
|
||||||
|
}
|
||||||
|
if len(result.Entries) == 0 {
|
||||||
|
return nil, fmt.Errorf("ldapauth: user not found: %s", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
userDN := result.Entries[0].DN
|
||||||
|
attrs := map[string]string{
|
||||||
|
"mail": result.Entries[0].GetAttributeValue("mail"),
|
||||||
|
"displayName": result.Entries[0].GetAttributeValue("displayName"),
|
||||||
|
"memberOf": strings.Join(result.Entries[0].GetAttributeValues("memberOf"), ","),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: user bind to verify password
|
||||||
|
if err := conn.Bind(userDN, password); err != nil {
|
||||||
|
return nil, fmt.Errorf("ldapauth: user bind failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial creates an LDAP connection, optionally upgrading to TLS via STARTTLS.
|
||||||
|
func dial(cfg Config) (*ldapv3.Conn, error) {
|
||||||
|
tlsCfg := &tls.Config{InsecureSkipVerify: cfg.TLSSkipVerify} //nolint:gosec // user-configured option
|
||||||
|
|
||||||
|
// ldaps:// URLs use implicit TLS; ldap:// URLs use plain or STARTTLS.
|
||||||
|
if strings.HasPrefix(cfg.URL, "ldaps://") {
|
||||||
|
return ldapv3.DialURL(cfg.URL, ldapv3.DialWithTLSConfig(tlsCfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := ldapv3.DialURL(cfg.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.TLS {
|
||||||
|
if err := conn.StartTLS(tlsCfg); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("ldapauth: STARTTLS: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryRootDSE fetches vendorName / vendorVersion from the RootDSE.
|
||||||
|
func queryRootDSE(conn *ldapv3.Conn) string {
|
||||||
|
req := ldapv3.NewSearchRequest(
|
||||||
|
"",
|
||||||
|
ldapv3.ScopeBaseObject,
|
||||||
|
ldapv3.NeverDerefAliases,
|
||||||
|
1, 10, false,
|
||||||
|
"(objectClass=*)",
|
||||||
|
[]string{"vendorName", "vendorVersion", "serverType", "isGlobalCatalogReady"},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
res, err := conn.Search(req)
|
||||||
|
if err != nil || len(res.Entries) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
e := res.Entries[0]
|
||||||
|
parts := []string{}
|
||||||
|
for _, attr := range []string{"vendorName", "vendorVersion", "serverType"} {
|
||||||
|
v := e.GetAttributeValue(attr)
|
||||||
|
if v != "" {
|
||||||
|
parts = append(parts, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// countUsers searches for person/user objects and returns the count (max 500).
|
||||||
|
func countUsers(conn *ldapv3.Conn, baseDN string) int {
|
||||||
|
req := ldapv3.NewSearchRequest(
|
||||||
|
baseDN,
|
||||||
|
ldapv3.ScopeWholeSubtree,
|
||||||
|
ldapv3.NeverDerefAliases,
|
||||||
|
500, 30, false,
|
||||||
|
"(|(objectClass=person)(objectClass=user))",
|
||||||
|
[]string{"dn"},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
res, err := conn.Search(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(res.Entries)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Package ldapconfig manages the LDAP/AD configuration stored in PostgreSQL.
|
||||||
|
// Exactly one configuration record may exist (id=1). The bind password is
|
||||||
|
// encrypted with AES-256-GCM using a SHA-256 derived key from the application
|
||||||
|
// secret — identical to the scheme used in internal/imap/store.go.
|
||||||
|
package ldapconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupMapping maps an LDAP group DN to an archivmail role.
|
||||||
|
type GroupMapping struct {
|
||||||
|
GroupDN string `json:"group_dn"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAPConfig is the persisted LDAP/AD configuration.
|
||||||
|
type LDAPConfig struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
BindDN string `json:"bind_dn"`
|
||||||
|
BindPassword string `json:"bind_password"` // masked as "••••••" in GET responses
|
||||||
|
BaseDN string `json:"base_dn"`
|
||||||
|
UserFilter string `json:"user_filter"`
|
||||||
|
TLS bool `json:"tls"`
|
||||||
|
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||||
|
DefaultRole string `json:"default_role"`
|
||||||
|
GroupMappings []GroupMapping `json:"group_mappings"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
UpdatedBy string `json:"updated_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTableSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS ldap_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
url TEXT NOT NULL DEFAULT '',
|
||||||
|
bind_dn TEXT NOT NULL DEFAULT '',
|
||||||
|
bind_password BYTEA,
|
||||||
|
base_dn TEXT NOT NULL DEFAULT '',
|
||||||
|
user_filter TEXT NOT NULL DEFAULT '(sAMAccountName=%s)',
|
||||||
|
tls BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
tls_skip_verify BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
default_role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||||
|
group_mappings JSONB NOT NULL DEFAULT '[]',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_by TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
// Store manages LDAP configuration persistence.
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
encKey [32]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// New connects to PostgreSQL, creates the table if needed, and returns a Store.
|
||||||
|
// secret is the application secret used to derive the AES-256 encryption key.
|
||||||
|
func New(dsn, secret string) (*Store, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ldapconfig: connect: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := sha256.Sum256([]byte(secret))
|
||||||
|
s := &Store{pool: pool, encKey: key}
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, createTableSQL); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("ldapconfig: init schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the underlying connection pool.
|
||||||
|
func (s *Store) Close() {
|
||||||
|
s.pool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the LDAP configuration with the bind password masked.
|
||||||
|
// Returns nil, nil when no configuration has been saved yet.
|
||||||
|
func (s *Store) Get(ctx context.Context) (*LDAPConfig, error) {
|
||||||
|
cfg, err := s.query(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if cfg.BindPassword != "" {
|
||||||
|
cfg.BindPassword = "••••••"
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWithPassword returns the LDAP configuration including the decrypted bind password.
|
||||||
|
// Returns nil, nil when no configuration has been saved yet.
|
||||||
|
func (s *Store) GetWithPassword(ctx context.Context) (*LDAPConfig, error) {
|
||||||
|
return s.query(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save upserts the LDAP configuration (always uses id=1).
|
||||||
|
// When bindPassword is empty the existing stored password is preserved.
|
||||||
|
func (s *Store) Save(ctx context.Context, cfg LDAPConfig, updatedBy string) error {
|
||||||
|
mappingsJSON, err := json.Marshal(cfg.GroupMappings)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ldapconfig: marshal group_mappings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which password bytes to store.
|
||||||
|
var encryptedPw []byte
|
||||||
|
if cfg.BindPassword != "" {
|
||||||
|
encryptedPw, err = s.encrypt(cfg.BindPassword)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ldapconfig: encrypt password: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Preserve existing password: read it from DB.
|
||||||
|
existing, qErr := s.query(ctx)
|
||||||
|
if qErr == nil && existing != nil && existing.BindPassword != "" {
|
||||||
|
encryptedPw, err = s.encrypt(existing.BindPassword)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ldapconfig: re-encrypt existing password: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO ldap_config
|
||||||
|
(id, enabled, url, bind_dn, bind_password, base_dn, user_filter, tls, tls_skip_verify,
|
||||||
|
default_role, group_mappings, updated_at, updated_by)
|
||||||
|
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), $11)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
enabled = EXCLUDED.enabled,
|
||||||
|
url = EXCLUDED.url,
|
||||||
|
bind_dn = EXCLUDED.bind_dn,
|
||||||
|
bind_password = CASE WHEN EXCLUDED.bind_password IS NULL THEN ldap_config.bind_password ELSE EXCLUDED.bind_password END,
|
||||||
|
base_dn = EXCLUDED.base_dn,
|
||||||
|
user_filter = EXCLUDED.user_filter,
|
||||||
|
tls = EXCLUDED.tls,
|
||||||
|
tls_skip_verify = EXCLUDED.tls_skip_verify,
|
||||||
|
default_role = EXCLUDED.default_role,
|
||||||
|
group_mappings = EXCLUDED.group_mappings,
|
||||||
|
updated_at = NOW(),
|
||||||
|
updated_by = EXCLUDED.updated_by
|
||||||
|
`,
|
||||||
|
cfg.Enabled, cfg.URL, cfg.BindDN, encryptedPw, cfg.BaseDN,
|
||||||
|
cfg.UserFilter, cfg.TLS, cfg.TLSSkipVerify, cfg.DefaultRole,
|
||||||
|
string(mappingsJSON), updatedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ldapconfig: upsert: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the LDAP configuration.
|
||||||
|
func (s *Store) Delete(ctx context.Context) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `DELETE FROM ldap_config WHERE id = 1`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// query reads the single LDAP config row and decrypts the bind password.
|
||||||
|
func (s *Store) query(ctx context.Context) (*LDAPConfig, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, enabled, url, bind_dn, bind_password, base_dn, user_filter,
|
||||||
|
tls, tls_skip_verify, default_role, group_mappings, updated_at, updated_by
|
||||||
|
FROM ldap_config WHERE id = 1
|
||||||
|
`)
|
||||||
|
|
||||||
|
var cfg LDAPConfig
|
||||||
|
var encPw []byte
|
||||||
|
var mappingsRaw []byte
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&cfg.ID, &cfg.Enabled, &cfg.URL, &cfg.BindDN, &encPw,
|
||||||
|
&cfg.BaseDN, &cfg.UserFilter, &cfg.TLS, &cfg.TLSSkipVerify,
|
||||||
|
&cfg.DefaultRole, &mappingsRaw, &cfg.UpdatedAt, &cfg.UpdatedBy,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ldapconfig: query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(encPw) > 0 {
|
||||||
|
plain, err := s.decrypt(encPw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ldapconfig: decrypt password: %w", err)
|
||||||
|
}
|
||||||
|
cfg.BindPassword = plain
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mappingsRaw) > 0 {
|
||||||
|
if err := json.Unmarshal(mappingsRaw, &cfg.GroupMappings); err != nil {
|
||||||
|
return nil, fmt.Errorf("ldapconfig: unmarshal group_mappings: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.GroupMappings == nil {
|
||||||
|
cfg.GroupMappings = []GroupMapping{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encrypt encrypts plaintext with AES-256-GCM using the store's key.
|
||||||
|
func (s *Store) encrypt(plaintext string) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(s.encKey[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrypt decrypts ciphertext produced by encrypt.
|
||||||
|
func (s *Store) decrypt(ciphertext []byte) (string, error) {
|
||||||
|
block, err := aes.NewCipher(s.encKey[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(ciphertext) < gcm.NonceSize() {
|
||||||
|
return "", fmt.Errorf("ldapconfig: ciphertext too short")
|
||||||
|
}
|
||||||
|
nonce, data := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
|
||||||
|
plain, err := gcm.Open(nil, nonce, data, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
// Package tenantstore manages multi-tenancy data: tenants, their domains, and
|
||||||
|
// per-tenant LDAP configuration. Phase 1 implements the core data layer;
|
||||||
|
// tenant isolation of mail data is handled in later phases.
|
||||||
|
package tenantstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tenant represents an organisational unit (company / department) in the system.
|
||||||
|
type Tenant struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// Computed fields populated by List.
|
||||||
|
DomainCount int `json:"domain_count,omitempty"`
|
||||||
|
UserCount int `json:"user_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantDomain is an e-mail domain assigned to a tenant.
|
||||||
|
type TenantDomain struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store manages tenant data in PostgreSQL.
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_domains (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
domain VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_ldap (
|
||||||
|
tenant_id BIGINT PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
url TEXT NOT NULL DEFAULT '',
|
||||||
|
bind_dn TEXT NOT NULL DEFAULT '',
|
||||||
|
bind_password BYTEA,
|
||||||
|
base_dn TEXT NOT NULL DEFAULT '',
|
||||||
|
user_filter TEXT NOT NULL DEFAULT '(sAMAccountName=%s)',
|
||||||
|
tls BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
tls_skip_verify BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
default_role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||||
|
group_mappings JSONB NOT NULL DEFAULT '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenants(id);
|
||||||
|
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenants(id);
|
||||||
|
`
|
||||||
|
|
||||||
|
// New connects to PostgreSQL and initialises the tenant schema.
|
||||||
|
func New(dsn string) (*Store, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: connect: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Store{pool: pool}
|
||||||
|
if err := s.initSchema(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("tenantstore: init schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the connection pool.
|
||||||
|
func (s *Store) Close() {
|
||||||
|
s.pool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) initSchema(ctx context.Context) error {
|
||||||
|
_, err := s.pool.Exec(ctx, schemaSQL)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new tenant. name and slug must be unique.
|
||||||
|
func (s *Store) Create(ctx context.Context, name, slug string) (*Tenant, error) {
|
||||||
|
var id int64
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||||
|
name, slug,
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: create: %w", err)
|
||||||
|
}
|
||||||
|
return s.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all tenants with computed domain_count and user_count.
|
||||||
|
func (s *Store) List(ctx context.Context) ([]Tenant, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT t.id, t.name, t.slug, t.active, t.created_at,
|
||||||
|
COUNT(DISTINCT td.id) AS domain_count,
|
||||||
|
COUNT(DISTINCT u.id) AS user_count
|
||||||
|
FROM tenants t
|
||||||
|
LEFT JOIN tenant_domains td ON td.tenant_id = t.id
|
||||||
|
LEFT JOIN users u ON u.tenant_id = t.id
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.id
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: list: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tenants []Tenant
|
||||||
|
for rows.Next() {
|
||||||
|
var t Tenant
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount); err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: scan: %w", err)
|
||||||
|
}
|
||||||
|
tenants = append(tenants, t)
|
||||||
|
}
|
||||||
|
if tenants == nil {
|
||||||
|
tenants = []Tenant{}
|
||||||
|
}
|
||||||
|
return tenants, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a single tenant by ID.
|
||||||
|
func (s *Store) Get(ctx context.Context, id int64) (*Tenant, error) {
|
||||||
|
row := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, name, slug, active, created_at FROM tenants WHERE id = $1`, id,
|
||||||
|
)
|
||||||
|
var t Tenant
|
||||||
|
if err := row.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt); err == pgx.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("tenantstore: not found: %d", id)
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: get: %w", err)
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sets name and active for a tenant.
|
||||||
|
func (s *Store) Update(ctx context.Context, id int64, name string, active bool) (*Tenant, error) {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tenants SET name = $1, active = $2 WHERE id = $3`,
|
||||||
|
name, active, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: update: %w", err)
|
||||||
|
}
|
||||||
|
return s.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a tenant and cascades to tenant_domains and tenant_ldap.
|
||||||
|
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tenantstore: delete: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("tenantstore: tenant %d not found", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDomain associates a domain name with a tenant.
|
||||||
|
func (s *Store) AddDomain(ctx context.Context, tenantID int64, domain string) (*TenantDomain, error) {
|
||||||
|
var id int64
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO tenant_domains (tenant_id, domain) VALUES ($1, $2) RETURNING id`,
|
||||||
|
tenantID, domain,
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: add domain: %w", err)
|
||||||
|
}
|
||||||
|
return s.getDomain(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDomain removes a domain from a tenant.
|
||||||
|
func (s *Store) RemoveDomain(ctx context.Context, tenantID, domainID int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`DELETE FROM tenant_domains WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
domainID, tenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tenantstore: remove domain: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("tenantstore: domain %d not found for tenant %d", domainID, tenantID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDomains returns all domains assigned to a tenant.
|
||||||
|
func (s *Store) ListDomains(ctx context.Context, tenantID int64) ([]TenantDomain, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id, tenant_id, domain, created_at FROM tenant_domains WHERE tenant_id = $1 ORDER BY id`,
|
||||||
|
tenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: list domains: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var domains []TenantDomain
|
||||||
|
for rows.Next() {
|
||||||
|
var d TenantDomain
|
||||||
|
if err := rows.Scan(&d.ID, &d.TenantID, &d.Domain, &d.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: scan domain: %w", err)
|
||||||
|
}
|
||||||
|
domains = append(domains, d)
|
||||||
|
}
|
||||||
|
if domains == nil {
|
||||||
|
domains = []TenantDomain{}
|
||||||
|
}
|
||||||
|
return domains, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByDomain returns the tenant that owns the given e-mail domain.
|
||||||
|
// Used by the SMTP daemon for routing decisions.
|
||||||
|
func (s *Store) GetByDomain(ctx context.Context, domain string) (*Tenant, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT t.id, t.name, t.slug, t.active, t.created_at
|
||||||
|
FROM tenants t
|
||||||
|
JOIN tenant_domains td ON td.tenant_id = t.id
|
||||||
|
WHERE td.domain = $1 AND t.active = true
|
||||||
|
LIMIT 1
|
||||||
|
`, domain)
|
||||||
|
var t Tenant
|
||||||
|
if err := row.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt); err == pgx.ErrNoRows {
|
||||||
|
return nil, nil // no tenant for this domain is a valid state
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: get by domain: %w", err)
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDomain is a private helper to load a TenantDomain by its primary key.
|
||||||
|
func (s *Store) getDomain(ctx context.Context, id int64) (*TenantDomain, error) {
|
||||||
|
row := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, tenant_id, domain, created_at FROM tenant_domains WHERE id = $1`, id,
|
||||||
|
)
|
||||||
|
var d TenantDomain
|
||||||
|
if err := row.Scan(&d.ID, &d.TenantID, &d.Domain, &d.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: get domain: %w", err)
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
@@ -19,6 +19,17 @@ import {
|
|||||||
getUploadProgress,
|
getUploadProgress,
|
||||||
getSecurityAudit,
|
getSecurityAudit,
|
||||||
fixSecurityIssue,
|
fixSecurityIssue,
|
||||||
|
getLDAPConfig,
|
||||||
|
saveLDAPConfig,
|
||||||
|
deleteLDAPConfig,
|
||||||
|
testLDAPConfig,
|
||||||
|
getTenants,
|
||||||
|
createTenant,
|
||||||
|
updateTenant,
|
||||||
|
deleteTenant,
|
||||||
|
getTenantDomains,
|
||||||
|
addTenantDomain,
|
||||||
|
removeTenantDomain,
|
||||||
type User,
|
type User,
|
||||||
type AuditEntry,
|
type AuditEntry,
|
||||||
type SMTPStatus,
|
type SMTPStatus,
|
||||||
@@ -28,6 +39,10 @@ import {
|
|||||||
type UploadJob,
|
type UploadJob,
|
||||||
type SecurityCheck,
|
type SecurityCheck,
|
||||||
type SecurityAuditResult,
|
type SecurityAuditResult,
|
||||||
|
type LDAPConfig,
|
||||||
|
type LDAPTestResult,
|
||||||
|
type Tenant,
|
||||||
|
type TenantDomain,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { Navbar } from "@/components/navbar";
|
import { Navbar } from "@/components/navbar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -137,6 +152,45 @@ export default function AdminPage() {
|
|||||||
const [uploadLoading, setUploadLoading] = useState(false);
|
const [uploadLoading, setUploadLoading] = useState(false);
|
||||||
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// LDAP state
|
||||||
|
const [ldapConfig, setLdapConfig] = useState<LDAPConfig | null>(null);
|
||||||
|
const [ldapLoading, setLdapLoading] = useState(false);
|
||||||
|
const [ldapSaving, setLdapSaving] = useState(false);
|
||||||
|
const [ldapTesting, setLdapTesting] = useState(false);
|
||||||
|
const [ldapError, setLdapError] = useState("");
|
||||||
|
const [ldapTestResult, setLdapTestResult] = useState<LDAPTestResult | null>(null);
|
||||||
|
const [ldapForm, setLdapForm] = useState<LDAPConfig>({
|
||||||
|
enabled: false,
|
||||||
|
url: "ldap://",
|
||||||
|
bind_dn: "",
|
||||||
|
bind_password: "",
|
||||||
|
base_dn: "",
|
||||||
|
user_filter: "(sAMAccountName=%s)",
|
||||||
|
tls: false,
|
||||||
|
tls_skip_verify: false,
|
||||||
|
default_role: "user",
|
||||||
|
group_mappings: [],
|
||||||
|
});
|
||||||
|
const [ldapChangePassword, setLdapChangePassword] = useState(false);
|
||||||
|
|
||||||
|
// Tenants state
|
||||||
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
|
const [tenantsLoading, setTenantsLoading] = useState(false);
|
||||||
|
const [tenantsError, setTenantsError] = useState("");
|
||||||
|
const [tenantDialogOpen, setTenantDialogOpen] = useState(false);
|
||||||
|
const [newTenantName, setNewTenantName] = useState("");
|
||||||
|
const [newTenantSlug, setNewTenantSlug] = useState("");
|
||||||
|
const [tenantCreateLoading, setTenantCreateLoading] = useState(false);
|
||||||
|
const [tenantCreateError, setTenantCreateError] = useState("");
|
||||||
|
const [tenantDeleteId, setTenantDeleteId] = useState<number | null>(null);
|
||||||
|
const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false);
|
||||||
|
const [domainDialogTenant, setDomainDialogTenant] = useState<Tenant | null>(null);
|
||||||
|
const [tenantDomains, setTenantDomains] = useState<TenantDomain[]>([]);
|
||||||
|
const [domainsLoading, setDomainsLoading] = useState(false);
|
||||||
|
const [newDomain, setNewDomain] = useState("");
|
||||||
|
const [addDomainLoading, setAddDomainLoading] = useState(false);
|
||||||
|
const [domainError, setDomainError] = useState("");
|
||||||
|
|
||||||
const loadDashboard = useCallback(async () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
setDashLoading(true);
|
setDashLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -382,6 +436,168 @@ 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<LDAPConfig> = { ...ldapForm };
|
||||||
|
if (!ldapChangePassword) {
|
||||||
|
delete payload.bind_password;
|
||||||
|
}
|
||||||
|
await saveLDAPConfig(payload);
|
||||||
|
await loadLDAP();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setLdapSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTestLDAP() {
|
||||||
|
setLdapTesting(true);
|
||||||
|
setLdapError("");
|
||||||
|
setLdapTestResult(null);
|
||||||
|
try {
|
||||||
|
const payload = ldapConfig
|
||||||
|
? { use_saved: true }
|
||||||
|
: { use_saved: false, ...ldapForm };
|
||||||
|
const result = await testLDAPConfig(payload as Parameters<typeof testLDAPConfig>[0]);
|
||||||
|
setLdapTestResult(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setLdapTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteLDAP() {
|
||||||
|
setLdapSaving(true);
|
||||||
|
setLdapError("");
|
||||||
|
try {
|
||||||
|
await deleteLDAPConfig();
|
||||||
|
setLdapConfig(null);
|
||||||
|
setLdapForm({
|
||||||
|
enabled: false, url: "ldap://", bind_dn: "", bind_password: "",
|
||||||
|
base_dn: "", user_filter: "(sAMAccountName=%s)", tls: false,
|
||||||
|
tls_skip_verify: false, default_role: "user", group_mappings: [],
|
||||||
|
});
|
||||||
|
setLdapTestResult(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setLdapSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenants handlers
|
||||||
|
const loadTenants = useCallback(async () => {
|
||||||
|
setTenantsLoading(true);
|
||||||
|
setTenantsError("");
|
||||||
|
try {
|
||||||
|
const data = await getTenants();
|
||||||
|
setTenants(data || []);
|
||||||
|
} catch {
|
||||||
|
setTenantsError("Mandanten konnten nicht geladen werden.");
|
||||||
|
} finally {
|
||||||
|
setTenantsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleCreateTenant(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setTenantCreateLoading(true);
|
||||||
|
setTenantCreateError("");
|
||||||
|
try {
|
||||||
|
await createTenant(newTenantName, newTenantSlug);
|
||||||
|
setTenantDialogOpen(false);
|
||||||
|
setNewTenantName("");
|
||||||
|
setNewTenantSlug("");
|
||||||
|
await loadTenants();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setTenantCreateError(err instanceof Error ? err.message : "Erstellen fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setTenantCreateLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleTenant(t: Tenant) {
|
||||||
|
try {
|
||||||
|
await updateTenant(t.id, { active: !t.active });
|
||||||
|
await loadTenants();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteTenant() {
|
||||||
|
if (!tenantDeleteId) return;
|
||||||
|
setTenantDeleteLoading(true);
|
||||||
|
try {
|
||||||
|
await deleteTenant(tenantDeleteId);
|
||||||
|
setTenantDeleteId(null);
|
||||||
|
await loadTenants();
|
||||||
|
} catch { /* ignore */ } finally {
|
||||||
|
setTenantDeleteLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDomainDialog(t: Tenant) {
|
||||||
|
setDomainDialogTenant(t);
|
||||||
|
setDomainsLoading(true);
|
||||||
|
setDomainError("");
|
||||||
|
setNewDomain("");
|
||||||
|
try {
|
||||||
|
const domains = await getTenantDomains(t.id);
|
||||||
|
setTenantDomains(domains || []);
|
||||||
|
} catch { setDomainError("Domains konnten nicht geladen werden."); }
|
||||||
|
finally { setDomainsLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddDomain() {
|
||||||
|
if (!domainDialogTenant || !newDomain) return;
|
||||||
|
setAddDomainLoading(true);
|
||||||
|
setDomainError("");
|
||||||
|
try {
|
||||||
|
await addTenantDomain(domainDialogTenant.id, newDomain);
|
||||||
|
setNewDomain("");
|
||||||
|
const domains = await getTenantDomains(domainDialogTenant.id);
|
||||||
|
setTenantDomains(domains || []);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht hinzugefügt werden.");
|
||||||
|
} finally {
|
||||||
|
setAddDomainLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveDomain(domainId: number) {
|
||||||
|
if (!domainDialogTenant) return;
|
||||||
|
setDomainError("");
|
||||||
|
try {
|
||||||
|
await removeTenantDomain(domainDialogTenant.id, domainId);
|
||||||
|
const domains = await getTenantDomains(domainDialogTenant.id);
|
||||||
|
setTenantDomains(domains || []);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht entfernt werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -404,7 +620,9 @@ export default function AdminPage() {
|
|||||||
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
||||||
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
||||||
<TabsTrigger value="import">Import</TabsTrigger>
|
<TabsTrigger value="import">Import</TabsTrigger>
|
||||||
|
<TabsTrigger value="ldap" onClick={loadLDAP}>LDAP</TabsTrigger>
|
||||||
<TabsTrigger value="security">Security</TabsTrigger>
|
<TabsTrigger value="security">Security</TabsTrigger>
|
||||||
|
<TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>
|
||||||
<TabsTrigger value="modules">Module</TabsTrigger>
|
<TabsTrigger value="modules">Module</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -1343,6 +1561,448 @@ export default function AdminPage() {
|
|||||||
})()}
|
})()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── LDAP ── */}
|
||||||
|
<TabsContent value="ldap" className="mt-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">LDAP / Active Directory</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">LDAP aktiviert</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4"
|
||||||
|
checked={ldapForm.enabled}
|
||||||
|
onChange={(e) => setLdapForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ldapError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{ldapError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ldapLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSaveLDAP} className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ldap-url">Server-URL</Label>
|
||||||
|
<Input
|
||||||
|
id="ldap-url"
|
||||||
|
placeholder="ldap://dc.example.com:389"
|
||||||
|
value={ldapForm.url}
|
||||||
|
onChange={(e) => setLdapForm((f) => ({ ...f, url: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ldap-bind-dn">Bind-DN (Service-Account)</Label>
|
||||||
|
<Input
|
||||||
|
id="ldap-bind-dn"
|
||||||
|
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
|
||||||
|
value={ldapForm.bind_dn}
|
||||||
|
onChange={(e) => setLdapForm((f) => ({ ...f, bind_dn: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ldap-pw">Bind-Passwort</Label>
|
||||||
|
{ldapConfig && !ldapChangePassword ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id="ldap-pw" type="password" value="••••••" readOnly className="flex-1" />
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setLdapChangePassword(true)}>
|
||||||
|
Ändern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id="ldap-pw"
|
||||||
|
type="password"
|
||||||
|
placeholder="Neues Passwort eingeben"
|
||||||
|
value={ldapForm.bind_password}
|
||||||
|
onChange={(e) => setLdapForm((f) => ({ ...f, bind_password: e.target.value }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ldap-base-dn">Base-DN</Label>
|
||||||
|
<Input
|
||||||
|
id="ldap-base-dn"
|
||||||
|
placeholder="DC=example,DC=com"
|
||||||
|
value={ldapForm.base_dn}
|
||||||
|
onChange={(e) => setLdapForm((f) => ({ ...f, base_dn: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ldap-filter">User-Filter</Label>
|
||||||
|
<Input
|
||||||
|
id="ldap-filter"
|
||||||
|
placeholder="(sAMAccountName=%s)"
|
||||||
|
value={ldapForm.user_filter}
|
||||||
|
onChange={(e) => setLdapForm((f) => ({ ...f, user_filter: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ldap-role">Standard-Rolle</Label>
|
||||||
|
<Select
|
||||||
|
value={ldapForm.default_role}
|
||||||
|
onValueChange={(v) => setLdapForm((f) => ({ ...f, default_role: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="ldap-role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="auditor">Auditor</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4"
|
||||||
|
checked={ldapForm.tls}
|
||||||
|
onChange={(e) => setLdapForm((f) => ({ ...f, tls: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
STARTTLS verwenden
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4"
|
||||||
|
checked={ldapForm.tls_skip_verify}
|
||||||
|
onChange={(e) => setLdapForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
TLS-Zertifikat ignorieren
|
||||||
|
{ldapForm.tls_skip_verify && (
|
||||||
|
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Group mappings */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Gruppen-Rollenzuordnung</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setLdapForm((f) => ({
|
||||||
|
...f,
|
||||||
|
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{ldapForm.group_mappings.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{ldapForm.group_mappings.map((gm, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
placeholder="CN=Archivadmins,OU=Groups,DC=example,DC=com"
|
||||||
|
value={gm.group_dn}
|
||||||
|
onChange={(e) => {
|
||||||
|
const gms = [...ldapForm.group_mappings];
|
||||||
|
gms[i] = { ...gms[i], group_dn: e.target.value };
|
||||||
|
setLdapForm((f) => ({ ...f, group_mappings: gms }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={gm.role}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const gms = [...ldapForm.group_mappings];
|
||||||
|
gms[i] = { ...gms[i], role: v };
|
||||||
|
setLdapForm((f) => ({ ...f, group_mappings: gms }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="auditor">Auditor</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const gms = ldapForm.group_mappings.filter((_, j) => j !== i);
|
||||||
|
setLdapForm((f) => ({ ...f, group_mappings: gms }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Test result */}
|
||||||
|
{ldapTestResult && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={ldapTestResult.ok ? "default" : "destructive"}>
|
||||||
|
{ldapTestResult.ok ? "Verbunden" : "Fehler"}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">{ldapTestResult.message}</span>
|
||||||
|
{ldapTestResult.latency_ms > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">{ldapTestResult.latency_ms} ms</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ldapTestResult.server_info && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">{ldapTestResult.server_info}</p>
|
||||||
|
)}
|
||||||
|
{ldapTestResult.users_found > 0 && (
|
||||||
|
<p className="text-sm">{ldapTestResult.users_found} Benutzer gefunden</p>
|
||||||
|
)}
|
||||||
|
{ldapTestResult.error_detail && (
|
||||||
|
<p className="text-xs text-destructive font-mono">{ldapTestResult.error_detail}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action bar */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleTestLDAP} disabled={ldapTesting || ldapSaving}>
|
||||||
|
{ldapTesting ? "Teste..." : "Verbindung testen"}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={ldapSaving || ldapTesting}>
|
||||||
|
{ldapSaving ? "Speichern..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
{ldapConfig && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={ldapSaving}
|
||||||
|
onClick={handleDeleteLDAP}
|
||||||
|
>
|
||||||
|
Konfiguration löschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ldapConfig && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Zuletzt geändert: {ldapConfig.updated_at ? new Date(ldapConfig.updated_at).toLocaleString("de-DE") : "–"}
|
||||||
|
{ldapConfig.updated_by ? ` von ${ldapConfig.updated_by}` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Mandanten ── */}
|
||||||
|
<TabsContent value="tenants" className="mt-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Mandantenverwaltung</h2>
|
||||||
|
<Dialog open={tenantDialogOpen} onOpenChange={setTenantDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">+ Mandant anlegen</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neuen Mandanten anlegen</DialogTitle>
|
||||||
|
<DialogDescription>Name und URL-Slug für den neuen Mandanten eingeben.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleCreateTenant} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tenant-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="tenant-name"
|
||||||
|
value={newTenantName}
|
||||||
|
required
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewTenantName(e.target.value);
|
||||||
|
setNewTenantSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tenant-slug">Slug</Label>
|
||||||
|
<Input
|
||||||
|
id="tenant-slug"
|
||||||
|
value={newTenantSlug}
|
||||||
|
required
|
||||||
|
onChange={(e) => setNewTenantSlug(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tenantCreateError && (
|
||||||
|
<p className="text-sm text-destructive">{tenantCreateError}</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={tenantCreateLoading}>
|
||||||
|
{tenantCreateLoading ? "Erstellen..." : "Erstellen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tenantsError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{tenantsError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tenantsLoading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : tenants.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
Keine Mandanten vorhanden. Klicke auf “+ Mandant anlegen” um den ersten Mandanten zu erstellen.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Slug</TableHead>
|
||||||
|
<TableHead className="text-center">Domains</TableHead>
|
||||||
|
<TableHead className="text-center">Nutzer</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Aktionen</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell className="font-medium">{t.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">{t.slug}</TableCell>
|
||||||
|
<TableCell className="text-center">{t.domain_count ?? 0}</TableCell>
|
||||||
|
<TableCell className="text-center">{t.user_count ?? 0}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={t.active ? "default" : "secondary"}>
|
||||||
|
{t.active ? "Aktiv" : "Inaktiv"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openDomainDialog(t)}>
|
||||||
|
Domains
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}>
|
||||||
|
{t.active ? "Deaktivieren" : "Aktivieren"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setTenantDeleteId(t.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tenant delete confirmation */}
|
||||||
|
<Dialog open={tenantDeleteId !== null} onOpenChange={(open) => { if (!open) setTenantDeleteId(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Mandant löschen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Alle Daten dieses Mandanten (Domains, LDAP-Konfiguration) werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setTenantDeleteId(null)}>Abbrechen</Button>
|
||||||
|
<Button variant="destructive" disabled={tenantDeleteLoading} onClick={handleDeleteTenant}>
|
||||||
|
{tenantDeleteLoading ? "Löschen..." : "Endgültig löschen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Domain management dialog */}
|
||||||
|
<Dialog open={domainDialogTenant !== null} onOpenChange={(open) => { if (!open) setDomainDialogTenant(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Domains: {domainDialogTenant?.name}</DialogTitle>
|
||||||
|
<DialogDescription>E-Mail-Domains diesem Mandanten zuweisen.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{domainError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{domainError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{domainsLoading ? (
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tenantDomains.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Keine Domains zugewiesen.</p>
|
||||||
|
) : (
|
||||||
|
tenantDomains.map((d) => (
|
||||||
|
<div key={d.id} className="flex items-center justify-between rounded border px-3 py-2">
|
||||||
|
<span className="font-mono text-sm">{d.domain}</span>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => handleRemoveDomain(d.id)}>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Input
|
||||||
|
placeholder="example.com"
|
||||||
|
value={newDomain}
|
||||||
|
onChange={(e) => setNewDomain(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddDomain(); } }}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleAddDomain} disabled={addDomainLoading || !newDomain}>
|
||||||
|
{addDomainLoading ? "..." : "Hinzufügen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="modules" className="mt-4">
|
<TabsContent value="modules" className="mt-4">
|
||||||
<ModulesTab />
|
<ModulesTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
+129
@@ -599,3 +599,132 @@ export async function fixSecurityIssue(action: string): Promise<{ message: strin
|
|||||||
body: JSON.stringify({ action }),
|
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 LDAPTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
latency_ms: number;
|
||||||
|
server_info: string;
|
||||||
|
users_found: number;
|
||||||
|
error_detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLDAPConfig(): Promise<LDAPConfig | null> {
|
||||||
|
try {
|
||||||
|
return await request<LDAPConfig>("/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<LDAPConfig>): Promise<void> {
|
||||||
|
await request<void>("/api/admin/ldap", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(cfg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLDAPConfig(): Promise<void> {
|
||||||
|
await request<void>("/api/admin/ldap", { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testLDAPConfig(
|
||||||
|
payload: { use_saved: boolean } & Partial<LDAPConfig>
|
||||||
|
): Promise<LDAPTestResult> {
|
||||||
|
return request<LDAPTestResult>("/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantDomain {
|
||||||
|
id: number;
|
||||||
|
tenant_id: number;
|
||||||
|
domain: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTenants(): Promise<Tenant[]> {
|
||||||
|
return request<Tenant[]>("/api/tenants");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTenant(name: string, slug: string): Promise<Tenant> {
|
||||||
|
return request<Tenant>("/api/tenants", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, slug }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTenant(
|
||||||
|
id: number,
|
||||||
|
data: { name?: string; active?: boolean }
|
||||||
|
): Promise<Tenant> {
|
||||||
|
return request<Tenant>(`/api/tenants/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTenant(id: number): Promise<void> {
|
||||||
|
await request<void>(`/api/tenants/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTenantDomains(id: number): Promise<TenantDomain[]> {
|
||||||
|
return request<TenantDomain[]>(`/api/tenants/${id}/domains`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTenantDomain(
|
||||||
|
tenantId: number,
|
||||||
|
domain: string
|
||||||
|
): Promise<TenantDomain> {
|
||||||
|
return request<TenantDomain>(`/api/tenants/${tenantId}/domains`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ domain }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTenantDomain(
|
||||||
|
tenantId: number,
|
||||||
|
domainId: number
|
||||||
|
): Promise<void> {
|
||||||
|
await request<void>(`/api/tenants/${tenantId}/domains/${domainId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user