From 30c6694dffb6421ef5a05ff0248891fb9fb1ac40 Mon Sep 17 00:00:00 2001
From: sysops
Date: Fri, 20 Mar 2026 03:15:34 +0100
Subject: [PATCH] feat(PROJ-24): Mandanten-Logo Upload
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- DB: logo_data (BYTEA) + logo_content_type Spalten in tenants-Tabelle
- Backend: SetLogo/GetLogo/DeleteLogo im tenantstore
- API: Logo-Endpunkte für superadmin (beliebiger Mandant) und
domain_admin (eigener Mandant), max. 2 MB, PNG/JPEG/GIF/WebP/SVG
- Frontend: Logo-Dialog in Mandantentabelle (superadmin),
Logo-Upload-Sektion im LDAP-Tab (domain_admin)
Co-Authored-By: Claude Sonnet 4.6
---
internal/api/ldap_tenants.go | 263 +++++++++++++++++++++++++++++++++-
internal/tenantstore/store.go | 58 +++++++-
src/app/admin/page.tsx | 227 ++++++++++++++++++++++++++++-
src/lib/api.ts | 58 +++++++-
4 files changed, 595 insertions(+), 11 deletions(-)
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 */}
+

+
+
+ { 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 */}
+
+
+ {/* Default credentials dialog after tenant creation */}
+
+
{/* Domain management dialog */}