'use client'; import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; 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); // ─── Initial status load ──────────────────────────────────────────────── useEffect(() => { 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]); // ─── 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 (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) { setError(humanError(e)); } }} 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 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 (
{error}
); } // ─── 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 ───────────────────────────────────────────────────────── function ServerStep({ config, setConfig, onNext }: Pick) { const [submitting, setSubmitting] = useState(false); const [probe, setProbe] = useState(null); const [probing, setProbing] = useState(false); async function testJmap() { setProbe(null); setProbing(true); 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(); if (data.status === 'jmap_detected') { setProbe(`JMAP server confirmed at ${data.endpoint}`); } else if (data.status === 'reachable_no_jmap') { setProbe(`Server reachable (HTTP ${data.httpStatus}) but no JMAP session found at standard paths.`); } else { setProbe(data.message ?? 'Could not reach server.'); } } catch (e) { setProbe(humanError(e)); } 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 { 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 })} required placeholder="https://" type="url" />
{probe &&

{probe}

}
{/* 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…' : '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 ─────────────────────────────────────────────────────── 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 the operator actually filled in. Saving an empty // string would create an admin override that shadows the system // default — a blank "Login logo" field would suppress the default // Bulwark logo on the login page, which is never what we want from // the wizard. 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 })} /> setConfig({ ...config, appLogoLightUrl: v })} /> setConfig({ ...config, appLogoDarkUrl: v })} />
setConfig({ ...config, loginWebsiteUrl: v })} type="url" /> setConfig({ ...config, loginImprintUrl: v })} type="url" /> setConfig({ ...config, loginPrivacyPolicyUrl: v })} type="url" />
Back {submitting ? 'Saving…' : 'Next'}
); } // ─── 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); } } return (
{config.jmapServers.length > 0 && ( s.id).join(', ') + (config.jmapServerAutoPickByDomain ? ' (auto-pick by domain)' : '') } /> )}
{localError && (

{localError}

)}
Back {submitting ? 'Applying…' : 'Apply & Finish'}
); } // ─── 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 ( ); } function Row({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } // ─── 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 humanError(e: unknown): string { if (e instanceof Error) return e.message; if (typeof e === 'string') return e; return 'Unknown error'; }