/** * SetupPage — standalone full-page wizard for initial instance configuration. * * UI-SPEC §Surface Architecture: Standalone full-page — no nav shell; owns entire viewport. * Renders the revised 4-step wizard (D-02/D-04/D-05): * Step 1: Welcome (orient operator; pre-start note; no inputs) * Step 2: Instance Configuration (collect app_url, oidc_issuer, oidc_client_id, vapid_public_key) * Step 3: Calendar Credential (Fastmail email + app password; CalDAV validation) * Terminal: "Setup complete" (Surface 7) — replaces wizard card after step 3 completes * * Step 2 validates DB connectivity (POST /api/setup/validate/db), OIDC discovery * (POST /api/setup/validate/oidc), and VAPID key pair (POST /api/setup/validate/vapid) * AFTER writing config (POST /api/setup/config). All three must pass to proceed. * Step 3 validates CalDAV PROPFIND (POST /api/setup/credential), then calls * POST /api/setup/complete to flip setup_complete. * * Security: * T-12-13: wizard never displays/handles VAPID_PRIVATE_KEY or SESSION_SECRET * T-12-14: all copy is plain-text JSX children — no HTML injection (T-14 contract) * T-12-15: app password is type="password"; never stored client-side */ import { useState, useRef, useEffect } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { ShieldCheck, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; import { fetchSetupStatus, postSetupConfig, validateSetupDb, validateSetupOidc, validateSetupVapid, postSetupCredential, postSetupComplete, SetupAlreadyLockedError, type SetupConfigPayload, type SetupCredentialPayload, } from '../api/client.js'; // ── Types ───────────────────────────────────────────────────────────────────── type WizardStep = 1 | 2 | 3; type TerminalState = 'complete' | 'locked' | null; type ValidationRowState = 'idle' | 'pending' | 'success' | 'failure'; interface ValidationRowStatus { db: ValidationRowState; oidc: ValidationRowState; vapid: ValidationRowState; caldav: ValidationRowState; } export interface SetupPageProps { /** Pass true to immediately render the "Already Locked" screen (Surface 8). Used in tests. */ alreadyLocked?: boolean; } // ── Styles ──────────────────────────────────────────────────────────────────── const pageStyle: React.CSSProperties = { minHeight: '100dvh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', background: 'var(--color-surface, #ffffff)', fontFamily: 'var(--font-family-base)', color: 'var(--color-text-primary, #111318)', }; const contentColStyle: React.CSSProperties = { maxWidth: '540px', width: '100%', margin: '0 auto', padding: 'var(--space-12, 48px) var(--space-6, 24px)', }; const cardStyle: React.CSSProperties = { background: 'var(--color-surface, #ffffff)', border: '1px solid var(--color-border, #e2e4e9)', borderRadius: '8px', padding: 'var(--space-6, 24px)', boxShadow: '0 1px 4px rgba(0,0,0,0.06)', }; const primaryBtnStyle = (disabled: boolean): React.CSSProperties => ({ background: disabled ? 'var(--color-border, #e2e4e9)' : 'var(--color-member-0, #4a90d9)', color: '#ffffff', border: 'none', cursor: disabled ? 'default' : 'pointer', fontSize: 'var(--text-label-size, 13px)', fontWeight: 600, minHeight: '44px', minWidth: '44px', padding: '0 var(--space-6, 24px)', borderRadius: 'var(--space-1, 4px)', fontFamily: 'var(--font-family-base)', transition: 'background 0.15s ease', }); const ghostBtnStyle: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--text-label-size, 13px)', fontWeight: 600, color: 'var(--color-text-secondary, #6b7280)', minHeight: '44px', minWidth: '44px', padding: '0 var(--space-4, 16px)', fontFamily: 'var(--font-family-base)', borderRadius: 'var(--space-1, 4px)', }; const inputStyle = (hasError: boolean): React.CSSProperties => ({ width: '100%', boxSizing: 'border-box', padding: 'var(--space-3, 12px) var(--space-4, 16px)', border: `1px solid ${hasError ? 'var(--color-destructive, #dc2626)' : 'var(--color-border, #e2e4e9)'}`, borderRadius: 'var(--space-1, 4px)', fontSize: 'var(--text-body-size, 15px)', color: 'var(--color-text-primary, #111318)', background: 'var(--color-surface, #ffffff)', fontFamily: 'var(--font-family-base)', outline: 'none', }); const labelStyle: React.CSSProperties = { display: 'block', fontSize: 'var(--text-label-size, 13px)', fontWeight: 600, color: 'var(--color-text-primary, #111318)', marginBottom: 'var(--space-1, 4px)', }; const helperStyle: React.CSSProperties = { fontSize: 'var(--text-label-size, 13px)', color: 'var(--color-text-secondary, #6b7280)', marginTop: 'var(--space-1, 4px)', }; // ── Step Indicator (Surface 2) ─────────────────────────────────────────────── const STEP_LABELS = ['Welcome', 'Instance', 'Calendar', 'Complete']; interface StepIndicatorProps { currentStep: WizardStep; } function StepIndicator({ currentStep }: StepIndicatorProps) { return (
    {STEP_LABELS.map((label, idx) => { const stepNum = idx + 1; const isCompleted = stepNum < currentStep; const isActive = stepNum === currentStep; const circleStyle: React.CSSProperties = { width: '28px', height: '28px', borderRadius: '50%', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: isCompleted || isActive ? 'var(--color-member-0, #4a90d9)' : 'transparent', border: isCompleted || isActive ? 'none' : '2px solid var(--color-border, #e2e4e9)', color: isCompleted || isActive ? '#ffffff' : 'var(--color-text-muted, #9ca3af)', fontSize: 'var(--text-label-size, 13px)', fontWeight: 600, }; return (
  1. {/* Connector line before this step */} {idx > 0 && (
  2. ); })}
); } // ── Validation Row (Surface 5) ──────────────────────────────────────────────── interface ValidationRowProps { state: ValidationRowState; pendingText: string; successText: string; failureText: string; } function ValidationRow({ state, pendingText, successText, failureText }: ValidationRowProps) { if (state === 'idle') return null; return (
{state === 'pending' && ( <>
); } // ── Action Row (Surface 6) ──────────────────────────────────────────────────── interface ActionRowProps { onBack?: () => void; onContinue: () => void; continueLabel?: string; continueDisabled?: boolean; isPending?: boolean; } function ActionRow({ onBack, onContinue, continueLabel = 'Continue', continueDisabled = false, isPending = false, }: ActionRowProps) { return (
{onBack && ( )}
); } // ── Step 1: Welcome ─────────────────────────────────────────────────────────── interface Step1Props { onContinue: () => void; stepHeadingRef: React.RefObject; } function Step1Welcome({ onContinue, stepHeadingRef }: Step1Props) { return (

