feat(PROJ-24): Mandanten-Logo Upload
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -12,6 +16,8 @@ import (
|
|||||||
"github.com/archivmail/internal/userstore"
|
"github.com/archivmail/internal/userstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxLogoSize = 2 * 1024 * 1024 // 2 MB
|
||||||
|
|
||||||
// ── Server extension fields and wiring ──────────────────────────────────────
|
// ── Server extension fields and wiring ──────────────────────────────────────
|
||||||
|
|
||||||
// ldapStore and tenantStore are added to the Server struct via SetLDAP / SetTenants.
|
// 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("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("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)))
|
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 ────────────────────────────────────────────────────────────
|
// ── LDAP handlers ────────────────────────────────────────────────────────────
|
||||||
@@ -215,6 +231,50 @@ func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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())
|
sess := sessionFromCtx(r.Context())
|
||||||
s.audlog.Log(audit.Entry{
|
s.audlog.Log(audit.Entry{
|
||||||
EventType: "tenant_created",
|
EventType: "tenant_created",
|
||||||
@@ -224,7 +284,7 @@ func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
Detail: "Mandant erstellt: " + req.Name,
|
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) {
|
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)
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Tenant struct {
|
|||||||
UserCount int `json:"user_count,omitempty"`
|
UserCount int `json:"user_count,omitempty"`
|
||||||
LDAPEnabled *bool `json:"ldap_enabled,omitempty"`
|
LDAPEnabled *bool `json:"ldap_enabled,omitempty"`
|
||||||
LDAPURL string `json:"ldap_url,omitempty"`
|
LDAPURL string `json:"ldap_url,omitempty"`
|
||||||
|
HasLogo bool `json:"has_logo,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TenantDomain is an e-mail domain assigned to a tenant.
|
// 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 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 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.
|
// 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) {
|
func (s *Store) List(ctx context.Context) ([]Tenant, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT t.id, t.name, t.slug, t.active, t.created_at,
|
SELECT t.id, t.name, t.slug, t.active, t.created_at,
|
||||||
COUNT(DISTINCT td.id) AS domain_count,
|
COUNT(DISTINCT td.id) AS domain_count,
|
||||||
COUNT(DISTINCT u.id) AS user_count,
|
COUNT(DISTINCT u.id) AS user_count,
|
||||||
tl.enabled AS ldap_enabled,
|
tl.enabled AS ldap_enabled,
|
||||||
tl.url AS ldap_url
|
tl.url AS ldap_url,
|
||||||
|
(t.logo_data IS NOT NULL) AS has_logo
|
||||||
FROM tenants t
|
FROM tenants t
|
||||||
LEFT JOIN tenant_domains td ON td.tenant_id = t.id
|
LEFT JOIN tenant_domains td ON td.tenant_id = t.id
|
||||||
LEFT JOIN users u ON u.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
|
var tenants []Tenant
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t Tenant
|
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)
|
return nil, fmt.Errorf("tenantstore: scan: %w", err)
|
||||||
}
|
}
|
||||||
tenants = append(tenants, t)
|
tenants = append(tenants, t)
|
||||||
@@ -150,10 +154,10 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
|
|||||||
// Get returns a single tenant by ID.
|
// Get returns a single tenant by ID.
|
||||||
func (s *Store) Get(ctx context.Context, id int64) (*Tenant, error) {
|
func (s *Store) Get(ctx context.Context, id int64) (*Tenant, error) {
|
||||||
row := s.pool.QueryRow(ctx,
|
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
|
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)
|
return nil, fmt.Errorf("tenantstore: not found: %d", id)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, fmt.Errorf("tenantstore: get: %w", err)
|
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
|
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.
|
// Update sets name and active for a tenant.
|
||||||
func (s *Store) Update(ctx context.Context, id int64, name string, active bool) (*Tenant, error) {
|
func (s *Store) Update(ctx context.Context, id int64, name string, active bool) (*Tenant, error) {
|
||||||
_, err := s.pool.Exec(ctx,
|
_, err := s.pool.Exec(ctx,
|
||||||
|
|||||||
+226
-1
@@ -45,6 +45,11 @@ import {
|
|||||||
getLabelRules,
|
getLabelRules,
|
||||||
createLabelRule,
|
createLabelRule,
|
||||||
deleteLabelRule,
|
deleteLabelRule,
|
||||||
|
getTenantLogoUrl,
|
||||||
|
uploadTenantLogo,
|
||||||
|
deleteTenantLogo,
|
||||||
|
uploadMyTenantLogo,
|
||||||
|
deleteMyTenantLogo,
|
||||||
type User,
|
type User,
|
||||||
type AuditEntry,
|
type AuditEntry,
|
||||||
type SMTPStatus,
|
type SMTPStatus,
|
||||||
@@ -58,6 +63,7 @@ import {
|
|||||||
type LDAPTestResult,
|
type LDAPTestResult,
|
||||||
type TenantLDAPConfig,
|
type TenantLDAPConfig,
|
||||||
type Tenant,
|
type Tenant,
|
||||||
|
type TenantDefaultUser,
|
||||||
type TenantDomain,
|
type TenantDomain,
|
||||||
type MailLabel,
|
type MailLabel,
|
||||||
type LabelRule,
|
type LabelRule,
|
||||||
@@ -206,6 +212,9 @@ export default function AdminPage() {
|
|||||||
const [newTenantSlug, setNewTenantSlug] = useState("");
|
const [newTenantSlug, setNewTenantSlug] = useState("");
|
||||||
const [tenantCreateLoading, setTenantCreateLoading] = useState(false);
|
const [tenantCreateLoading, setTenantCreateLoading] = useState(false);
|
||||||
const [tenantCreateError, setTenantCreateError] = useState("");
|
const [tenantCreateError, setTenantCreateError] = useState("");
|
||||||
|
const [tenantCreatedUsers, setTenantCreatedUsers] = useState<TenantDefaultUser[]>([]);
|
||||||
|
const [tenantCreatedName, setTenantCreatedName] = useState("");
|
||||||
|
const [tenantCredDialogOpen, setTenantCredDialogOpen] = useState(false);
|
||||||
const [tenantDeleteId, setTenantDeleteId] = useState<number | null>(null);
|
const [tenantDeleteId, setTenantDeleteId] = useState<number | null>(null);
|
||||||
const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false);
|
const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false);
|
||||||
const [domainDialogTenant, setDomainDialogTenant] = useState<Tenant | null>(null);
|
const [domainDialogTenant, setDomainDialogTenant] = useState<Tenant | null>(null);
|
||||||
@@ -239,6 +248,17 @@ export default function AdminPage() {
|
|||||||
// Superadmin: tenant LDAP dialog
|
// Superadmin: tenant LDAP dialog
|
||||||
const [tenantLdapDialogId, setTenantLdapDialogId] = useState<number | null>(null);
|
const [tenantLdapDialogId, setTenantLdapDialogId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Logo dialog (superadmin: any tenant)
|
||||||
|
const [logoDialogTenant, setLogoDialogTenant] = useState<Tenant | null>(null);
|
||||||
|
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [logoUploading, setLogoUploading] = useState(false);
|
||||||
|
const [logoError, setLogoError] = useState("");
|
||||||
|
|
||||||
|
// Logo for domain_admin own tenant
|
||||||
|
const [ownLogoPreviewUrl, setOwnLogoPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [ownLogoUploading, setOwnLogoUploading] = useState(false);
|
||||||
|
const [ownLogoError, setOwnLogoError] = useState("");
|
||||||
|
|
||||||
// Tenant users dialog
|
// Tenant users dialog
|
||||||
const [tenantUsersDialogId, setTenantUsersDialogId] = useState<number | null>(null);
|
const [tenantUsersDialogId, setTenantUsersDialogId] = useState<number | null>(null);
|
||||||
const [tenantUsersDialogName, setTenantUsersDialogName] = useState("");
|
const [tenantUsersDialogName, setTenantUsersDialogName] = useState("");
|
||||||
@@ -713,8 +733,11 @@ export default function AdminPage() {
|
|||||||
setTenantCreateLoading(true);
|
setTenantCreateLoading(true);
|
||||||
setTenantCreateError("");
|
setTenantCreateError("");
|
||||||
try {
|
try {
|
||||||
await createTenant(newTenantName, newTenantSlug);
|
const result = await createTenant(newTenantName, newTenantSlug);
|
||||||
setTenantDialogOpen(false);
|
setTenantDialogOpen(false);
|
||||||
|
setTenantCreatedName(result.name);
|
||||||
|
setTenantCreatedUsers(result.default_users ?? []);
|
||||||
|
setTenantCredDialogOpen(true);
|
||||||
setNewTenantName("");
|
setNewTenantName("");
|
||||||
setNewTenantSlug("");
|
setNewTenantSlug("");
|
||||||
await loadTenants();
|
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)
|
// Tenant LDAP handlers (domain_admin)
|
||||||
const loadTenantLDAP = useCallback(async () => {
|
const loadTenantLDAP = useCallback(async () => {
|
||||||
setTenantLdapLoading(true);
|
setTenantLdapLoading(true);
|
||||||
@@ -817,6 +923,16 @@ export default function AdminPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setTenantLdapLoading(false);
|
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) {
|
async function handleSaveTenantLDAP(e: React.FormEvent) {
|
||||||
@@ -2393,6 +2509,48 @@ export default function AdminPage() {
|
|||||||
{tenantLdapConfig.updated_by ? ` von ${tenantLdapConfig.updated_by}` : ""}
|
{tenantLdapConfig.updated_by ? ` von ${tenantLdapConfig.updated_by}` : ""}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Logo section for domain_admin */}
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">Mandanten-Logo</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Logo deines Mandanten hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB).</p>
|
||||||
|
</div>
|
||||||
|
{ownLogoError && <p className="text-sm text-destructive">{ownLogoError}</p>}
|
||||||
|
{ownLogoPreviewUrl ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center justify-center rounded border p-3 bg-muted/30">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={ownLogoPreviewUrl} alt="Logo" className="max-h-20 max-w-40 object-contain" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||||
|
disabled={ownLogoUploading}
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleOwnLogoUpload(f); }}
|
||||||
|
className="w-auto"
|
||||||
|
/>
|
||||||
|
<Button variant="destructive" size="sm" disabled={ownLogoUploading} onClick={handleOwnLogoDelete}>
|
||||||
|
{ownLogoUploading ? "Bitte warten..." : "Logo entfernen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center justify-center rounded border p-6 bg-muted/30 text-sm text-muted-foreground w-40">
|
||||||
|
Kein Logo
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||||
|
disabled={ownLogoUploading}
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleOwnLogoUpload(f); }}
|
||||||
|
className="w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── Mandanten ── */}
|
{/* ── Mandanten ── */}
|
||||||
@@ -2509,6 +2667,9 @@ export default function AdminPage() {
|
|||||||
<Button size="sm" variant="outline" onClick={() => setTenantLdapDialogId(t.id)}>
|
<Button size="sm" variant="outline" onClick={() => setTenantLdapDialogId(t.id)}>
|
||||||
LDAP
|
LDAP
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openLogoDialog(t)}>
|
||||||
|
Logo
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}>
|
<Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}>
|
||||||
{t.active ? "Deaktivieren" : "Aktivieren"}
|
{t.active ? "Deaktivieren" : "Aktivieren"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2546,6 +2707,70 @@ export default function AdminPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Logo upload dialog */}
|
||||||
|
<Dialog open={logoDialogTenant !== null} onOpenChange={(open) => { if (!open) { setLogoDialogTenant(null); setLogoPreviewUrl(null); } }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Logo — {logoDialogTenant?.name}</DialogTitle>
|
||||||
|
<DialogDescription>Logo hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB).</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{logoPreviewUrl && (
|
||||||
|
<div className="flex items-center justify-center rounded border p-4 bg-muted/30">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={logoPreviewUrl} alt="Logo" className="max-h-32 max-w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!logoPreviewUrl && (
|
||||||
|
<div className="flex items-center justify-center rounded border p-8 bg-muted/30 text-sm text-muted-foreground">
|
||||||
|
Kein Logo gesetzt
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{logoError && <p className="text-sm text-destructive">{logoError}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||||
|
disabled={logoUploading}
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleLogoUpload(f); }}
|
||||||
|
/>
|
||||||
|
{logoPreviewUrl && (
|
||||||
|
<Button variant="destructive" size="sm" disabled={logoUploading} onClick={handleLogoDelete}>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setLogoDialogTenant(null); setLogoPreviewUrl(null); }}>Schließen</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Default credentials dialog after tenant creation */}
|
||||||
|
<Dialog open={tenantCredDialogOpen} onOpenChange={setTenantCredDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Mandant „{tenantCreatedName}“ erstellt</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Folgende Standard-Benutzer wurden angelegt. Passwörter bitte sofort notieren — sie werden nur einmalig angezeigt.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tenantCreatedUsers.map((u) => (
|
||||||
|
<div key={u.username} className="rounded border p-3 text-sm font-mono space-y-1">
|
||||||
|
<div><span className="text-muted-foreground">Benutzer:</span> {u.username}</div>
|
||||||
|
<div><span className="text-muted-foreground">Passwort:</span> {u.password}</div>
|
||||||
|
<div><span className="text-muted-foreground">Rolle:</span> {u.role}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setTenantCredDialogOpen(false)}>Verstanden</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Domain management dialog */}
|
{/* Domain management dialog */}
|
||||||
<Dialog open={domainDialogTenant !== null} onOpenChange={(open) => { if (!open) setDomainDialogTenant(null); }}>
|
<Dialog open={domainDialogTenant !== null} onOpenChange={(open) => { if (!open) setDomainDialogTenant(null); }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
+56
-2
@@ -693,6 +693,7 @@ export interface Tenant {
|
|||||||
user_count?: number;
|
user_count?: number;
|
||||||
ldap_enabled?: boolean;
|
ldap_enabled?: boolean;
|
||||||
ldap_url?: string;
|
ldap_url?: string;
|
||||||
|
has_logo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TenantDomain {
|
export interface TenantDomain {
|
||||||
@@ -710,8 +711,18 @@ export async function getTenantUsers(tenantId: number): Promise<User[]> {
|
|||||||
return request<User[]>(`/api/tenants/${tenantId}/users`);
|
return request<User[]>(`/api/tenants/${tenantId}/users`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTenant(name: string, slug: string): Promise<Tenant> {
|
export interface TenantDefaultUser {
|
||||||
return request<Tenant>("/api/tenants", {
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantResponse extends Tenant {
|
||||||
|
default_users: TenantDefaultUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTenant(name: string, slug: string): Promise<CreateTenantResponse> {
|
||||||
|
return request<CreateTenantResponse>("/api/tenants", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name, slug }),
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await request<void>(`/api/tenants/${tenantId}/logo`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// domain_admin: own tenant logo
|
||||||
|
export async function uploadMyTenantLogo(file: File): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await request<void>("/api/tenant/logo", { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
// ── PROJ-23: Pro-Mandant LDAP (tenant_ldap) ──────────────────────────────
|
// ── PROJ-23: Pro-Mandant LDAP (tenant_ldap) ──────────────────────────────
|
||||||
|
|
||||||
export interface TenantLDAPConfig extends LDAPConfig {
|
export interface TenantLDAPConfig extends LDAPConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user