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:
sysops
2026-03-20 03:15:34 +01:00
parent cd2781bdff
commit 30c6694dff
4 changed files with 595 additions and 11 deletions
+51 -7
View File
@@ -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,