'use client'; import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; type State = 'bootstrap' | 'configured' | 'env-managed'; interface StatusResponse { state: State; authenticated: boolean; readOnly: boolean; partialConfig: Record | null; } interface JmapServerRow { id: string; label: string; url: string; /** comma-separated, parsed before save */ domains: string; } interface WizardConfig { // Server appName: string; jmapServerUrl: string; stalwartFeaturesEnabled: boolean; jmapServers: JmapServerRow[]; jmapServerAutoPickByDomain: boolean; // Auth oauthEnabled: boolean; oauthOnly: boolean; oauthClientId: string; oauthClientSecret: string; oauthIssuerUrl: string; // Security sessionSecret: string; settingsSyncEnabled: boolean; // Logging logFormat: 'text' | 'json'; logLevel: 'error' | 'warn' | 'info' | 'debug'; // Branding faviconUrl: string; appLogoLightUrl: string; appLogoDarkUrl: string; loginLogoLightUrl: string; loginLogoDarkUrl: string; loginCompanyName: string; loginImprintUrl: string; loginPrivacyPolicyUrl: string; loginWebsiteUrl: string; } const EMPTY_CONFIG: WizardConfig = { appName: 'Bulwark Webmail', jmapServerUrl: '', stalwartFeaturesEnabled: true, jmapServers: [], jmapServerAutoPickByDomain: false, oauthEnabled: false, oauthOnly: false, oauthClientId: '', oauthClientSecret: '', oauthIssuerUrl: '', sessionSecret: '', settingsSyncEnabled: true, logFormat: 'text', logLevel: 'info', faviconUrl: '', appLogoLightUrl: '', appLogoDarkUrl: '', loginLogoLightUrl: '', loginLogoDarkUrl: '', loginCompanyName: '', loginImprintUrl: '', loginPrivacyPolicyUrl: '', loginWebsiteUrl: '', }; const STEPS = [ { id: 'welcome', label: 'Welcome' }, { id: 'server', label: 'Server' }, { id: 'auth', label: 'Auth' }, { id: 'security', label: 'Security' }, { id: 'logging', label: 'Logging' }, { id: 'branding', label: 'Branding' }, { id: 'review', label: 'Review' }, ] as const; export default function SetupWizardPage() { const router = useRouter(); const searchParams = useSearchParams(); const [bootstrapping, setBootstrapping] = useState(true); const [error, setError] = useState(null); const [state, setState] = useState('bootstrap'); const [authenticated, setAuthenticated] = useState(false); const [readOnly, setReadOnly] = useState(false); const [config, setConfig] = useState(EMPTY_CONFIG); const [stepIndex, setStepIndex] = useState(0); const [completed, setCompleted] = useState(false); // Detect synchronously on first client render so we don't flash the loading // screen before the warning appears. The session cookie is set with the // Secure flag in production, which browsers silently drop over plain HTTP - // every subsequent step call then 401s with "Wizard session required". const [insecureContext] = useState(detectInsecureContext); // ─── Initial status load ──────────────────────────────────────────────── useEffect(() => { // Skip the status fetch entirely when we're going to render the HTTPS // notice - the wizard cookie can't survive an HTTP origin anyway. if (insecureContext) { setBootstrapping(false); return; } let cancelled = false; (async () => { try { const res = await apiFetch('/api/setup/status', { cache: 'no-store' }); const data = (await res.json()) as StatusResponse; if (cancelled) return; setState(data.state); setReadOnly(data.readOnly); if (data.state === 'configured' || data.state === 'env-managed') { // Wizard not active - bounce to login. Middleware will 404 us // before we get here in practice, but defensive. router.replace('/'); return; } setAuthenticated(data.authenticated); if (data.partialConfig) { setConfig((prev) => mergePartial(prev, data.partialConfig!)); // If auth is OK and we already have a JMAP URL persisted, jump // ahead to the next unfilled step. if (data.authenticated && data.partialConfig.jmapServerUrl) { setStepIndex(2); } else if (data.authenticated) { setStepIndex(1); } } } catch (e) { if (!cancelled) setError(humanError(e)); } finally { if (!cancelled) setBootstrapping(false); } })(); return () => { cancelled = true; }; }, [router, insecureContext]); // ─── Token submit (welcome step) ──────────────────────────────────────── async function submitToken(token: string) { setError(null); const res = await apiFetch('/api/setup/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error ?? `Token rejected (HTTP ${res.status})`); } setAuthenticated(true); setStepIndex(1); } // ─── Step persistence ─────────────────────────────────────────────────── async function saveStep(step: string, values: Record) { const res = await apiFetch('/api/setup/step', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ step, values }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error ?? `Step save failed (HTTP ${res.status})`); } } // ─── Render shell ─────────────────────────────────────────────────────── if (insecureContext) { return ; } if (bootstrapping) { return

Loading…

; } if (completed) { return ; } if (state !== 'bootstrap') { return ; } if (readOnly) { return (

Configuration is read-only

The config volume is mounted read-only or ADMIN_CONFIG_READONLY is set. Remount it read-write or unset that variable, then restart the container.

); } return (
{error && setError(null)} />} {!authenticated ? ( { try { await submitToken(t); } catch (e) { setError(humanError(e)); } }} /> ) : ( { try { await saveStep(step, values); setStepIndex((i) => Math.min(i + 1, STEPS.length - 1)); } catch (e) { const msg = humanError(e); setError(msg); // Session expired mid-flow - kick the user back to the // welcome step so they can re-enter the token without // having to refresh. if (/wizard session required/i.test(msg)) { setAuthenticated(false); setStepIndex(0); } } }} onBack={() => setStepIndex((i) => Math.max(i - 1, 1))} onFinish={() => { setCompleted(true); // Hard navigation after a beat - gives the user a moment // to see the success screen and works around any router // edge cases that swallow client-side replaces after the // setupComplete flag flips. setTimeout(() => { window.location.assign('/admin/login'); }, 1500); }} /> )}
); } // ─── Layout helpers ─────────────────────────────────────────────────────── function Header() { return (

Bulwark Webmail Setup

Configure your webmail instance from the browser.

); } function ProgressBar({ stepIndex }: { stepIndex: number }) { return (
{STEPS.map((step, i) => (
{step.label}
))}
); } function CompletedScreen() { return (

You're all set!

Bulwark Webmail is configured and ready to use.

Taking you to the admin dashboard…

); } function InsecureContextScreen() { const httpsUrl = typeof window !== 'undefined' ? `https://${window.location.host}${window.location.pathname}${window.location.search}` : ''; return (

HTTPS required for setup

The setup wizard signs you in with a Secure cookie, which your browser will only accept over HTTPS. Loading this page over plain HTTP causes every step to fail with Wizard session required.

To continue, do one of the following:

  • Reach this page over HTTPS (terminate TLS on the container or a reverse proxy in front of it).
  • If you already have a reverse proxy, make sure it forwards to the webmail and forwards the X-Forwarded-Proto header.
{httpsUrl && ( Open over HTTPS )}
); } function AlreadyConfiguredScreen() { return (

Setup is already complete

Bulwark Webmail is configured. Sign in to continue.

); } function CenteredCard({ children }: { children: ReactNode }) { return (
{children}
); } function ErrorBanner({ error, onDismiss }: { error: string; onDismiss: () => void }) { return (

{friendlyError(error)}

); } /** * Translate raw API error strings into user-facing copy. Matches the friendly * tone of the JMAP probe cards. */ function friendlyError(raw: string): string { const lower = raw.toLowerCase(); if (lower.includes('invalid or expired token')) { return "That setup token isn't valid anymore. Restart the container to get a fresh one from the logs."; } if (lower.includes('wizard session required')) { return 'Your wizard session expired. Paste the setup token again to continue.'; } if (lower.includes('token required')) { return 'Paste the setup token printed in the container logs to continue.'; } if (lower.includes('setup is not active')) { return 'Setup has already finished. Reload to sign in.'; } return raw; } // ─── Welcome / token step ──────────────────────────────────────────────── function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmit: (t: string) => Promise }) { const [token, setToken] = useState(tokenFromUrl); const [submitting, setSubmitting] = useState(false); // Auto-submit if token came in via URL. useEffect(() => { if (tokenFromUrl && !submitting) { setSubmitting(true); onSubmit(tokenFromUrl).finally(() => setSubmitting(false)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tokenFromUrl]); async function handle(e: FormEvent) { e.preventDefault(); setSubmitting(true); try { await onSubmit(token.trim()); } finally { setSubmitting(false); } } return (

