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
- 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>
1187 lines
37 KiB
TypeScript
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'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'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'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'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>
|
|
);
|
|
}
|