import { useState, useEffect, useRef } from "react"; import { useBranding } from "../BrandingContext.js"; interface AuthProviderConfig { id: number; providerId: string; displayName: string; issuerUrl: string; internalBaseUrl: string | null; clientId: string; clientSecret: string; scopes: string; enabled: boolean; createdAt: string; updatedAt: string; } interface AuthProviderForm { providerId: string; displayName: string; issuerUrl: string; internalBaseUrl: string; clientId: string; clientSecret: string; scopes: string; } const REDACTED = "••••••••"; const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); interface CurrentUser { id: string; name: string; email: string; role: string; isSuperUser: boolean; } interface SettingsForm { businessName: string; primaryColor: string; accentColor: string; logoKey: string | null; logoUrl: string | null; logoBase64: string | null; // legacy logoMimeType: string | null; // legacy } export function SettingsPage() { const { refresh } = useBranding(); const [currentUser, setCurrentUser] = useState(null); // Auth provider state const [authConfig, setAuthConfig] = useState(null); const [authForm, setAuthForm] = useState({ providerId: "authentik", displayName: "", issuerUrl: "", internalBaseUrl: "", clientId: "", clientSecret: "", scopes: "openid profile email", }); const [authSecretTouched, setAuthSecretTouched] = useState(false); const [authLoaded, setAuthLoaded] = useState(false); const [authSaving, setAuthSaving] = useState(false); const [authMessage, setAuthMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [testingConnection, setTestingConnection] = useState(false); const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null); const [showInternalBaseUrl, setShowInternalBaseUrl] = useState(false); const [confirmReset, setConfirmReset] = useState(false); const [form, setForm] = useState({ businessName: "", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoKey: null, logoUrl: null, logoBase64: null, logoMimeType: null, }); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [loaded, setLoaded] = useState(false); const fileInputRef = useRef(null); useEffect(() => { fetch("/api/admin/settings") .then((r) => r.json()) .then(async (data) => { // The logo is now proxied through the API server so the browser // never receives an S3 URL — use the proxy path directly as the src. setForm({ businessName: data.businessName ?? "GroomBook", primaryColor: data.primaryColor ?? "#4f8a6f", accentColor: data.accentColor ?? "#8b7355", logoKey: data.logoKey ?? null, logoUrl: data.logoKey ? "/api/admin/settings/logo" : null, logoBase64: data.logoBase64 ?? null, logoMimeType: data.logoMimeType ?? null, }); setLoaded(true); }) .catch(() => setLoaded(true)); }, []); // Load current user (for isSuperUser check) and auth provider config useEffect(() => { Promise.all([ fetch("/api/staff/me").then((r) => r.json()).catch(() => null), fetch("/api/admin/auth-provider").then(async (r) => { if (r.ok) return r.json(); if (r.status === 404) return null; throw new Error(`HTTP ${r.status}`); }).catch(() => null), ]).then(([user, auth]) => { setCurrentUser(user as CurrentUser | null); if (auth) { setAuthConfig(auth as AuthProviderConfig); setAuthForm({ providerId: (auth as AuthProviderConfig).providerId, displayName: (auth as AuthProviderConfig).displayName, issuerUrl: (auth as AuthProviderConfig).issuerUrl, internalBaseUrl: (auth as AuthProviderConfig).internalBaseUrl ?? "", clientId: (auth as AuthProviderConfig).clientId, clientSecret: (auth as AuthProviderConfig).clientSecret, scopes: (auth as AuthProviderConfig).scopes, }); } setAuthLoaded(true); }); }, []); const handleLogoChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (file.size > 512 * 1024) { setMessage({ type: "error", text: "Logo must be under 512KB." }); return; } const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"]; if (!validTypes.includes(file.type)) { setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." }); return; } try { // Upload directly through the API server to avoid mixed-content issues // with pre-signed URLs that use the internal HTTP endpoint const formData = new FormData(); formData.append("file", file); const uploadRes = await fetch("/api/admin/settings/logo/upload", { method: "POST", body: formData, }); if (!uploadRes.ok) { const err = await uploadRes.json().catch(() => null); throw new Error(err?.error ?? "Failed to upload logo"); } const { logoKey } = await uploadRes.json(); setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null })); setMessage({ type: "success", text: "Logo uploaded." }); refresh(); } catch (err: unknown) { setMessage({ type: "error", text: err instanceof Error ? err.message : "Logo upload failed" }); } }; const handleSave = async () => { setSaving(true); setMessage(null); try { const res = await fetch("/api/admin/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form), }); if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.error ?? "Failed to save settings"); } setMessage({ type: "success", text: "Settings saved." }); refresh(); } catch (err: unknown) { setMessage({ type: "error", text: err instanceof Error ? err.message : "Save failed" }); } finally { setSaving(false); } }; // Auth provider handlers const handleTestConnection = async () => { setTestingConnection(true); setTestResult(null); try { const res = await fetch("/api/admin/auth-provider/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ issuerUrl: authForm.issuerUrl, ...(authForm.internalBaseUrl ? { internalBaseUrl: authForm.internalBaseUrl } : {}), }), }); const data = await res.json(); setTestResult(data); } catch { setTestResult({ ok: false, error: "Network error. Please try again." }); } finally { setTestingConnection(false); } }; const handleAuthSave = async () => { setAuthSaving(true); setAuthMessage(null); try { const payload: Record = { providerId: authForm.providerId, displayName: authForm.displayName, issuerUrl: authForm.issuerUrl, clientId: authForm.clientId, scopes: authForm.scopes, }; if (authForm.internalBaseUrl) { payload.internalBaseUrl = authForm.internalBaseUrl; } // Only send clientSecret if user changed it from the redacted value if (authSecretTouched) { payload.clientSecret = authForm.clientSecret; } const res = await fetch("/api/admin/auth-provider", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.error ?? "Failed to save auth provider"); } const saved = await res.json() as AuthProviderConfig; setAuthConfig(saved); setAuthForm({ providerId: saved.providerId, displayName: saved.displayName, issuerUrl: saved.issuerUrl, internalBaseUrl: saved.internalBaseUrl ?? "", clientId: saved.clientId, clientSecret: saved.clientSecret, scopes: saved.scopes, }); setAuthSecretTouched(false); setAuthMessage({ type: "success", text: "Auth provider saved." }); } catch (err: unknown) { setAuthMessage({ type: "error", text: err instanceof Error ? err.message : "Save failed" }); } finally { setAuthSaving(false); } }; const handleResetToEnvDefaults = async () => { if (!confirmReset) { setConfirmReset(true); return; } setConfirmReset(false); try { const res = await fetch("/api/admin/auth-provider", { method: "DELETE" }); if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.error ?? "Failed to reset auth provider"); } setAuthConfig(null); setAuthForm({ providerId: "authentik", displayName: "", issuerUrl: "", internalBaseUrl: "", clientId: "", clientSecret: "", scopes: "openid profile email", }); setAuthSecretTouched(false); setAuthMessage({ type: "success", text: "Auth provider reset to environment defaults." }); } catch (err: unknown) { setAuthMessage({ type: "error", text: err instanceof Error ? err.message : "Reset failed" }); } }; if (!loaded) return

Loading settings...

; const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null); return (

Branding & Appearance

Customize your business name, logo, and color scheme.

{/* Business Name */}
setForm((f) => ({ ...f, businessName: e.target.value }))} style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, }} />
{/* Logo Upload */}
{logoSrc ? ( Logo preview ) : (
No logo
)}
{logoSrc && ( )}