Welcome

Paste the setup token printed in the container logs to continue. The token expires after 1 hour.

setToken(v)} autoFocus required placeholder="32-byte hex token" /> {submitting ? 'Verifying…' : 'Continue'}
); } // ─── Step router ───────────────────────────────────────────────────────── interface StepProps { stepIndex: number; config: WizardConfig; setConfig: React.Dispatch>; onNext: (step: string, values: Record) => Promise; onBack: () => void; onFinish: () => void; } function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }: StepProps) { switch (stepIndex) { case 1: return ; case 2: return ; case 3: return ; case 4: return ; case 5: return ; case 6: return ; default: return

Loading…

; } } // ─── Server step ───────────────────────────────────────────────────────── type ProbeStatus = 'jmap_detected' | 'reachable_no_jmap' | 'unreachable' | 'invalid_url'; function ServerStep({ config, setConfig, onNext }: Pick) { const [submitting, setSubmitting] = useState(false); const [probe, setProbe] = useState<{ status: ProbeStatus; message: string; url: string } | null>(null); const [probing, setProbing] = useState(false); // When the server is reachable but isn't a JMAP endpoint, the wizard // shows a "looks wrong, are you sure?" inline confirmation. The flag // resets every time the URL changes. const [confirmedNonJmap, setConfirmedNonJmap] = useState(false); async function testJmap(): Promise<{ status: ProbeStatus; message: string; url: string } | null> { setProbe(null); setProbing(true); setConfirmedNonJmap(false); try { const res = await apiFetch('/api/setup/test-jmap', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: config.jmapServerUrl }), }); const data = await res.json(); let entry: { status: ProbeStatus; message: string; url: string }; if (data.status === 'jmap_detected') { entry = { status: 'jmap_detected', message: 'Connected - this looks like a JMAP server.', url: config.jmapServerUrl }; } else if (data.status === 'reachable_no_jmap') { entry = { status: 'reachable_no_jmap', message: "We reached the server, but it doesn't look like a JMAP endpoint.", url: config.jmapServerUrl }; } else if (data.status === 'invalid_url') { entry = { status: 'invalid_url', message: data.message ?? 'That URL is not valid. Make sure it starts with http:// or https://.', url: config.jmapServerUrl }; } else { entry = { status: 'unreachable', message: data.message ?? "Couldn't connect to that address. Double-check the URL and that the server is online.", url: config.jmapServerUrl }; } setProbe(entry); return entry; } catch (e) { const entry = { status: 'unreachable' as ProbeStatus, message: humanError(e), url: config.jmapServerUrl }; setProbe(entry); return entry; } finally { setProbing(false); } } const [showAdditional, setShowAdditional] = useState(config.jmapServers.length > 0); function updateRow(index: number, patch: Partial) { setConfig({ ...config, jmapServers: config.jmapServers.map((row, i) => (i === index ? { ...row, ...patch } : row)), }); } function addRow() { setConfig({ ...config, jmapServers: [...config.jmapServers, { id: '', label: '', url: '', domains: '' }], }); setShowAdditional(true); } function removeRow(index: number) { setConfig({ ...config, jmapServers: config.jmapServers.filter((_, i) => i !== index), }); } // Validate the multi-server rows: each must have a unique id matching the // schema, a usable URL, and no collision with the primary server. const rowErrors: string[] = []; const seenIds = new Set(); for (let i = 0; i < config.jmapServers.length; i++) { const r = config.jmapServers[i]; const id = r.id.trim(); if (!id) { rowErrors.push(`Server #${i + 1}: id is required`); } else if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) { rowErrors.push(`Server #${i + 1}: id must be alphanumeric (with - or _), starting with a letter or digit`); } else if (seenIds.has(id)) { rowErrors.push(`Server #${i + 1}: id "${id}" is duplicated`); } else { seenIds.add(id); } const url = r.url.trim(); if (!url) { rowErrors.push(`Server #${i + 1}: url is required`); } else if (!/^https?:\/\//i.test(url)) { rowErrors.push(`Server #${i + 1}: url must start with http:// or https://`); } } const hasRowErrors = rowErrors.length > 0; async function handle(e: FormEvent) { e.preventDefault(); if (hasRowErrors) return; setSubmitting(true); try { // Auto-probe the URL on Next so the operator can't accidentally // skip past a wrong URL. If the URL changed since the last probe, // re-run; otherwise reuse the cached result. let result = probe && probe.url === config.jmapServerUrl ? probe : null; if (!result) { result = await testJmap(); } if (!result) return; // Hard-fail on these - no "are you sure" since they can't be right. if (result.status === 'invalid_url' || result.status === 'unreachable') { return; } // Soft warning: server responded but it's not a JMAP endpoint at the // standard paths. Could be legitimate (reverse proxy routing) so we // ask for explicit confirmation rather than blocking. if (result.status === 'reachable_no_jmap' && !confirmedNonJmap) { return; } await onNext('server', { appName: config.appName, jmapServerUrl: config.jmapServerUrl, stalwartFeaturesEnabled: config.stalwartFeaturesEnabled, // The API route runs parseJmapServers on this; we pre-canonicalize // here so the round-trip is clean. jmapServers: rowsToCanonical(config.jmapServers), jmapServerAutoPickByDomain: config.jmapServerAutoPickByDomain, }); } finally { setSubmitting(false); } } return (
setConfig({ ...config, appName: v })} required />
{ setConfig({ ...config, jmapServerUrl: v }); // Any URL change invalidates the previous probe result. if (probe && probe.url !== v) { setProbe(null); setConfirmedNonJmap(false); } }} required placeholder="https://" type="url" />
{isInsecureHttpUrl(config.jmapServerUrl) && (

This URL uses plain HTTP.

Passwords and email contents will travel unencrypted between users and your server. Use https:// in production - terminate TLS on the mail server or a reverse proxy in front of it.

)} {probe && probe.url === config.jmapServerUrl && ( probe.status === 'jmap_detected' ? (

{probe.message}

) : probe.status === 'reachable_no_jmap' ? (

{probe.message}

This can happen when a reverse proxy routes JMAP separately on the same domain. Otherwise, it usually means the URL is wrong.

) : (

{probe.message}

) )}
{/* Additional servers (optional) */}
Additional JMAP servers

Optional. Surface multiple servers in the login dropdown - useful for hosts running several Stalwart instances.

{showAdditional && (
{config.jmapServers.map((row, i) => (
Server #{i + 1}
updateRow(i, { id: v })} placeholder="eu-1" required /> updateRow(i, { label: v })} placeholder="Europe (primary)" />
updateRow(i, { url: v })} placeholder="https://" type="url" required /> updateRow(i, { domains: v })} placeholder="example.com, mail.example.com" />
))} {config.jmapServers.length > 0 && ( setConfig({ ...config, jmapServerAutoPickByDomain: v })} label="Auto-pick server by email domain" hint="When a user types their email, automatically select the matching server from the list above." /> )} {hasRowErrors && (
    {rowErrors.map((err, i) => (
  • {err}
  • ))}
)}
)}
setConfig({ ...config, stalwartFeaturesEnabled: v })} label="Enable Stalwart-specific features" hint="Adds password change and Sieve filter management. Safe to enable on non-Stalwart servers." />
{submitting ? 'Saving…' : probing ? 'Testing…' : 'Next'}
); } // ─── Auth step ─────────────────────────────────────────────────────────── function AuthStep({ config, setConfig, onNext, onBack }: Pick) { const [submitting, setSubmitting] = useState(false); async function handle(e: FormEvent) { e.preventDefault(); setSubmitting(true); try { const values: Partial = { oauthEnabled: config.oauthEnabled }; if (config.oauthEnabled) { values.oauthOnly = config.oauthOnly; values.oauthClientId = config.oauthClientId; values.oauthIssuerUrl = config.oauthIssuerUrl; if (config.oauthClientSecret) { values.oauthClientSecret = config.oauthClientSecret; } } await onNext('auth', values); } finally { setSubmitting(false); } } return (
setConfig({ ...config, oauthEnabled: v })} label="Enable OAuth2 / OpenID Connect" /> {config.oauthEnabled && ( <> setConfig({ ...config, oauthOnly: v })} label="OAuth-only mode (hide password form)" /> setConfig({ ...config, oauthClientId: v })} required /> setConfig({ ...config, oauthClientSecret: v })} type="password" placeholder="paste secret" /> setConfig({ ...config, oauthIssuerUrl: v })} type="url" /> )}
Back {submitting ? 'Saving…' : 'Next'}
); } // ─── Security step ─────────────────────────────────────────────────────── function generateSessionSecret(): string { // 32 random bytes, base64-encoded - same shape as `openssl rand -base64 32`. const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin); } function SecurityStep({ config, setConfig, onNext, onBack }: Pick) { const [submitting, setSubmitting] = useState(false); const [reveal, setReveal] = useState(false); const [customize, setCustomize] = useState(false); // Auto-generate on first render so the operator doesn't have to click a // button for the recommended path. They can still regenerate or paste // their own via the "Customize" toggle. useEffect(() => { if (!config.sessionSecret) { setConfig((prev) => ({ ...prev, sessionSecret: generateSessionSecret() })); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function handle(e: FormEvent) { e.preventDefault(); setSubmitting(true); try { const values: Partial = { settingsSyncEnabled: config.settingsSyncEnabled, }; if (config.sessionSecret) values.sessionSecret = config.sessionSecret; await onNext('security', values); } finally { setSubmitting(false); } } return (
Session secret generated

