diff --git a/apps/pwa/src/routes/SetupPage.test.tsx b/apps/pwa/src/routes/SetupPage.test.tsx index 80caf21..9634506 100644 --- a/apps/pwa/src/routes/SetupPage.test.tsx +++ b/apps/pwa/src/routes/SetupPage.test.tsx @@ -372,4 +372,79 @@ describe('SetupPage — Step 2 Instance copy + DB-name field (gaps 1, 3)', () => }); }); -// (gap-4 Back-navigation tests added in Task 2) +// ── Tests: Back navigation preserves Instance fields (gap 4) ────────────────── + +describe('SetupPage — Back navigation preserves Instance fields (gap 4)', () => { + let queryClient: QueryClient; + + beforeEach(async () => { + queryClient = makeQueryClient(); + vi.resetAllMocks(); + const { fetchSetupStatus } = await import('../api/client.js'); + (fetchSetupStatus as ReturnType).mockResolvedValue({ + setupComplete: false, + dbName: 'familysync', + }); + }); + + /** + * Helper: fill the four Instance fields, run validation to GREEN, advance to + * the Calendar step (step 3). + */ + async function fillStep2AndAdvance() { + const { postSetupConfig, validateSetupDb, validateSetupOidc, validateSetupVapid } = + await import('../api/client.js'); + (postSetupConfig as ReturnType).mockResolvedValue(undefined); + (validateSetupDb as ReturnType).mockResolvedValue(undefined); + (validateSetupOidc as ReturnType).mockResolvedValue(undefined); + (validateSetupVapid as ReturnType).mockResolvedValue(undefined); + + renderSetupPage(queryClient); + fireEvent.click(await screen.findByRole('button', { name: 'Continue' })); + await screen.findByText('Instance Configuration'); + + fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } }); + fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } }); + fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } }); + fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' })); + + // After all validations pass, the Continue button appears + fireEvent.click(await screen.findByRole('button', { name: 'Continue' })); + await screen.findByText('Fastmail Credential'); + } + + it('restores all four Instance field values after navigating Back from Calendar step', async () => { + await fillStep2AndAdvance(); + + // Now on step 3 (Calendar). Navigate Back. + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + await screen.findByText('Instance Configuration'); + + expect(screen.getByLabelText('App URL')).toHaveValue('https://app.example.com'); + expect(screen.getByLabelText('OIDC issuer URL')).toHaveValue('https://auth.example.com'); + expect(screen.getByLabelText('OIDC client ID')).toHaveValue('familysync'); + expect(screen.getByLabelText('VAPID public key')).toHaveValue('BHtest123'); + }); + + it('does NOT persist the Fastmail app password across Back/forward navigation (T-12-15)', async () => { + await fillStep2AndAdvance(); + + // On step 3: type a password into the app password field. + const pwField = screen.getByLabelText('App password'); + fireEvent.change(pwField, { target: { value: 'super-secret-pw' } }); + expect(pwField).toHaveValue('super-secret-pw'); + + // Back to step 2 — field values are preserved, but validation state is not + // lifted, so re-run Save & Validate to surface Continue, then advance to step 3. + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + await screen.findByText('Instance Configuration'); + // Fields are still populated (gap 4), so just re-validate. + fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' })); + fireEvent.click(await screen.findByRole('button', { name: 'Continue' })); + await screen.findByText('Fastmail Credential'); + + // Step 3 re-mounted with fresh local state — the app password is NOT persisted. + expect(screen.getByLabelText('App password')).toHaveValue(''); + }); +}); diff --git a/apps/pwa/src/routes/SetupPage.tsx b/apps/pwa/src/routes/SetupPage.tsx index 23da0fa..b5652f3 100644 --- a/apps/pwa/src/routes/SetupPage.tsx +++ b/apps/pwa/src/routes/SetupPage.tsx @@ -430,17 +430,28 @@ function Step1Welcome({ onContinue, stepHeadingRef }: Step1Props) { // ── 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 }: Step2Props) { - const [appUrl, setAppUrl] = useState(''); - const [oidcIssuer, setOidcIssuer] = useState(''); - const [oidcClientId, setOidcClientId] = useState(''); - const [vapidPublicKey, setVapidPublicKey] = useState(''); +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 @@ -1072,6 +1083,16 @@ export function SetupPage({ alreadyLocked = false }: SetupPageProps) { 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) { @@ -1166,6 +1187,8 @@ export function SetupPage({ alreadyLocked = false }: SetupPageProps) { onBack={() => setStep(1)} onSuccess={() => setStep(3)} stepHeadingRef={stepHeadingRef} + fields={instanceFields} + setFields={setInstanceFields} /> )} {step === 3 && (