feat(12-05): preserve Instance fields across Back navigation (gap 4)
- Lift appUrl/oidcIssuer/oidcClientId/vapidPublicKey into SetupPage so Step2 unmount preserves them - Step2Config now reads/writes these via fields/setFields props - Fastmail app password stays in Step3 local state, never lifted/persisted, cleared on unmount (T-12-15) - Tests: Back from Calendar restores all four Instance values; password not persisted across nav
This commit is contained in:
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
(validateSetupVapid as ReturnType<typeof vi.fn>).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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -430,17 +430,28 @@ function Step1Welcome({ onContinue, stepHeadingRef }: Step1Props) {
|
|||||||
|
|
||||||
// ── Step 2: Instance Configuration ───────────────────────────────────────────
|
// ── Step 2: Instance Configuration ───────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InstanceFields {
|
||||||
|
appUrl: string;
|
||||||
|
oidcIssuer: string;
|
||||||
|
oidcClientId: string;
|
||||||
|
vapidPublicKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Step2Props {
|
interface Step2Props {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
stepHeadingRef: React.RefObject<HTMLHeadingElement | null>;
|
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 }: Step2Props) {
|
function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: Step2Props) {
|
||||||
const [appUrl, setAppUrl] = useState('');
|
const { appUrl, oidcIssuer, oidcClientId, vapidPublicKey } = fields;
|
||||||
const [oidcIssuer, setOidcIssuer] = useState('');
|
const setAppUrl = (v: string) => setFields((f) => ({ ...f, appUrl: v }));
|
||||||
const [oidcClientId, setOidcClientId] = useState('');
|
const setOidcIssuer = (v: string) => setFields((f) => ({ ...f, oidcIssuer: v }));
|
||||||
const [vapidPublicKey, setVapidPublicKey] = useState('');
|
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);
|
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Gap 3 (frontend): fetch the env-derived, non-secret DB name so the
|
// 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<TerminalState>(alreadyLocked ? 'locked' : null);
|
const [terminal, setTerminal] = useState<TerminalState>(alreadyLocked ? 'locked' : null);
|
||||||
const stepHeadingRef = useRef<HTMLHeadingElement>(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)
|
// Focus the step heading on step change for a11y (D-04 focus management)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stepHeadingRef.current) {
|
if (stepHeadingRef.current) {
|
||||||
@@ -1166,6 +1187,8 @@ export function SetupPage({ alreadyLocked = false }: SetupPageProps) {
|
|||||||
onBack={() => setStep(1)}
|
onBack={() => setStep(1)}
|
||||||
onSuccess={() => setStep(3)}
|
onSuccess={() => setStep(3)}
|
||||||
stepHeadingRef={stepHeadingRef}
|
stepHeadingRef={stepHeadingRef}
|
||||||
|
fields={instanceFields}
|
||||||
|
setFields={setInstanceFields}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user