A 32-byte secret was created for you. You only need to change this if you have a specific reason.

{customize && (
setConfig({ ...config, sessionSecret: v })} type={reveal ? 'text' : 'password'} />
)} setConfig({ ...config, settingsSyncEnabled: v })} label="Sync user settings across devices" hint="Stores user preferences server-side, encrypted with the session secret." disabled={!config.sessionSecret} />
Back {submitting ? 'Saving…' : 'Next'}
); } // ─── Logging step ──────────────────────────────────────────────────────── function LoggingStep({ config, setConfig, onNext, onBack }: Pick) { const [submitting, setSubmitting] = useState(false); async function handle(e: FormEvent) { e.preventDefault(); setSubmitting(true); try { await onNext('logging', { logFormat: config.logFormat, logLevel: config.logLevel }); } finally { setSubmitting(false); } } return (
setConfig({ ...config, logLevel: v as WizardConfig['logLevel'] })} options={[ { value: 'error', label: 'error' }, { value: 'warn', label: 'warn' }, { value: 'info', label: 'info (recommended)' }, { value: 'debug', label: 'debug' }, ]} />
Back {submitting ? 'Saving…' : 'Next'}
); } // ─── Branding step ─────────────────────────────────────────────────────── type BrandingSlot = | 'faviconUrl' | 'appLogoLightUrl' | 'appLogoDarkUrl' | 'loginLogoLightUrl' | 'loginLogoDarkUrl'; function BrandingStep({ config, setConfig, onNext, onBack }: Pick) { const [submitting, setSubmitting] = useState(false); async function handle(e: FormEvent) { e.preventDefault(); setSubmitting(true); try { // Only send fields with a value. Empty strings would create an admin // override that shadows the system default and suppress the bundled // Bulwark logo on the login page. const allFields = { faviconUrl: config.faviconUrl, appLogoLightUrl: config.appLogoLightUrl, appLogoDarkUrl: config.appLogoDarkUrl, loginLogoLightUrl: config.loginLogoLightUrl, loginLogoDarkUrl: config.loginLogoDarkUrl, loginCompanyName: config.loginCompanyName, loginImprintUrl: config.loginImprintUrl, loginPrivacyPolicyUrl: config.loginPrivacyPolicyUrl, loginWebsiteUrl: config.loginWebsiteUrl, }; const values: Record = {}; for (const [k, v] of Object.entries(allFields)) { if (v.trim() !== '') values[k] = v.trim(); } await onNext('branding', values); } finally { setSubmitting(false); } } return (
setConfig({ ...config, loginCompanyName: v })} />
setConfig({ ...config, faviconUrl: v })} /> setConfig({ ...config, loginLogoLightUrl: v })} /> setConfig({ ...config, loginLogoDarkUrl: v })} previewBg="dark" /> setConfig({ ...config, appLogoLightUrl: v })} /> setConfig({ ...config, appLogoDarkUrl: v })} previewBg="dark" />
setConfig({ ...config, loginWebsiteUrl: v })} type="url" /> setConfig({ ...config, loginImprintUrl: v })} type="url" /> setConfig({ ...config, loginPrivacyPolicyUrl: v })} type="url" />
Back {submitting ? 'Saving…' : 'Next'}
); } /** * One branding asset slot: shows a thumbnail preview if a value is set, * a file picker (uploads to /api/setup/branding), and a URL field for * operators who'd rather paste a link. Upload and URL are mutually * compatible - the URL field always reflects the persisted value. */ function BrandingAsset({ label, hint, slot, value, onChange, previewBg = 'light', }: { label: string; hint?: string; slot: BrandingSlot; value: string; onChange: (v: string) => void; previewBg?: 'light' | 'dark'; }) { const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [showUrlField, setShowUrlField] = useState(false); const [dragOver, setDragOver] = useState(false); async function handleFile(file: File) { setUploadError(null); setUploading(true); try { const fd = new FormData(); fd.append('file', file); fd.append('slot', slot); const res = await apiFetch('/api/setup/branding', { method: 'POST', body: fd, }); const data = await res.json(); if (!res.ok) { setUploadError(data?.error ?? `Upload failed (HTTP ${res.status})`); return; } onChange(data.url); } catch (e) { setUploadError(humanError(e)); } finally { setUploading(false); } } async function clearAsset() { setUploadError(null); try { await apiFetch('/api/setup/branding', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slot }), }).catch(() => null); } finally { onChange(''); } } const previewClasses = 'shrink-0 w-16 h-16 rounded-md border border-border flex items-center justify-center overflow-hidden transition-colors ' + (previewBg === 'dark' ? 'bg-zinc-900' : 'bg-muted/40') + (dragOver ? ' ring-2 ring-primary border-primary' : ''); return (
{label}
{value && ( )}
{hint &&

{hint}

}
{uploading ? ( Uploading… ) : value ? ( {value.startsWith('/api/') ? 'Uploaded file' : value} ) : ( SVG, PNG, JPEG, WebP or ICO · max 2 MB )}
{showUrlField && (
)} {uploadError && (

{uploadError}

)}
); } // ─── Review / finish step ───────────────────────────────────────────────── function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) { const [adminPassword, setAdminPassword] = useState(''); const [adminConfirm, setAdminConfirm] = useState(''); const [lockConfig, setLockConfig] = useState(false); const [submitting, setSubmitting] = useState(false); const [localError, setLocalError] = useState(null); async function handle(e: FormEvent) { e.preventDefault(); setLocalError(null); if (adminPassword.length < 8) { setLocalError('Admin password must be at least 8 characters.'); return; } if (adminPassword !== adminConfirm) { setLocalError('Passwords do not match.'); return; } setSubmitting(true); try { const res = await apiFetch('/api/setup/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ adminPassword, lockConfig }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); setLocalError(data.error ?? `Finish failed (HTTP ${res.status})`); return; } onFinish(); } catch (e) { setLocalError(humanError(e)); } finally { setSubmitting(false); } } const passwordsMatch = adminConfirm.length > 0 && adminPassword === adminConfirm; const passwordTooShort = adminPassword.length > 0 && adminPassword.length < 8; const canSubmit = !submitting && adminPassword.length >= 8 && passwordsMatch; return (
{/* Summary card with grouped sections */}
} title="Server"> {config.jmapServers.length > 0 && ( )} } title="Authentication"> {config.oauthEnabled && config.oauthClientId && ( )} } title="Security"> } title="Logging"> } title="Branding"> {config.loginCompanyName && ( )}
{/* Admin password card */}
Choose an admin password

