diff --git a/internal/api/ldap_tenants.go b/internal/api/ldap_tenants.go index 6e38967..07e723c 100644 --- a/internal/api/ldap_tenants.go +++ b/internal/api/ldap_tenants.go @@ -1,7 +1,11 @@ package api import ( + "crypto/rand" + "encoding/hex" "encoding/json" + "fmt" + "io" "net/http" "strconv" @@ -12,6 +16,8 @@ import ( "github.com/archivmail/internal/userstore" ) +const maxLogoSize = 2 * 1024 * 1024 // 2 MB + // ── Server extension fields and wiring ────────────────────────────────────── // ldapStore and tenantStore are added to the Server struct via SetLDAP / SetTenants. @@ -41,6 +47,16 @@ func (s *Server) SetTenants(store *tenantstore.Store) { 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))) s.mux.HandleFunc("GET /api/tenants/{id}/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenantUsers))) + + // Logo routes: any auth can read; admin can write + s.mux.HandleFunc("GET /api/tenants/{id}/logo", s.auth(s.handleGetTenantLogo)) + s.mux.HandleFunc("POST /api/tenants/{id}/logo", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUploadTenantLogo))) + s.mux.HandleFunc("DELETE /api/tenants/{id}/logo", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteTenantLogo))) + + // Logo routes for domain_admin (own tenant) + s.mux.HandleFunc("GET /api/tenant/logo", s.authAdmin(s.handleGetOwnTenantLogo)) + s.mux.HandleFunc("POST /api/tenant/logo", s.authAdmin(s.handleUploadOwnTenantLogo)) + s.mux.HandleFunc("DELETE /api/tenant/logo", s.authAdmin(s.handleDeleteOwnTenantLogo)) } // ── LDAP handlers ──────────────────────────────────────────────────────────── @@ -215,6 +231,50 @@ func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) { return } + // Create default users for the new tenant. + type defaultUserCreds struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + } + type createTenantResponse struct { + *tenantstore.Tenant + DefaultUsers []defaultUserCreds `json:"default_users"` + } + + resp := createTenantResponse{Tenant: tenant} + for _, spec := range []struct { + suffix string + role string + }{ + {suffix: "admin", role: userstore.RoleDomainAdmin}, + {suffix: "auditor", role: userstore.RoleAuditor}, + } { + pw, pwErr := tenantRandomPassword() + if pwErr != nil { + writeError(w, http.StatusInternalServerError, "failed to generate password") + return + } + username := req.Slug + "-" + spec.suffix + email := fmt.Sprintf("%s@%s.local", username, req.Slug) + u, uErr := s.users.Create(userstore.CreateUserRequest{ + Username: username, + Email: email, + Password: pw, + Role: spec.role, + TenantID: &tenant.ID, + }) + if uErr != nil { + writeError(w, http.StatusInternalServerError, "failed to create default user") + return + } + resp.DefaultUsers = append(resp.DefaultUsers, defaultUserCreds{ + Username: u.Username, + Password: pw, + Role: spec.role, + }) + } + sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: "tenant_created", @@ -224,7 +284,7 @@ func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) { Detail: "Mandant erstellt: " + req.Name, }) - writeJSON(w, http.StatusCreated, tenant) + writeJSON(w, http.StatusCreated, resp) } func (s *Server) handleGetTenant(w http.ResponseWriter, r *http.Request) { @@ -744,3 +804,204 @@ func parseTenantID(r *http.Request) (int64, error) { return strconv.ParseInt(r.PathValue("id"), 10, 64) } +// tenantRandomPassword generates a cryptographically random 16-byte hex password. +func tenantRandomPassword() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// ── Logo handlers (admin: any tenant) ─────────────────────────────────────── + +func (s *Server) handleGetTenantLogo(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 + } + data, contentType, err := s.tenantStore.GetLogo(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load logo") + return + } + if data == nil { + writeError(w, http.StatusNotFound, "no logo set") + return + } + if contentType == "" { + contentType = "image/png" + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=86400") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func (s *Server) handleUploadTenantLogo(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 + } + s.saveTenantLogo(w, r, id) +} + +func (s *Server) handleDeleteTenantLogo(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 + } + if err := s.tenantStore.DeleteLogo(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete logo") + return + } + sess := sessionFromCtx(r.Context()) + s.audlog.Log(audit.Entry{ + EventType: "tenant_logo_deleted", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: true, + Detail: "Mandant-Logo gelöscht (tenant " + strconv.FormatInt(id, 10) + ")", + }) + w.WriteHeader(http.StatusNoContent) +} + +// ── Logo handlers (domain_admin: own tenant) ───────────────────────────────── + +func (s *Server) handleGetOwnTenantLogo(w http.ResponseWriter, r *http.Request) { + if s.tenantStore == nil { + writeError(w, http.StatusServiceUnavailable, "tenant store not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.TenantID == nil { + writeError(w, http.StatusBadRequest, "no tenant context") + return + } + data, contentType, err := s.tenantStore.GetLogo(r.Context(), *sess.TenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load logo") + return + } + if data == nil { + writeError(w, http.StatusNotFound, "no logo set") + return + } + if contentType == "" { + contentType = "image/png" + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=86400") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func (s *Server) handleUploadOwnTenantLogo(w http.ResponseWriter, r *http.Request) { + if s.tenantStore == nil { + writeError(w, http.StatusServiceUnavailable, "tenant store not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.TenantID == nil { + writeError(w, http.StatusBadRequest, "no tenant context") + return + } + s.saveTenantLogo(w, r, *sess.TenantID) +} + +func (s *Server) handleDeleteOwnTenantLogo(w http.ResponseWriter, r *http.Request) { + if s.tenantStore == nil { + writeError(w, http.StatusServiceUnavailable, "tenant store not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.TenantID == nil { + writeError(w, http.StatusBadRequest, "no tenant context") + return + } + if err := s.tenantStore.DeleteLogo(r.Context(), *sess.TenantID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete logo") + return + } + s.audlog.Log(audit.Entry{ + EventType: "tenant_logo_deleted", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: true, + Detail: "Mandant-Logo gelöscht", + }) + w.WriteHeader(http.StatusNoContent) +} + +// saveTenantLogo is the shared multipart upload logic for logo handlers. +func (s *Server) saveTenantLogo(w http.ResponseWriter, r *http.Request, tenantID int64) { + if err := r.ParseMultipartForm(maxLogoSize); err != nil { + writeError(w, http.StatusBadRequest, "failed to parse multipart form") + return + } + file, header, err := r.FormFile("logo") + if err != nil { + writeError(w, http.StatusBadRequest, "logo file required") + return + } + defer file.Close() + + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "image/png" + } + allowed := map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/jpg": true, + "image/gif": true, + "image/webp": true, + "image/svg+xml": true, + } + if !allowed[contentType] { + writeError(w, http.StatusBadRequest, "unsupported image type (allowed: png, jpeg, gif, webp, svg)") + return + } + + data, err := io.ReadAll(io.LimitReader(file, maxLogoSize+1)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read logo") + return + } + if int64(len(data)) > maxLogoSize { + writeError(w, http.StatusBadRequest, "logo too large (max 2 MB)") + return + } + + if err := s.tenantStore.SetLogo(r.Context(), tenantID, data, contentType); err != nil { + writeError(w, http.StatusInternalServerError, "failed to save logo") + return + } + + sess := sessionFromCtx(r.Context()) + s.audlog.Log(audit.Entry{ + EventType: "tenant_logo_uploaded", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: true, + Detail: fmt.Sprintf("Mandant-Logo hochgeladen (%d bytes, %s, tenant %d)", len(data), contentType, tenantID), + }) + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + diff --git a/internal/tenantstore/store.go b/internal/tenantstore/store.go index 75c67a4..35ad28e 100644 --- a/internal/tenantstore/store.go +++ b/internal/tenantstore/store.go @@ -24,6 +24,7 @@ type Tenant struct { UserCount int `json:"user_count,omitempty"` LDAPEnabled *bool `json:"ldap_enabled,omitempty"` LDAPURL string `json:"ldap_url,omitempty"` + HasLogo bool `json:"has_logo,omitempty"` } // TenantDomain is an e-mail domain assigned to a tenant. @@ -71,6 +72,8 @@ CREATE TABLE IF NOT EXISTS tenant_ldap ( 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); +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_data BYTEA; +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_content_type VARCHAR(100) NOT NULL DEFAULT ''; ` // New connects to PostgreSQL and initialises the tenant schema. @@ -117,10 +120,11 @@ func (s *Store) Create(ctx context.Context, name, slug string) (*Tenant, error) 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, - tl.enabled AS ldap_enabled, - tl.url AS ldap_url + COUNT(DISTINCT td.id) AS domain_count, + COUNT(DISTINCT u.id) AS user_count, + tl.enabled AS ldap_enabled, + tl.url AS ldap_url, + (t.logo_data IS NOT NULL) AS has_logo FROM tenants t LEFT JOIN tenant_domains td ON td.tenant_id = t.id LEFT JOIN users u ON u.tenant_id = t.id @@ -136,7 +140,7 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) { 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, &t.LDAPEnabled, &t.LDAPURL); err != nil { + if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo); err != nil { return nil, fmt.Errorf("tenantstore: scan: %w", err) } tenants = append(tenants, t) @@ -150,10 +154,10 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) { // 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, + `SELECT id, name, slug, active, created_at, (logo_data IS NOT NULL) AS has_logo 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 { + if err := row.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.HasLogo); err == pgx.ErrNoRows { return nil, fmt.Errorf("tenantstore: not found: %d", id) } else if err != nil { return nil, fmt.Errorf("tenantstore: get: %w", err) @@ -161,6 +165,46 @@ func (s *Store) Get(ctx context.Context, id int64) (*Tenant, error) { return &t, nil } +// SetLogo stores a logo image for a tenant (replaces any existing logo). +func (s *Store) SetLogo(ctx context.Context, tenantID int64, data []byte, contentType string) error { + _, err := s.pool.Exec(ctx, + `UPDATE tenants SET logo_data = $1, logo_content_type = $2 WHERE id = $3`, + data, contentType, tenantID, + ) + if err != nil { + return fmt.Errorf("tenantstore: set logo: %w", err) + } + return nil +} + +// GetLogo returns the raw logo bytes and content type for a tenant. +// Returns nil data if no logo is set. +func (s *Store) GetLogo(ctx context.Context, tenantID int64) ([]byte, string, error) { + var data []byte + var contentType string + err := s.pool.QueryRow(ctx, + `SELECT logo_data, logo_content_type FROM tenants WHERE id = $1`, tenantID, + ).Scan(&data, &contentType) + if err == pgx.ErrNoRows { + return nil, "", nil + } + if err != nil { + return nil, "", fmt.Errorf("tenantstore: get logo: %w", err) + } + return data, contentType, nil +} + +// DeleteLogo removes the logo from a tenant. +func (s *Store) DeleteLogo(ctx context.Context, tenantID int64) error { + _, err := s.pool.Exec(ctx, + `UPDATE tenants SET logo_data = NULL, logo_content_type = '' WHERE id = $1`, tenantID, + ) + if err != nil { + return fmt.Errorf("tenantstore: delete logo: %w", err) + } + return 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, diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1370ddd..cda93ce 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -45,6 +45,11 @@ import { getLabelRules, createLabelRule, deleteLabelRule, + getTenantLogoUrl, + uploadTenantLogo, + deleteTenantLogo, + uploadMyTenantLogo, + deleteMyTenantLogo, type User, type AuditEntry, type SMTPStatus, @@ -58,6 +63,7 @@ import { type LDAPTestResult, type TenantLDAPConfig, type Tenant, + type TenantDefaultUser, type TenantDomain, type MailLabel, type LabelRule, @@ -206,6 +212,9 @@ export default function AdminPage() { const [newTenantSlug, setNewTenantSlug] = useState(""); const [tenantCreateLoading, setTenantCreateLoading] = useState(false); const [tenantCreateError, setTenantCreateError] = useState(""); + const [tenantCreatedUsers, setTenantCreatedUsers] = useState([]); + const [tenantCreatedName, setTenantCreatedName] = useState(""); + const [tenantCredDialogOpen, setTenantCredDialogOpen] = useState(false); const [tenantDeleteId, setTenantDeleteId] = useState(null); const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false); const [domainDialogTenant, setDomainDialogTenant] = useState(null); @@ -239,6 +248,17 @@ export default function AdminPage() { // Superadmin: tenant LDAP dialog const [tenantLdapDialogId, setTenantLdapDialogId] = useState(null); + // Logo dialog (superadmin: any tenant) + const [logoDialogTenant, setLogoDialogTenant] = useState(null); + const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); + const [logoUploading, setLogoUploading] = useState(false); + const [logoError, setLogoError] = useState(""); + + // Logo for domain_admin own tenant + const [ownLogoPreviewUrl, setOwnLogoPreviewUrl] = useState(null); + const [ownLogoUploading, setOwnLogoUploading] = useState(false); + const [ownLogoError, setOwnLogoError] = useState(""); + // Tenant users dialog const [tenantUsersDialogId, setTenantUsersDialogId] = useState(null); const [tenantUsersDialogName, setTenantUsersDialogName] = useState(""); @@ -713,8 +733,11 @@ export default function AdminPage() { setTenantCreateLoading(true); setTenantCreateError(""); try { - await createTenant(newTenantName, newTenantSlug); + const result = await createTenant(newTenantName, newTenantSlug); setTenantDialogOpen(false); + setTenantCreatedName(result.name); + setTenantCreatedUsers(result.default_users ?? []); + setTenantCredDialogOpen(true); setNewTenantName(""); setNewTenantSlug(""); await loadTenants(); @@ -801,6 +824,89 @@ export default function AdminPage() { } } + // Logo handlers (superadmin: any tenant) + async function openLogoDialog(t: Tenant) { + setLogoDialogTenant(t); + setLogoError(""); + setLogoPreviewUrl(null); + if (t.has_logo) { + try { + const res = await fetch(getTenantLogoUrl(t.id), { credentials: "include" }); + if (res.ok) { + const blob = await res.blob(); + setLogoPreviewUrl(URL.createObjectURL(blob)); + } + } catch { + // preview not critical + } + } + } + + async function handleLogoUpload(file: File) { + if (!logoDialogTenant) return; + setLogoUploading(true); + setLogoError(""); + try { + await uploadTenantLogo(logoDialogTenant.id, file); + const res = await fetch(getTenantLogoUrl(logoDialogTenant.id), { credentials: "include" }); + if (res.ok) { + const blob = await res.blob(); + setLogoPreviewUrl(URL.createObjectURL(blob)); + } + setTenants((prev) => prev.map((t) => t.id === logoDialogTenant.id ? { ...t, has_logo: true } : t)); + } catch (err: unknown) { + setLogoError(err instanceof Error ? err.message : "Upload fehlgeschlagen."); + } finally { + setLogoUploading(false); + } + } + + async function handleLogoDelete() { + if (!logoDialogTenant) return; + setLogoUploading(true); + setLogoError(""); + try { + await deleteTenantLogo(logoDialogTenant.id); + setLogoPreviewUrl(null); + setTenants((prev) => prev.map((t) => t.id === logoDialogTenant.id ? { ...t, has_logo: false } : t)); + } catch (err: unknown) { + setLogoError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); + } finally { + setLogoUploading(false); + } + } + + // Logo handlers (domain_admin: own tenant) + async function handleOwnLogoUpload(file: File) { + setOwnLogoUploading(true); + setOwnLogoError(""); + try { + await uploadMyTenantLogo(file); + const res = await fetch(`/api/tenant/logo`, { credentials: "include" }); + if (res.ok) { + const blob = await res.blob(); + setOwnLogoPreviewUrl(URL.createObjectURL(blob)); + } + } catch (err: unknown) { + setOwnLogoError(err instanceof Error ? err.message : "Upload fehlgeschlagen."); + } finally { + setOwnLogoUploading(false); + } + } + + async function handleOwnLogoDelete() { + setOwnLogoUploading(true); + setOwnLogoError(""); + try { + await deleteMyTenantLogo(); + setOwnLogoPreviewUrl(null); + } catch (err: unknown) { + setOwnLogoError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); + } finally { + setOwnLogoUploading(false); + } + } + // Tenant LDAP handlers (domain_admin) const loadTenantLDAP = useCallback(async () => { setTenantLdapLoading(true); @@ -817,6 +923,16 @@ export default function AdminPage() { } finally { setTenantLdapLoading(false); } + // Load own tenant logo preview + try { + const res = await fetch("/api/tenant/logo", { credentials: "include" }); + if (res.ok) { + const blob = await res.blob(); + setOwnLogoPreviewUrl(URL.createObjectURL(blob)); + } + } catch { + // no logo or not available + } }, []); async function handleSaveTenantLDAP(e: React.FormEvent) { @@ -2393,6 +2509,48 @@ export default function AdminPage() { {tenantLdapConfig.updated_by ? ` von ${tenantLdapConfig.updated_by}` : ""}

)} + + {/* Logo section for domain_admin */} +
+
+