Welcome to FamilySync Setup

This wizard will guide you through configuring your self-hosted instance. Before continuing, run npm run generate-secrets from the repo to generate your instance secrets and add them to your Docker environment. You'll also need: your OIDC client credentials (Authelia) and a Fastmail account with an app password. This takes about 5 minutes.

{/* Before you start note block */}
Before you start
Run npm run generate-secrets and add the output to your Docker environment block. These secrets cannot be recovered if lost.
); } // ── Step 2: Instance Configuration ─────────────────────────────────────────── interface InstanceFields { appUrl: string; oidcIssuer: string; oidcClientId: string; vapidPublicKey: string; } interface Step2Props { onBack: () => void; onSuccess: () => void; stepHeadingRef: React.RefObject; /** Lifted to SetupPage so values survive step unmount (gap 4: Back preserves entries). */ fields: InstanceFields; setFields: React.Dispatch>; } function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: Step2Props) { const { appUrl, oidcIssuer, oidcClientId, vapidPublicKey } = fields; const setAppUrl = (v: string) => setFields((f) => ({ ...f, appUrl: v })); const setOidcIssuer = (v: string) => setFields((f) => ({ ...f, oidcIssuer: v })); const setOidcClientId = (v: string) => setFields((f) => ({ ...f, oidcClientId: v })); const setVapidPublicKey = (v: string) => setFields((f) => ({ ...f, vapidPublicKey: v })); const [fieldError, setFieldError] = useState(null); // Gap 3 (frontend): fetch the env-derived, non-secret DB name so the // "database connection verified" row below has an on-screen referent. // Only dbName is surfaced — DB_HOST/DB_USER/DB_PASSWORD are never fetched (T-12-3DB). const { data: setupStatus } = useQuery({ queryKey: ['setupStatus'], queryFn: fetchSetupStatus, staleTime: 0, retry: false, }); const dbName = setupStatus?.dbName ?? ''; const [validationRows, setValidationRows] = useState< Pick >({ db: 'idle', oidc: 'idle', vapid: 'idle', }); // Track overall state: null = not yet run, 'running', 'done' (both pass), 'failed' const [configSaved, setConfigSaved] = useState(false); const [bothPassed, setBothPassed] = useState(false); const configMutation = useMutation({ mutationFn: (payload: SetupConfigPayload) => postSetupConfig(payload), onSuccess: async () => { setConfigSaved(true); setFieldError(null); // Sequential validation: DB → OIDC → VAPID setValidationRows({ db: 'pending', oidc: 'idle', vapid: 'idle' }); try { await validateSetupDb(); setValidationRows({ db: 'success', oidc: 'pending', vapid: 'idle' }); try { await validateSetupOidc(); setValidationRows({ db: 'success', oidc: 'success', vapid: 'pending' }); try { await validateSetupVapid(); setValidationRows({ db: 'success', oidc: 'success', vapid: 'success' }); setBothPassed(true); } catch (vapidErr) { setValidationRows((prev) => ({ ...prev, vapid: 'failure' })); setFieldError( vapidErr instanceof Error ? vapidErr.message : 'VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.', ); } } catch (oidcErr) { setValidationRows((prev) => ({ ...prev, oidc: 'failure' })); setFieldError( oidcErr instanceof Error ? oidcErr.message : 'OIDC discovery failed. Check the issuer URL and that Authelia is reachable from the server.', ); } } catch (dbErr) { setValidationRows({ db: 'failure', oidc: 'idle', vapid: 'idle' }); setFieldError( dbErr instanceof Error ? dbErr.message : 'Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your Docker environment and try again.', ); } }, onError: (err) => { setFieldError(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); }, }); const isSaveValidating = configMutation.isPending; const isValidating = validationRows.db === 'pending' || validationRows.oidc === 'pending' || validationRows.vapid === 'pending'; const anyPending = isSaveValidating || isValidating; function handleSaveAndValidate() { setFieldError(null); setBothPassed(false); setConfigSaved(false); setValidationRows({ db: 'idle', oidc: 'idle', vapid: 'idle' }); if (!appUrl.trim() || !oidcIssuer.trim() || !oidcClientId.trim() || !vapidPublicKey.trim()) { setFieldError('All fields are required.'); return; } configMutation.mutate({ appExternalUrl: appUrl.trim(), oidcIssuer: oidcIssuer.trim(), oidcClientId: oidcClientId.trim(), vapidPublicKey: vapidPublicKey.trim(), }); } return (