You'll use this to sign in at /admin. Minimum 8 characters.

{passwordTooShort && (

At least 8 characters.

)}
{adminConfirm.length > 0 && !passwordsMatch && (

Passwords don't match.

)} {passwordsMatch && adminPassword.length >= 8 && (

Looks good.

)}
{/* Advanced */}
Advanced Show Hide
{localError && (

{localError}

)}
Back {submitting ? 'Applying…' : 'Apply & Finish'}
); } function SummaryGroup({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { return (
{icon} {title}
{children}
); } function SummaryRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
{label} {value || -}
); } // ─── Atoms ──────────────────────────────────────────────────────────────── function StepHeader({ title, subtitle }: { title: string; subtitle?: string }) { return (

{title}

{subtitle &&

{subtitle}

}
); } function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return (
{children} {hint &&

{hint}

}
); } function Input({ value, onChange, type = 'text', placeholder, required, autoFocus, }: { value: string; onChange: (v: string) => void; type?: string; placeholder?: string; required?: boolean; autoFocus?: boolean; }) { return ( onChange(e.target.value)} placeholder={placeholder} required={required} autoFocus={autoFocus} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> ); } function Select({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) { return ( ); } function Toggle({ checked, onChange, label, hint, disabled, }: { checked: boolean; onChange: (v: boolean) => void; label: string; hint?: string; disabled?: boolean; }) { return ( ); } function Footer({ children }: { children: ReactNode }) { return
{children}
; } function PrimaryButton({ children, ...rest }: React.ButtonHTMLAttributes) { return ( ); } function SecondaryButton({ children, onClick, disabled }: { children: ReactNode; onClick: () => void; disabled?: boolean }) { return ( ); } // ─── Helpers ────────────────────────────────────────────────────────────── function mergePartial(prev: WizardConfig, partial: Record): WizardConfig { const next: WizardConfig = { ...prev }; for (const key of Object.keys(prev) as (keyof WizardConfig)[]) { const incoming = partial[key]; if (incoming === undefined) continue; if (key === 'jmapServers') { // Server stores canonical shape; wizard form uses csv domains string. next.jmapServers = canonicalToRows(incoming); continue; } if (typeof incoming === typeof prev[key] || prev[key] === '' || prev[key] === false) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (next as any)[key] = incoming; } } return next; } function canonicalToRows(value: unknown): JmapServerRow[] { if (!Array.isArray(value)) return []; return value .map((item): JmapServerRow | null => { if (!item || typeof item !== 'object') return null; const e = item as Record; const id = typeof e.id === 'string' ? e.id : ''; const label = typeof e.label === 'string' ? e.label : ''; const url = typeof e.url === 'string' ? e.url : ''; const domains = Array.isArray(e.domains) ? (e.domains as unknown[]) .filter((d): d is string => typeof d === 'string') .join(', ') : ''; if (!id || !url) return null; return { id, label, url, domains }; }) .filter((r): r is JmapServerRow => r !== null); } function rowsToCanonical(rows: JmapServerRow[]) { return rows .map((r) => { const id = r.id.trim(); const url = r.url.trim(); if (!id || !url) return null; const domains = r.domains .split(',') .map((d) => d.trim()) .filter(Boolean); return { id, label: r.label.trim() || id, url, ...(domains.length > 0 ? { domains } : {}), }; }) .filter((e): e is { id: string; label: string; url: string; domains?: string[] } => e !== null); } function hasAnyBranding(c: WizardConfig): boolean { return Boolean( c.loginCompanyName || c.faviconUrl || c.appLogoLightUrl || c.appLogoDarkUrl || c.loginLogoLightUrl || c.loginLogoDarkUrl || c.loginWebsiteUrl || c.loginImprintUrl || c.loginPrivacyPolicyUrl, ); } function isInsecureHttpUrl(url: string): boolean { return /^http:\/\//i.test(url.trim()); } function detectInsecureContext(): boolean { if (typeof window === 'undefined') return false; if (window.location.protocol !== 'http:') return false; // Browsers treat localhost/loopback as "potentially trustworthy" and accept // Secure cookies even without TLS, so the wizard still works there. const host = window.location.hostname; if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') { return false; } return true; } function humanError(e: unknown): string { if (e instanceof Error) return e.message; if (typeof e === 'string') return e; return 'Unknown error'; }