Mandanten-Logo

+

Logo deines Mandanten hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB).

+
+ {ownLogoError &&

{ownLogoError}

} + {ownLogoPreviewUrl ? ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Logo +
+
+ { const f = e.target.files?.[0]; if (f) handleOwnLogoUpload(f); }} + className="w-auto" + /> + +
+
+ ) : ( +
+
+ Kein Logo +
+ { const f = e.target.files?.[0]; if (f) handleOwnLogoUpload(f); }} + className="w-auto" + /> +
+ )} +
{/* ── Mandanten ── */} @@ -2509,6 +2667,9 @@ export default function AdminPage() { + @@ -2546,6 +2707,70 @@ export default function AdminPage() { + {/* Logo upload dialog */} + { if (!open) { setLogoDialogTenant(null); setLogoPreviewUrl(null); } }}> + + + Logo — {logoDialogTenant?.name} + Logo hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB). + +
+ {logoPreviewUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Logo +
+ )} + {!logoPreviewUrl && ( +
+ Kein Logo gesetzt +
+ )} + {logoError &&

{logoError}

} +
+ { const f = e.target.files?.[0]; if (f) handleLogoUpload(f); }} + /> + {logoPreviewUrl && ( + + )} +
+
+ + + +
+
+ + {/* Default credentials dialog after tenant creation */} + + + + Mandant „{tenantCreatedName}“ erstellt + + Folgende Standard-Benutzer wurden angelegt. Passwörter bitte sofort notieren — sie werden nur einmalig angezeigt. + + +
+ {tenantCreatedUsers.map((u) => ( +
+
Benutzer: {u.username}
+
Passwort: {u.password}
+
Rolle: {u.role}
+
+ ))} +
+ + + +
+
+ {/* Domain management dialog */} { if (!open) setDomainDialogTenant(null); }}> diff --git a/src/lib/api.ts b/src/lib/api.ts index 4397b7f..782514e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -693,6 +693,7 @@ export interface Tenant { user_count?: number; ldap_enabled?: boolean; ldap_url?: string; + has_logo?: boolean; } export interface TenantDomain { @@ -710,8 +711,18 @@ export async function getTenantUsers(tenantId: number): Promise { return request(`/api/tenants/${tenantId}/users`); } -export async function createTenant(name: string, slug: string): Promise { - return request("/api/tenants", { +export interface TenantDefaultUser { + username: string; + password: string; + role: string; +} + +export interface CreateTenantResponse extends Tenant { + default_users: TenantDefaultUser[]; +} + +export async function createTenant(name: string, slug: string): Promise { + return request("/api/tenants", { method: "POST", body: JSON.stringify({ name, slug }), }); @@ -754,6 +765,49 @@ export async function removeTenantDomain( }); } +// ── Tenant Logo ───────────────────────────────────────────────────────────── + +export function getTenantLogoUrl(tenantId: number): string { + return `${API_BASE}/api/tenants/${tenantId}/logo`; +} + +export async function uploadTenantLogo(tenantId: number, file: File): Promise { + const form = new FormData(); + form.append("logo", file); + const res = await fetch(`${API_BASE}/api/tenants/${tenantId}/logo`, { + method: "POST", + body: form, + credentials: "include", + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `Upload failed: ${res.status}`); + } +} + +export async function deleteTenantLogo(tenantId: number): Promise { + await request(`/api/tenants/${tenantId}/logo`, { method: "DELETE" }); +} + +// domain_admin: own tenant logo +export async function uploadMyTenantLogo(file: File): Promise { + const form = new FormData(); + form.append("logo", file); + const res = await fetch(`${API_BASE}/api/tenant/logo`, { + method: "POST", + body: form, + credentials: "include", + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `Upload failed: ${res.status}`); + } +} + +export async function deleteMyTenantLogo(): Promise { + await request("/api/tenant/logo", { method: "DELETE" }); +} + // ── PROJ-23: Pro-Mandant LDAP (tenant_ldap) ────────────────────────────── export interface TenantLDAPConfig extends LDAPConfig {