PNG, SVG, JPEG, or WebP. Max 512KB.

{/* Color Pickers */}
setForm((f) => ({ ...f, primaryColor: e.target.value }))} style={{ width: 40, height: 40, border: "none", cursor: "pointer" }} /> setForm((f) => ({ ...f, primaryColor: e.target.value }))} style={{ width: 90, padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 13, fontFamily: "monospace", }} />
setForm((f) => ({ ...f, accentColor: e.target.value }))} style={{ width: 40, height: 40, border: "none", cursor: "pointer" }} /> setForm((f) => ({ ...f, accentColor: e.target.value }))} style={{ width: 90, padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 13, fontFamily: "monospace", }} />
{/* Preview */}

Preview

{logoSrc && ( )} {form.businessName} Button Accent
{/* Save */} {message && (
{message.text}
)} {/* Auth Provider Section — super users only */} {currentUser?.isSuperUser && ( <>

Authentication Provider

Configure the SSO provider for sign-in. Changes require a service restart.

{/* Warning banner */}
⚠️ Changing auth settings will require a service restart. Active sessions will be preserved.
{/* Environment config banner */} {!authConfig && authLoaded && (
Currently using environment configuration (no DB config set).
)} {!authLoaded &&

Loading auth provider...

} {authLoaded && ( <>
{/* Provider ID */}
setAuthForm((f) => ({ ...f, providerId: e.target.value }))} placeholder="e.g. authentik, okta" style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }} />
{/* Display Name */}
setAuthForm((f) => ({ ...f, displayName: e.target.value }))} placeholder="e.g. Company SSO" style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }} />
{/* Issuer URL */}
setAuthForm((f) => ({ ...f, issuerUrl: e.target.value }))} placeholder="https://your-idp.example.com" style={{ flex: 1, padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }} />
{/* Test result */} {testResult && (
{testResult.ok ? "✓ Connection successful" : `✗ ${testResult.error}`}
)} {/* Internal Base URL — collapsible */}
{showInternalBaseUrl && ( setAuthForm((f) => ({ ...f, internalBaseUrl: e.target.value }))} placeholder="http://host.docker.internal:9080" style={{ marginTop: 4, width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }} /> )}
{/* Client ID */}
setAuthForm((f) => ({ ...f, clientId: e.target.value }))} style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }} />
{/* Client Secret */}
{ setAuthSecretTouched(true); setAuthForm((f) => ({ ...f, clientSecret: e.target.value })); }} placeholder={authConfig ? "(unchanged)" : "Required"} style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }} /> {authConfig && !authSecretTouched && (

Leave blank to keep existing secret.

)}
{/* Scopes */}
setAuthForm((f) => ({ ...f, scopes: e.target.value }))} style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }} />
{/* Auth messages */} {authMessage && (
{authMessage.text}
)} {/* Action buttons */}
{confirmReset && ( )}
)} )}
); }