Instance Configuration

Enter your instance's connection details.

{/* App URL */}
setAppUrl(e.target.value)} placeholder="https://familysync.example.com" style={inputStyle(false)} autoComplete="url" />
The public URL where FamilySync is reachable.
{/* Database (read-only, env-derived) — gives the DB validation row below a referent */}
Configured via the server's Docker environment (DB_HOST,{' '} DB_PORT, DB_USER, DB_PASSWORD) — not entered here.
{/* OIDC Issuer */}
setOidcIssuer(e.target.value)} placeholder="https://auth.example.com" style={inputStyle(false)} />
Your Authelia instance URL. FamilySync will fetch{' '} /.well-known/openid-configuration from this URL.
{/* OIDC Client ID */}
setOidcClientId(e.target.value)} placeholder="familysync" style={inputStyle(false)} />
The client ID registered in Authelia for this application.
{/* VAPID Public Key */}
setVapidPublicKey(e.target.value)} placeholder="BH…" style={inputStyle(false)} />
Paste the VAPID_PUBLIC_KEY value from npm run generate-secrets.
{/* Save & Validate button — full width above validation rows */} {/* Validation state rows */} {/* General field error (before validation rows show) */} {fieldError && validationRows.db === 'idle' && validationRows.oidc === 'idle' && validationRows.vapid === 'idle' && (
{fieldError}
)} {/* Continue + Back row — appears only when both validations pass */} {bothPassed && configSaved && ( )} {/* Always show Back if not pending and not already showing both-passed row */} {!bothPassed && !anyPending && (
)}
); } // ── Step 3: Calendar Credential ─────────────────────────────────────────────── interface Step3Props { onBack: () => void; onSuccess: () => void; onLocked: () => void; stepHeadingRef: React.RefObject; } function Step3Credential({ onBack, onSuccess, onLocked, stepHeadingRef }: Step3Props) { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [validationState, setValidationState] = useState('idle'); const [errorText, setErrorText] = useState(null); const [credentialVerified, setCredentialVerified] = useState(false); const completeMutation = useMutation({ mutationFn: async (payload: SetupCredentialPayload) => { setValidationState('pending'); setErrorText(null); await postSetupCredential(payload); setValidationState('success'); setCredentialVerified(true); }, onError: (err) => { if (err instanceof SetupAlreadyLockedError) { onLocked(); return; } setValidationState('failure'); setErrorText( err instanceof Error ? err.message : "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again.", ); }, }); const finalMutation = useMutation({ mutationFn: postSetupComplete, onSuccess: () => { onSuccess(); }, onError: (err) => { if (err instanceof SetupAlreadyLockedError) { onLocked(); return; } setErrorText(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); }, }); const isPending = completeMutation.isPending || finalMutation.isPending; const saveDisabled = isPending || email.trim().length === 0 || password.trim().length === 0; function handleValidate() { if (saveDisabled) return; setValidationState('idle'); setCredentialVerified(false); completeMutation.mutate({ fastmailEmail: email.trim(), appPassword: password }); } function handleComplete() { finalMutation.mutate(); } return (

