'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; import { BRANDING_OVERRIDE_KEYS, parseDomainBranding, type BrandingOverrideKey, type DomainBrandingEntry, } from '@/lib/admin/domain-branding'; interface ConfigEntry { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean; } const IMAGE_FIELDS = [ { key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' }, { key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' }, { key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' }, { key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' }, { key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' }, ] as const; const TEXT_FIELDS = [ { key: 'loginCompanyName', label: 'Company Name' }, { key: 'loginImprintUrl', label: 'Imprint URL' }, { key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' }, { key: 'loginWebsiteUrl', label: 'Company Website URL' }, ] as const; const PWA_IMAGE_FIELDS = [ { key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' }, ] as const; const PWA_TEXT_FIELDS = [ { key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' }, { key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' }, ] as const; const PWA_COLOR_FIELDS = [ { key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' }, { key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' }, ] as const; // Accepts exact hosts and one-level wildcards (e.g. *.example.com). const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; // Tighter rule for uploads: wildcards can only point to externally-hosted // URLs, since we'd have no concrete subdomain to serve a file from. const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; export function BrandingTab() { const [config, setConfig] = useState>({}); const [edits, setEdits] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [selectedHost, setSelectedHost] = useState(null); const [addingHost, setAddingHost] = useState(false); const [newHostInput, setNewHostInput] = useState(''); const [newHostError, setNewHostError] = useState(null); const fileInputRefs = useRef>({}); useEffect(() => { fetchConfig(); }, []); const domainEntries = useMemo( () => parseDomainBranding(config['domainBranding']?.value), [config], ); // Drop selection if the host disappeared from the config (e.g. concurrent edit). useEffect(() => { if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) { setSelectedHost(null); setEdits({}); } }, [domainEntries, selectedHost]); async function fetchConfig() { setLoading(true); const res = await apiFetch('/api/admin/config'); if (res.ok) setConfig(await res.json()); setLoading(false); } function selectedEntry(): DomainBrandingEntry | null { if (!selectedHost) return null; return domainEntries.find(e => e.host === selectedHost) ?? null; } function handleChange(key: string, value: string) { setEdits(prev => ({ ...prev, [key]: value })); setMessage(null); } function currentValue(key: string): string { if (key in edits) return edits[key]; if (selectedHost) { const entry = selectedEntry(); return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? ''; } return (config[key]?.value as string) ?? ''; } function isOverriddenInScope(key: string): boolean { if (selectedHost) { const entry = selectedEntry(); const v = entry?.[key as BrandingOverrideKey]; return typeof v === 'string' && v.length > 0; } return config[key]?.source === 'admin'; } const isUploadedFile = (key: string): boolean => { const val = currentValue(key); return val.startsWith('/api/admin/branding/'); }; function buildUpdatedDomainBranding(merge: Record): DomainBrandingEntry[] { if (!selectedHost) return domainEntries; const next = domainEntries.slice(); const idx = next.findIndex(e => e.host === selectedHost); const base: DomainBrandingEntry = idx === -1 ? { host: selectedHost } : { ...next[idx] }; const writable = base as unknown as Record; for (const [key, value] of Object.entries(merge)) { if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue; if (typeof value === 'string' && value.length > 0) { writable[key] = value; } else { delete writable[key]; } } if (idx === -1) next.push(base); else next[idx] = base; return next; } async function handleSave() { if (Object.keys(edits).length === 0) return; setSaving(true); setMessage(null); const payload = selectedHost ? { domainBranding: buildUpdatedDomainBranding(edits) } : edits; const res = await apiFetch('/api/admin/config', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (res.ok) { setMessage({ type: 'success', text: selectedHost ? `Branding for ${selectedHost} updated. Changes visible on next page load.` : 'Branding updated. Changes visible on next page load.', }); setEdits({}); await fetchConfig(); } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Failed to save' }); } setSaving(false); } async function handleUpload(slot: string, file: File) { if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) { setMessage({ type: 'error', text: 'Wildcard hosts cannot upload files. Enter a URL instead.', }); return; } setUploading(slot); setMessage(null); const formData = new FormData(); formData.append('file', file); formData.append('slot', slot); if (selectedHost) formData.append('host', selectedHost); const res = await apiFetch('/api/admin/branding', { method: 'POST', body: formData, }); if (res.ok) { const data = await res.json(); setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` }); setEdits(prev => { const next = { ...prev }; delete next[slot]; return next; }); // Refresh from server so domainBranding entries reflect the upload. await fetchConfig(); void data; } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Upload failed' }); } setUploading(null); } async function handleDeleteUpload(slot: string) { setMessage(null); const body: { slot: string; host?: string } = { slot }; if (selectedHost) body.host = selectedHost; const res = await apiFetch('/api/admin/branding', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (res.ok) { setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' }); setEdits(prev => { const next = { ...prev }; delete next[slot]; return next; }); await fetchConfig(); } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Failed to remove' }); } } async function handleRevert(key: string) { if (selectedHost) { // Domain scope: drop the field from the entry and PATCH the array. const updated = buildUpdatedDomainBranding({ [key]: '' }); const res = await apiFetch('/api/admin/config', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domainBranding: updated }), }); if (res.ok) { setEdits(prev => { const next = { ...prev }; delete next[key]; return next; }); await fetchConfig(); } return; } // Default scope: revert via DELETE /api/admin/config const res = await apiFetch('/api/admin/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), }); if (res.ok) { setEdits(prev => { const next = { ...prev }; delete next[key]; return next; }); await fetchConfig(); } } async function handleAddDomain() { const host = newHostInput.trim().toLowerCase().replace(/\.+$/, ''); if (!host) { setNewHostError('Enter a hostname'); return; } if (!HOST_RE.test(host)) { setNewHostError('Invalid hostname. Use foo.example.com or *.example.com'); return; } if (domainEntries.some(e => e.host === host)) { setNewHostError('A branding entry for this host already exists'); return; } setNewHostError(null); const next: DomainBrandingEntry[] = [...domainEntries, { host }]; const res = await apiFetch('/api/admin/config', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domainBranding: next }), }); if (res.ok) { setNewHostInput(''); setAddingHost(false); setSelectedHost(host); setEdits({}); await fetchConfig(); } else { const data = await res.json(); setNewHostError(data.error || 'Failed to add domain'); } } async function handleDeleteDomain() { if (!selectedHost) return; if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) { return; } const next = domainEntries.filter(e => e.host !== selectedHost); const res = await apiFetch('/api/admin/config', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domainBranding: next }), }); if (res.ok) { setSelectedHost(null); setEdits({}); await fetchConfig(); setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` }); } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Failed to remove domain' }); } } function handleScopeChange(host: string | null) { if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return; setSelectedHost(host); setEdits({}); setMessage(null); } const hasEdits = Object.keys(edits).length > 0; const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost); if (loading) { return
Loading...
; } return (

Branding

Customize logos, favicon, and company information

{hasEdits && ( )}
{/* Scope picker */}

Scope

{domainEntries.map(entry => ( ))} {!addingHost && ( )}
{addingHost && (
{ setNewHostInput(e.target.value); setNewHostError(null); }} onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }} placeholder="mail.example.com or *.example.com" className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> {newHostError && {newHostError}}
)} {selectedHost ? (

Editing overrides for {selectedHost}. Unset fields fall back to the Default values. {wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}

) : (

Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.

)}
{message && (
{message.text}
)}

