Files
familysync/apps/pwa/src/routes/SetupPage.tsx
T
Lucas BergerandClaude Opus 4.8 a193bc8236
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Successful in 2m16s
CI / api (pull_request) Failing after 1m37s
CI / harness (pull_request) Failing after 1h3m45s
CI / security (pull_request) Failing after 11s
CI / gate (pull_request) Failing after 1s
fix(12): satisfy CI fast-checks — lint unused vars, typed contract-test body, prettier
- Remove unused 'res'/'container' assignments (no-unused-vars)
- setupClient.contract.test.ts: typed parseSentBody helper + non-async json mock
  (no-unsafe-*/require-await)
- Prettier format 7 setup files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:53:14 -04:00

1187 lines
37 KiB
TypeScript

/**
* 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 (
<ol
role="list"
style={{
display: 'flex',
alignItems: 'center',
gap: 0,
listStyle: 'none',
padding: 0,
margin: '0 0 var(--space-6, 24px) 0',
minHeight: '44px',
}}
aria-label="Setup progress"
>
{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 (
<li
key={label}
role="listitem"
aria-current={isActive ? 'step' : undefined}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: 1,
position: 'relative',
}}
>
{/* Connector line before this step */}
{idx > 0 && (
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '14px',
right: '50%',
width: '100%',
height: '1px',
background: isCompleted
? 'var(--color-member-0, #4a90d9)'
: 'var(--color-border, #e2e4e9)',
zIndex: 0,
}}
/>
)}
<div style={{ ...circleStyle, zIndex: 1, position: 'relative' }}>
{isCompleted ? <CheckCircle size={16} aria-hidden="true" /> : <span>{stepNum}</span>}
</div>
<span
style={{
fontSize: '11px',
fontWeight: 400,
color: isActive
? 'var(--color-text-primary, #111318)'
: 'var(--color-text-muted, #9ca3af)',
marginTop: '4px',
textAlign: 'center',
whiteSpace: 'nowrap',
}}
>
{label}
</span>
</li>
);
})}
</ol>
);
}
// ── 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 (
<div
role="status"
aria-live="polite"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
fontSize: 'var(--text-body-size, 15px)',
marginTop: 'var(--space-3, 12px)',
color:
state === 'failure'
? 'var(--color-destructive, #dc2626)'
: 'var(--color-text-secondary, #6b7280)',
}}
>
{state === 'pending' && (
<>
<Loader2
size={16}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
{pendingText}
</>
)}
{state === 'success' && (
<>
<CheckCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
{successText}
</>
)}
{state === 'failure' && (
<>
<AlertCircle
size={16}
aria-hidden="true"
style={{ color: 'var(--color-destructive, #dc2626)', flexShrink: 0 }}
/>
{failureText}
</>
)}
</div>
);
}
// ── 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 (
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 'var(--space-3, 12px)',
marginTop: 'var(--space-6, 24px)',
}}
>
{onBack && (
<button type="button" onClick={onBack} style={ghostBtnStyle}>
Back
</button>
)}
<button
type="button"
onClick={onContinue}
disabled={continueDisabled || isPending}
style={primaryBtnStyle(continueDisabled || isPending)}
>
{isPending ? 'Saving…' : continueLabel}
</button>
</div>
);
}
// ── Step 1: Welcome ───────────────────────────────────────────────────────────
interface Step1Props {
onContinue: () => void;
stepHeadingRef: React.RefObject<HTMLHeadingElement | null>;
}
function Step1Welcome({ onContinue, stepHeadingRef }: Step1Props) {
return (
<div style={cardStyle}>
<h2
ref={stepHeadingRef}
tabIndex={-1}
style={{
margin: '0 0 var(--space-2, 8px) 0',
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
outline: 'none',
}}
>
Welcome to FamilySync Setup
</h2>
<p
style={{
margin: '0 0 var(--space-6, 24px) 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
lineHeight: 1.5,
}}
>
This wizard will guide you through configuring your self-hosted instance. Before continuing,
run <code>npm run generate-secrets</code> from the repo to generate your instance secrets
and add them to your Docker environment. You&apos;ll also need: your OIDC client credentials
(Authelia) and a Fastmail account with an app password. This takes about 5 minutes.
</p>
{/* Before you start note block */}
<div
style={{
background: 'var(--color-surface-dim, #f7f7f8)',
borderRadius: '4px',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
marginBottom: 'var(--space-4, 16px)',
}}
>
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary, #111318)',
marginBottom: '4px',
}}
>
Before you start
</div>
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
lineHeight: 1.5,
}}
>
Run <code>npm run generate-secrets</code> and add the output to your Docker environment
block. These secrets cannot be recovered if lost.
</div>
</div>
<ActionRow onContinue={onContinue} continueLabel="Continue" />
</div>
);
}
// ── Step 2: Instance Configuration ───────────────────────────────────────────
interface InstanceFields {
appUrl: string;
oidcIssuer: string;
oidcClientId: string;
vapidPublicKey: string;
}
interface Step2Props {
onBack: () => void;
onSuccess: () => void;
stepHeadingRef: React.RefObject<HTMLHeadingElement | null>;
/** Lifted to SetupPage so values survive step unmount (gap 4: Back preserves entries). */
fields: InstanceFields;
setFields: React.Dispatch<React.SetStateAction<InstanceFields>>;
}
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<string | null>(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<ValidationRowStatus, 'db' | 'oidc' | 'vapid'>
>({
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 (
<div style={cardStyle}>
<h2
ref={stepHeadingRef}
tabIndex={-1}
style={{
margin: '0 0 var(--space-2, 8px) 0',
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
outline: 'none',
}}
>
Instance Configuration
</h2>
<p
style={{
margin: '0 0 var(--space-6, 24px) 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
lineHeight: 1.5,
}}
>
Enter your instance&apos;s connection details.
</p>
{/* App URL */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label htmlFor="setup-app-url" style={labelStyle}>
App URL
</label>
<input
id="setup-app-url"
type="text"
value={appUrl}
onChange={(e) => setAppUrl(e.target.value)}
placeholder="https://familysync.example.com"
style={inputStyle(false)}
autoComplete="url"
/>
<div style={helperStyle}>The public URL where FamilySync is reachable.</div>
</div>
{/* Database (read-only, env-derived) — gives the DB validation row below a referent */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label htmlFor="setup-db-name" style={labelStyle}>
Database
</label>
<input
id="setup-db-name"
type="text"
value={dbName || '—'}
readOnly
disabled
aria-readonly="true"
tabIndex={-1}
style={{
...inputStyle(false),
background: 'var(--color-surface-dim, #f7f7f8)',
color: 'var(--color-text-secondary, #6b7280)',
cursor: 'default',
}}
/>
<div style={helperStyle}>
Configured via the server&apos;s Docker environment (<code>DB_HOST</code>,{' '}
<code>DB_PORT</code>, <code>DB_USER</code>, <code>DB_PASSWORD</code>) not entered here.
</div>
</div>
{/* OIDC Issuer */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label htmlFor="setup-oidc-issuer" style={labelStyle}>
OIDC issuer URL
</label>
<input
id="setup-oidc-issuer"
type="text"
value={oidcIssuer}
onChange={(e) => setOidcIssuer(e.target.value)}
placeholder="https://auth.example.com"
style={inputStyle(false)}
/>
<div style={helperStyle}>
Your Authelia instance URL. FamilySync will fetch{' '}
<code>/.well-known/openid-configuration</code> from this URL.
</div>
</div>
{/* OIDC Client ID */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label htmlFor="setup-oidc-client-id" style={labelStyle}>
OIDC client ID
</label>
<input
id="setup-oidc-client-id"
type="text"
value={oidcClientId}
onChange={(e) => setOidcClientId(e.target.value)}
placeholder="familysync"
style={inputStyle(false)}
/>
<div style={helperStyle}>The client ID registered in Authelia for this application.</div>
</div>
{/* VAPID Public Key */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label htmlFor="setup-vapid-public-key" style={labelStyle}>
VAPID public key
</label>
<input
id="setup-vapid-public-key"
type="text"
value={vapidPublicKey}
onChange={(e) => setVapidPublicKey(e.target.value)}
placeholder="BH…"
style={inputStyle(false)}
/>
<div style={helperStyle}>
Paste the <code>VAPID_PUBLIC_KEY</code> value from <code>npm run generate-secrets</code>.
</div>
</div>
{/* Save & Validate button — full width above validation rows */}
<button
type="button"
onClick={handleSaveAndValidate}
disabled={anyPending}
style={{
...primaryBtnStyle(anyPending),
width: '100%',
marginBottom: 'var(--space-3, 12px)',
}}
>
{anyPending ? 'Validating…' : 'Save & Validate'}
</button>
{/* Validation state rows */}
<ValidationRow
state={validationRows.db}
pendingText="Testing database connection…"
successText="Database connection verified."
failureText={
fieldError ?? 'Cannot reach the database. Check your Docker environment and try again.'
}
/>
<ValidationRow
state={validationRows.oidc}
pendingText="Checking OIDC discovery…"
successText="OIDC discovery resolved."
failureText={
fieldError ??
'OIDC discovery failed. Check the issuer URL and that Authelia is reachable from the server.'
}
/>
<ValidationRow
state={validationRows.vapid}
pendingText="Validating VAPID key pair…"
successText="VAPID keys verified."
failureText={
fieldError ??
'VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.'
}
/>
{/* General field error (before validation rows show) */}
{fieldError &&
validationRows.db === 'idle' &&
validationRows.oidc === 'idle' &&
validationRows.vapid === 'idle' && (
<div
role="status"
aria-live="polite"
style={{
color: 'var(--color-destructive, #dc2626)',
fontSize: 'var(--text-body-size, 15px)',
marginTop: 'var(--space-3, 12px)',
}}
>
{fieldError}
</div>
)}
{/* Continue + Back row — appears only when both validations pass */}
{bothPassed && configSaved && (
<ActionRow onBack={onBack} onContinue={onSuccess} continueLabel="Continue" />
)}
{/* Always show Back if not pending and not already showing both-passed row */}
{!bothPassed && !anyPending && (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
marginTop: 'var(--space-3, 12px)',
}}
>
<button type="button" onClick={onBack} style={ghostBtnStyle}>
Back
</button>
</div>
)}
</div>
);
}
// ── Step 3: Calendar Credential ───────────────────────────────────────────────
interface Step3Props {
onBack: () => void;
onSuccess: () => void;
onLocked: () => void;
stepHeadingRef: React.RefObject<HTMLHeadingElement | null>;
}
function Step3Credential({ onBack, onSuccess, onLocked, stepHeadingRef }: Step3Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [validationState, setValidationState] = useState<ValidationRowState>('idle');
const [errorText, setErrorText] = useState<string | null>(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 (
<div style={cardStyle}>
<h2
ref={stepHeadingRef}
tabIndex={-1}
style={{
margin: '0 0 var(--space-2, 8px) 0',
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
outline: 'none',
}}
>
Fastmail Credential
</h2>
<p
style={{
margin: '0 0 var(--space-6, 24px) 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
lineHeight: 1.5,
}}
>
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.
</p>
{/* Fastmail email */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label htmlFor="setup-cred-email" style={labelStyle}>
Fastmail email
</label>
<input
id="setup-cred-email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@fastmail.com"
style={inputStyle(validationState === 'failure')}
/>
</div>
{/* App password */}
<div style={{ marginBottom: 'var(--space-2, 8px)' }}>
<label htmlFor="setup-cred-password" style={labelStyle}>
App password
</label>
<input
id="setup-cred-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-describedby="setup-cred-helper"
style={inputStyle(validationState === 'failure')}
/>
</div>
{/* Helper text */}
<div
id="setup-cred-helper"
style={{
...helperStyle,
marginBottom: 'var(--space-6, 24px)',
}}
>
Enter the Fastmail app password scoped to Calendars/CalDAV.{' '}
<a
href="https://app.fastmail.com/settings/security/devicetokens"
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--color-member-0, #4a90d9)',
textDecoration: 'underline',
}}
>
Get an app password
</a>
{" — choose the 'Calendars & Contacts (CalDAV)' scope."}
</div>
{/* Validation state row */}
<div role="status" aria-live="polite">
{validationState !== 'idle' && (
<ValidationRow
state={validationState}
pendingText="Validating against CalDAV…"
successText="Credential verified."
failureText={
errorText ??
"Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again."
}
/>
)}
</div>
{/* Action row */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 'var(--space-3, 12px)',
marginTop: 'var(--space-6, 24px)',
}}
>
<button type="button" onClick={onBack} disabled={isPending} style={ghostBtnStyle}>
Back
</button>
{!credentialVerified ? (
<button
type="button"
onClick={handleValidate}
disabled={saveDisabled}
style={primaryBtnStyle(saveDisabled)}
>
{completeMutation.isPending ? 'Validating…' : 'Validate Credential'}
</button>
) : (
<button
type="button"
onClick={handleComplete}
disabled={finalMutation.isPending}
style={primaryBtnStyle(finalMutation.isPending)}
>
{finalMutation.isPending ? 'Completing…' : 'Complete Setup'}
</button>
)}
</div>
</div>
);
}
// ── Surface 7: Terminal "Setup Complete" ──────────────────────────────────────
function TerminalComplete() {
return (
<div
style={{
textAlign: 'center',
padding: 'var(--space-12, 48px) var(--space-6, 24px)',
}}
>
<ShieldCheck
size={48}
aria-hidden="true"
style={{ color: 'var(--color-member-0, #4a90d9)' }}
/>
<h2
style={{
margin: 'var(--space-4, 16px) 0 0 0',
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
lineHeight: 'var(--text-display-line-height, 1.2)',
color: 'var(--color-text-primary, #111318)',
}}
>
Setup complete
</h2>
<p
style={{
margin: 'var(--space-2, 8px) 0 0 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
}}
>
Your FamilySync instance is ready. Sign in to continue.
</p>
<div style={{ marginTop: 'var(--space-6, 24px)' }}>
<a
href="/"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-member-0, #4a90d9)',
color: '#ffffff',
textDecoration: 'none',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
padding: '0 var(--space-6, 24px)',
borderRadius: 'var(--space-1, 4px)',
transition: 'background 0.15s ease',
}}
>
Sign in
</a>
</div>
</div>
);
}
// ── Surface 8: Already Locked ─────────────────────────────────────────────────
function AlreadyLocked() {
return (
<div
style={{
textAlign: 'center',
padding: 'var(--space-12, 48px) var(--space-6, 24px)',
}}
>
<ShieldCheck
size={48}
aria-hidden="true"
style={{ color: 'var(--color-text-muted, #9ca3af)' }}
/>
<h2
style={{
margin: 'var(--space-4, 16px) 0 var(--space-2, 8px) 0',
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
color: 'var(--color-text-primary, #111318)',
}}
>
Setup already complete
</h2>
<p
style={{
margin: '0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
}}
>
This instance has already been configured.{' '}
<a
href="/"
style={{
color: 'var(--color-member-0, #4a90d9)',
textDecoration: 'underline',
fontWeight: 600,
}}
>
Sign in
</a>{' '}
to continue.
</p>
</div>
);
}
// ── SetupPage ─────────────────────────────────────────────────────────────────
export function SetupPage({ alreadyLocked = false }: SetupPageProps) {
const [step, setStep] = useState<WizardStep>(1);
const [terminal, setTerminal] = useState<TerminalState>(alreadyLocked ? 'locked' : null);
const stepHeadingRef = useRef<HTMLHeadingElement>(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<InstanceFields>({
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 (
<div style={pageStyle}>
<div style={contentColStyle} role="main">
<h1
style={{
margin: '0 0 var(--space-2, 8px) 0',
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
lineHeight: 'var(--text-display-line-height, 1.2)',
color: 'var(--color-text-primary, #111318)',
}}
>
FamilySync Setup
</h1>
<AlreadyLocked />
</div>
</div>
);
}
// Complete state — render Surface 7
if (terminal === 'complete') {
return (
<div style={pageStyle}>
<div style={contentColStyle} role="main">
<h1
style={{
margin: '0 0 var(--space-2, 8px) 0',
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
lineHeight: 'var(--text-display-line-height, 1.2)',
color: 'var(--color-text-primary, #111318)',
}}
>
FamilySync Setup
</h1>
<TerminalComplete />
</div>
</div>
);
}
// Active wizard
return (
<div style={pageStyle}>
<div style={contentColStyle} role="main">
{/* Page title */}
<h1
style={{
margin: '0 0 var(--space-2, 8px) 0',
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
lineHeight: 'var(--text-display-line-height, 1.2)',
color: 'var(--color-text-primary, #111318)',
}}
>
FamilySync Setup
</h1>
<p
style={{
margin: '0 0 var(--space-8, 32px) 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
lineHeight: 1.5,
}}
>
Let&apos;s get your instance ready.
</p>
{/* Step indicator (Surface 2) */}
<StepIndicator currentStep={step} />
{/* Step card (Surface 3) */}
{step === 1 && (
<Step1Welcome onContinue={() => setStep(2)} stepHeadingRef={stepHeadingRef} />
)}
{step === 2 && (
<Step2Config
onBack={() => setStep(1)}
onSuccess={() => setStep(3)}
stepHeadingRef={stepHeadingRef}
fields={instanceFields}
setFields={setInstanceFields}
/>
)}
{step === 3 && (
<Step3Credential
onBack={() => setStep(2)}
onSuccess={() => setTerminal('complete')}
onLocked={() => setTerminal('locked')}
stepHeadingRef={stepHeadingRef}
/>
)}
</div>
</div>
);
}