Fastmail Credential

Add the Fastmail app password for the first household member. This credential is validated against Fastmail CalDAV before saving. The password is never stored in plain text.

{/* Fastmail email */}
setEmail(e.target.value)} placeholder="user@fastmail.com" style={inputStyle(validationState === 'failure')} />
{/* App password */}
setPassword(e.target.value)} aria-describedby="setup-cred-helper" style={inputStyle(validationState === 'failure')} />
{/* Helper text */}
Enter the Fastmail app password scoped to Calendars/CalDAV.{' '} Get an app password {" — choose the 'Calendars & Contacts (CalDAV)' scope."}
{/* Validation state row */}
{validationState !== 'idle' && ( )}
{/* Action row */}
{!credentialVerified ? ( ) : ( )}
); } // ── Surface 7: Terminal "Setup Complete" ────────────────────────────────────── function TerminalComplete() { return (
); } // ── Surface 8: Already Locked ───────────────────────────────────────────────── function AlreadyLocked() { return (
); } // ── SetupPage ───────────────────────────────────────────────────────────────── export function SetupPage({ alreadyLocked = false }: SetupPageProps) { const [step, setStep] = useState(1); const [terminal, setTerminal] = useState(alreadyLocked ? 'locked' : null); const stepHeadingRef = useRef(null); // Gap 4: Instance-step field values are lifted here so they survive Step2 unmount. // The Fastmail app password (Step 3) is deliberately NOT lifted — it stays in // Step3Credential local state and is cleared on unmount (T-12-15 preserved). const [instanceFields, setInstanceFields] = useState({ appUrl: '', oidcIssuer: '', oidcClientId: '', vapidPublicKey: '', }); // Focus the step heading on step change for a11y (D-04 focus management) useEffect(() => { if (stepHeadingRef.current) { stepHeadingRef.current.focus(); } }, [step]); // Locked state — render Surface 8 immediately if (terminal === 'locked') { return (

FamilySync Setup

); } // Complete state — render Surface 7 if (terminal === 'complete') { return (

FamilySync Setup

); } // Active wizard return (
{/* Page title */}

FamilySync Setup

Let's get your instance ready.

{/* Step indicator (Surface 2) */} {/* Step card (Surface 3) */} {step === 1 && ( setStep(2)} stepHeadingRef={stepHeadingRef} /> )} {step === 2 && ( setStep(1)} onSuccess={() => setStep(3)} stepHeadingRef={stepHeadingRef} fields={instanceFields} setFields={setInstanceFields} /> )} {step === 3 && ( setStep(2)} onSuccess={() => setTerminal('complete')} onLocked={() => setTerminal('locked')} stepHeadingRef={stepHeadingRef} /> )}
); }