Images & Logos

Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)

{IMAGE_FIELDS.map(field => (
{isOverriddenInScope(field.key) && ( {isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'} )}
handleChange(field.key, e.target.value)} placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'} className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> { fileInputRefs.current[field.key] = el; }} type="file" accept={field.accept} className="hidden" onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(field.key, file); e.target.value = ''; }} /> {isUploadedFile(field.key) && ( )} {isOverriddenInScope(field.key) && !isUploadedFile(field.key) && ( )}
{currentValue(field.key) && (
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
))}

Progressive Web App

Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.

{PWA_IMAGE_FIELDS.map(field => (
{isOverriddenInScope(field.key) && ( {isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'} )}
handleChange(field.key, e.target.value)} placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'} className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> { fileInputRefs.current[field.key] = el; }} type="file" accept={field.accept} className="hidden" onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(field.key, file); e.target.value = ''; }} /> {isUploadedFile(field.key) && ( )} {isOverriddenInScope(field.key) && !isUploadedFile(field.key) && ( )}
{currentValue(field.key) && (
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
))} {PWA_TEXT_FIELDS.map(field => (
{isOverriddenInScope(field.key) && ( {selectedHost ? 'domain' : 'admin'} )}
handleChange(field.key, e.target.value)} placeholder={field.placeholder} className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> {isOverriddenInScope(field.key) && ( )}
))} {PWA_COLOR_FIELDS.map(field => { const value = currentValue(field.key) || field.defaultValue; return (
{isOverriddenInScope(field.key) && ( {selectedHost ? 'domain' : 'admin'} )}
handleChange(field.key, e.target.value)} className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5" title="Pick a color" /> handleChange(field.key, e.target.value)} placeholder={field.defaultValue} className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> {isOverriddenInScope(field.key) && ( )}
); })}

Company Information

{TEXT_FIELDS.map(field => (
{isOverriddenInScope(field.key) && ( {selectedHost ? 'domain' : 'admin'} )}
handleChange(field.key, e.target.value)} placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'} className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> {isOverriddenInScope(field.key) && ( )}
))}
); }