/**
* 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 (
{/* Connector line before this step */}
{idx > 0 && (
)}
{isCompleted ? : {stepNum} }
{label}
);
})}
);
}
// ── 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' && (
<>
{pendingText}
>
)}
{state === 'success' && (
<>
{successText}
>
)}
{state === 'failure' && (
<>
{failureText}
>
)}
);
}
// ── 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 && (
Back
)}
{isPending ? 'Saving…' : continueLabel}
);
}
// ── 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 */}
{/* Database (read-only, env-derived) — gives the DB validation row below a referent */}
{/* OIDC Issuer */}
{/* OIDC Client ID */}
{/* VAPID Public Key */}
{/* Save & Validate button — full width above validation rows */}
{anyPending ? 'Validating…' : 'Save & Validate'}
{/* 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 && (
Back
)}
);
}
// ── 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 */}
Fastmail email
setEmail(e.target.value)}
placeholder="user@fastmail.com"
style={inputStyle(validationState === 'failure')}
/>
{/* App password */}
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 */}
Back
{!credentialVerified ? (
{completeMutation.isPending ? 'Validating…' : 'Validate Credential'}
) : (
{finalMutation.isPending ? 'Completing…' : 'Complete Setup'}
)}
);
}
// ── Surface 7: Terminal "Setup Complete" ──────────────────────────────────────
function TerminalComplete() {
return (
Setup complete
Your FamilySync instance is ready. Sign in to continue.
);
}
// ── Surface 8: Already Locked ─────────────────────────────────────────────────
function AlreadyLocked() {
return (
Setup already complete
This instance has already been configured.{' '}
Sign in
{' '}
to continue.
);
}
// ── 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 (
);
}
// Complete state — render Surface 7
if (terminal === 'complete') {
return (
);
}
// 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}
/>
)}
);
}