'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, getPathPrefix, withBasePath } 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; telemetryEnabled: 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, telemetryEnabled: false, 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); // Resolved in a post-mount effect, not at render, so the server-rendered // HTML (where window is absent) matches the client's first paint and // doesn't trip a hydration mismatch. const [insecureContext, setInsecureContext] = useState(false); const [insecureAcknowledged, setInsecureAcknowledged] = useState(false); useEffect(() => { setInsecureContext(detectInsecureContext()); }, []); // ─── 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 (insecureContext && !insecureAcknowledged) { return setInsecureAcknowledged(true)} />; } 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(`${getPathPrefix()}/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({ onContinue }: { onContinue: () => void }) { const httpsUrl = typeof window !== 'undefined' ? `https://${window.location.host}${window.location.pathname}${window.location.search}` : ''; return (

You're running setup over plain HTTP

The setup token and admin password you enter here will travel in cleartext. Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it.

{httpsUrl && ( Try 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.

)} {isPrivateOrLocalHostUrl(config.jmapServerUrl) && (

This URL only resolves locally.

Mail is fetched directly from the user's browser, so the JMAP URL must be reachable from anywhere users sign in - not just this machine or LAN. Use a public hostname (e.g. https://mail.example.com) in production.

)} {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 { // telemetryConsent is persisted to the telemetry state file by the API, // not to admin config - see app/api/setup/step/route.ts. const values: Record = { settingsSyncEnabled: config.settingsSyncEnabled, telemetryConsent: config.telemetryEnabled ? 'on' : 'off', }; 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} />
setConfig({ ...config, telemetryEnabled: v })} label="Send anonymous usage stats to help improve Bulwark" hint="Off by default. One anonymous heartbeat per day with version, platform, and which features are enabled - never email addresses, hostnames, or IPs. You can change this anytime in admin settings." />
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()); } /** * The JMAP URL is called directly from the user's browser. A URL that only * resolves on the operator's machine or LAN (localhost, RFC1918, .local mDNS) * works during setup but breaks for any real user. Surface a soft warning * so the operator catches this before going live. */ function isPrivateOrLocalHostUrl(url: string): boolean { const trimmed = url.trim(); if (!trimmed) return false; let host: string; try { host = new URL(trimmed).hostname.toLowerCase(); } catch { return false; } // Strip IPv6 brackets, if any. if (host.startsWith('[') && host.endsWith(']')) { host = host.slice(1, -1); } if (host === 'localhost' || host.endsWith('.localhost')) return true; if (host.endsWith('.local')) return true; if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true; // IPv4 literal: only flag the well-known private/loopback/link-local ranges. const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (v4) { const [a, b] = [Number(v4[1]), Number(v4[2])]; if (a === 10) return true; if (a === 127) return true; if (a === 169 && b === 254) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; } return false; } 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. In dev // we still want to render the warning so we can preview it without spinning // up a non-loopback host. const host = window.location.hostname; const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; if (isLoopback && process.env.NODE_ENV !== 'development') { 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'; }