--- phase: 12-initial-setup-wizard plan: 04 subsystem: pwa, ui, api-client tags: [react, vite, tanstack-query, tdd, setup-wizard, oidc, playwright] # Dependency graph requires: - phase: 12-02 provides: /api/setup/* routes (7 handlers, pre-auth mount) - phase: 12-03 provides: first-login-claims (upsertUser D-08) provides: - apps/pwa/src/api/client.ts — 7 setup API functions + SetupAlreadyLockedError - apps/pwa/src/routes/SetupPage.tsx — standalone 4-step wizard + Terminal/Locked screens - apps/pwa/src/App.tsx — setupQuery gate + /setup route + redirect when unconfigured - apps/pwa/src/App.test.tsx — gate tests (both branches) - apps/pwa/src/routes/SetupPage.test.tsx — wizard unit tests - apps/pwa/src/api/setupClient.contract.test.ts — contract regression tests (BUG 1+2 guards) - .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md — revised (done in prior session 0f3c378) affects: - first-run operator experience (SETUP-01/SETUP-02) # Tech tracking tech-stack: added: [] # Zero new packages patterns: - TDD RED/GREEN cycle — SetupPage.test.tsx (RED gate eb84e6e) → SetupPage.tsx (GREEN 62d80f6) - setupQuery (staleTime: 0) alongside meQuery — always-fresh setup gate (mirrors D-10 spirit) - alreadyLocked prop pattern — SetupPage accepts prop to directly render Surface 8 (testable) - window.history.pushState({}, '', '/') in beforeEach — URL isolation between BrowserRouter tests - nested inside route element — outer * route contains inner app-shell routes - camelCase API contract enforcement — SetupConfigPayload fields match API configSchema exactly - ZodError object-to-string extraction — issues[0].message extracted to prevent [object Object] key-files: created: - apps/pwa/src/routes/SetupPage.tsx - apps/pwa/src/routes/SetupPage.test.tsx - apps/pwa/src/App.test.tsx - apps/pwa/src/api/setupClient.contract.test.ts modified: - apps/pwa/src/api/client.ts - apps/pwa/src/App.tsx - .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md (prior session 0f3c378) key-decisions: - "ALREADYLOCKED-PROP: SetupPage accepts alreadyLocked?: boolean prop to render Surface 8 directly — enables unit tests without needing a live 423 response; also handles the runtime case where any setup API call returns 423 mid-wizard" - "NESTED-ROUTES: App.tsx uses outer containing inner to implement the gate — the /setup route is at the outer level (pre-gate) so it renders standalone before the gate logic runs" - "URL-ISOLATION: window.history.pushState({}, '', '/') in beforeEach resets BrowserRouter URL state between tests (jsdom shares window.location across tests in the same file)" - "CAMELCASE-CONTRACT: SetupConfigPayload interface renamed to camelCase (appExternalUrl, oidcIssuer, oidcClientId, vapidPublicKey) to match the API configSchema exactly — the original snake_case interface caused every /config POST to return 400 ZodError" - "ZODERROR-EXTRACTION: postSetupConfig now extracts issues[0].message when body.error is an object; falls back to status code message when no issues — prevents [object Object] in UI" # Metrics duration: 50min completed: 2026-06-15 --- # Phase 12 Plan 04: PWA Setup Wizard Summary **Setup wizard PWA side: 7 API client functions, standalone 4-step SetupPage, App.tsx gate + /setup route; TDD; 245 tests pass; playwright-cli full no-credential smoke pass (/config 200 confirmed)** ## Performance - **Duration:** 50 min - **Started:** 2026-06-15T18:20:37Z - **Completed:** 2026-06-15T18:52:00Z - **Tasks completed:** 4 of 4 (Task 4 verified to no-credential boundary; Fastmail step reserved for human) - **Files modified:** 6 ## Accomplishments ### Task 1: UI-SPEC Revision (pre-existing, 0f3c378) The UI-SPEC was revised in a prior planning session (commit 0f3c378). Verified all acceptance criteria pass: - No `/api/setup/generate` references (Generate Secrets step dropped per D-05) - Input fields for `oidc_issuer`, `oidc_client_id`, `vapid_public_key`, `app_external_url` present - Design system sections retained (Design System, Spacing Scale, Accessibility Contract) - Step indicator re-numbered to 4 steps (Welcome / Instance / Calendar / Complete) ### Task 2: Setup API Client + SetupPage Wizard (TDD RED/GREEN) **RED gate (eb84e6e):** 17 failing tests covering all 7 API function exports and SetupPage rendering. **GREEN (62d80f6):** Implemented: - `fetchSetupStatus()` — GETs `/api/setup/status`; no credentials/redirect:manual (pre-auth endpoint) - `postSetupConfig(payload)` — POSTs non-secret config (appExternalUrl, oidcIssuer, oidcClientId, vapidPublicKey) - `validateSetupDb()` — POSTs `/api/setup/validate/db`; typed error message on failure - `validateSetupOidc()` — POSTs `/api/setup/validate/oidc`; typed error message on failure - `validateSetupVapid()` — POSTs `/api/setup/validate/vapid`; typed error message on failure - `postSetupCredential(payload)` — POSTs fastmailEmail + appPassword to `/api/setup/credential` - `postSetupComplete()` — POSTs `/api/setup/complete`; throws SetupAlreadyLockedError on 423 - `SetupAlreadyLockedError` — typed error class for 423 responses **SetupPage.tsx:** - Standalone full-page wizard — no AppNav/BottomTabBar imports - `role="main"` on content column; `aria-live="polite"` on validation rows - 4 sub-components: StepIndicator, ValidationRow, ActionRow, step cards - Step 1 (Welcome): orientation text, "Before you start" note block, Continue button - Step 2 (Instance Configuration): 4 fields (App URL, OIDC issuer, client_id, VAPID public key); Save & Validate triggers sequential DB+OIDC validation; Continue appears only when both pass - Step 3 (Calendar Credential): email+password fields; CalDAV validation; Complete Setup button - Surface 7 (Terminal): ShieldCheck icon, "Setup complete" heading, Sign in link - Surface 8 (Already Locked): via `alreadyLocked` prop or any 423 response mid-wizard - All copy is plain-text JSX children — no HTML injection - Focus management: `stepHeadingRef.current.focus()` on step change (a11y) ### Task 3: App.tsx Gate + /setup Route (1587bca) - Added `setupQuery = useQuery({ queryKey: ['setupStatus'], queryFn: fetchSetupStatus, retry: false, staleTime: 0 })` - Added `} />` at the outer Routes level (pre-gate) - Redirect gate: `setupLoading →
` | `setupComplete===false → ` | `true → full app shell` - `/setup` route renders standalone — AppNav/BottomTabBar only render inside the `setupComplete===true` branch **App.test.tsx:** - `setupComplete: false` → SetupPage renders, AppNav absent ✓ - `setupComplete: true` → CalendarShell renders, AppNav present ✓ - Loading state → CalendarShell absent (no flash) ✓ ### Task 4: Bug Fixes + playwright-cli Full No-Credential Verification #### BUG 1 — Field-name contract mismatch (FIXED, 120ce85) **Root cause:** `SetupConfigPayload` interface had snake_case fields (`app_url`, `oidc_issuer`, `oidc_client_id`, `vapid_public_key`). The API's `configSchema` expects camelCase (`appExternalUrl`, `oidcIssuer`, `oidcClientId`, `vapidPublicKey`). Every `/config` POST returned 400 ZodError. **Fix:** - `client.ts`: Renamed `SetupConfigPayload` interface fields to camelCase matching the API contract - `SetupPage.tsx`: Updated `handleSaveAndValidate` call to `configMutation.mutate({ appExternalUrl, oidcIssuer, oidcClientId, vapidPublicKey })` **Verified:** playwright-cli `request-body 72` shows `{"appExternalUrl":"...","oidcIssuer":"...","oidcClientId":"...","vapidPublicKey":"..."}` — exact API contract match. Response: 200 OK. #### BUG 2 — Error status renders [object Object] (FIXED, 120ce85) **Root cause:** When `/config` returned 400, the response body `error` field was a ZodError object `{ name: "ZodError", issues: [...] }`, not a string. The client did `body.error ?? fallback` which yielded the object, then `new Error(object)` → message `"[object Object]"`. **Fix:** `client.ts` `postSetupConfig` now: 1. If `body.error` is a string: use it directly 2. If `body.error` is an object with `issues[0].message`: extract that as the error message 3. Otherwise: fall back to `POST /api/setup/config failed: {status}` **playwright-cli Verification (no-credential path):** | Step | Result | |------|--------| | `/` → redirect to `/setup` | PASS (URL confirmed `/setup`) | | Welcome step renders | PASS (h1, 4-step indicator, Continue button) | | Continue → Step 2 (Instance Configuration) | PASS (all 4 fields render with correct placeholders) | | Step 1 shows completion checkmark | PASS (img element in step indicator) | | Fill 4 fields + click "Save & Validate" | PASS | | `POST /api/setup/config` | **200 OK** (camelCase body verified via request-body) | | DB validation | **200 OK** ("Database connection verified." row) | | OIDC validation | **400 Bad Request** (Authelia unreachable from container — EXPECTED, ACCEPTABLE) | | OIDC error display | Readable string "OIDC discovery failed..." (no [object Object]) | | No [object Object] in UI | PASS | Screenshot: `.planning/phases/12-initial-setup-wizard/screenshot-setup-config-200-fixed.png` **Cannot be automated (reserved for human):** - Fastmail app password entry (Step 3 — CalDAV credential) requires real credentials - Live OIDC discovery validation (requires Authelia reachable from the container) - Final `POST /api/setup/complete` to flip setup_complete ## Task Commits 1. **Task 1: UI-SPEC revision** — `0f3c378` (prior session — docs) 2. **Task 2 RED: failing tests** — `eb84e6e` (test) 3. **Task 2 GREEN: client.ts + SetupPage** — `62d80f6` (feat) 4. **Task 3: App.tsx gate + tests** — `1587bca` (feat) 5. **Task 4 RED: contract regression tests** — `9f20c8b` (test) 6. **Task 4 GREEN: BUG 1+2 fixes** — `120ce85` (fix) ## Files Created/Modified - `apps/pwa/src/api/client.ts` — 7 setup functions + SetupAlreadyLockedError; camelCase payload fix; ZodError extraction fix - `apps/pwa/src/routes/SetupPage.tsx` — new (standalone wizard, 5 surfaces, 530 lines); camelCase mutation payload fix - `apps/pwa/src/routes/SetupPage.test.tsx` — new (17 tests, RED gate + implementation tests) - `apps/pwa/src/api/setupClient.contract.test.ts` — new (9 contract regression tests for BUG 1+2) - `apps/pwa/src/App.tsx` — setupQuery + /setup route + redirect gate added - `apps/pwa/src/App.test.tsx` — new (6 tests covering both gate branches) - `.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` — revised (prior session 0f3c378) ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] `require('./SetupPage.js')` pattern incompatible with Vitest ESM mode** - **Found during:** Task 2 test execution - **Issue:** RED test scaffolding used `require('./SetupPage.js')` inside test functions to import after mocks — but in Vitest's ESM mode this resolves at runtime and cannot find the `.tsx` source file - **Fix:** Changed to static `import { SetupPage } from './SetupPage.js'` at the top of the test file (mocks are hoisted via `vi.mock` so static imports work correctly) - **Files modified:** `apps/pwa/src/routes/SetupPage.test.tsx` - **Commit:** `62d80f6` (Task 2 GREEN) **2. [Rule 1 - Bug] BrowserRouter URL state persists between tests in jsdom** - **Found during:** Task 3 App.test.tsx test run - **Issue:** `setupComplete:false` test redirected to `/setup`, leaving `window.location` at `/setup` for the `setupComplete:true` test. The `/setup` route matched the standalone SetupPage instead of the CalendarShell. - **Fix:** Added `window.history.pushState({}, '', '/')` in `beforeEach` to reset URL to root before each test - **Files modified:** `apps/pwa/src/App.test.tsx` - **Commit:** `1587bca` (Task 3) **3. [Rule 1 - Bug] BUG 1 — SetupConfigPayload snake_case vs API camelCase mismatch** - **Found during:** Task 4 human-verify checkpoint (returned as blocking bug) - **Issue:** `SetupConfigPayload` interface used snake_case field names (`app_url`, `oidc_issuer`, `oidc_client_id`, `vapid_public_key`). API `configSchema` requires camelCase (`appExternalUrl`, `oidcIssuer`, `oidcClientId`, `vapidPublicKey`). Every `/api/setup/config` POST returned 400 ZodError, blocking wizard completion. - **Fix:** Renamed interface fields + updated SetupPage mutation call to use camelCase - **Files modified:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/routes/SetupPage.tsx` - **Commit:** `120ce85` (Task 4 GREEN) **4. [Rule 1 - Bug] BUG 2 — ZodError object serializes as [object Object] in error message** - **Found during:** Task 4 human-verify checkpoint (returned as blocking bug) - **Issue:** When API returns `{ error: { name: "ZodError", issues: [...] } }`, `postSetupConfig` did `body.error ?? fallback` yielding the ZodError object, then `new Error(object)` → `"[object Object]"` in UI - **Fix:** Extract `issues[0].message` from ZodError object; fall back to string `error` if present; fall back to status code - **Files modified:** `apps/pwa/src/api/client.ts` - **Commit:** `120ce85` (Task 4 GREEN) ## Known Stubs None — all wizard steps render from live state (no hardcoded empty values). The validation steps (DB, OIDC, CalDAV) require a live API to produce success states; the component correctly shows pending/success/failure per actual API responses. ## Threat Surface Scan No new threat surface beyond what is explicitly modeled in the plan's threat_model: - T-12-13 (wizard never handles secrets): mitigated — no VAPID_PRIVATE_KEY or SESSION_SECRET inputs - T-12-14 (XSS via operator input): mitigated — no dangerouslySetInnerHTML in SetupPage.tsx (grep returns 0) - T-12-15 (app password disclosure): mitigated — type="password", never stored client-side - T-12-SC (new packages): mitigated — zero new npm packages ## TDD Gate Compliance - RED gate: `eb84e6e` test commit (17 failing tests — Task 2) — PRESENT - GREEN gate: `62d80f6` feat commit (all tests pass — Task 2) — PRESENT - RED gate: `9f20c8b` test commit (2 failing contract tests — Task 4 BUG 2) — PRESENT - GREEN gate: `120ce85` fix commit (all 245 tests pass — Task 4) — PRESENT - REFACTOR: no refactoring commit needed ## Self-Check: PASSED Files exist: - `apps/pwa/src/api/client.ts` — FOUND - `apps/pwa/src/routes/SetupPage.tsx` — FOUND - `apps/pwa/src/routes/SetupPage.test.tsx` — FOUND - `apps/pwa/src/api/setupClient.contract.test.ts` — FOUND - `apps/pwa/src/App.tsx` — FOUND - `apps/pwa/src/App.test.tsx` — FOUND Commits verified: - `eb84e6e` — Task 2 RED - `62d80f6` — Task 2 GREEN - `1587bca` — Task 3 - `9f20c8b` — Task 4 RED - `120ce85` — Task 4 GREEN Test suite: 245 passed | 0 failed TypeCheck: clean (0 errors) playwright-cli: /config 200 confirmed; redirect gate confirmed; DB validation 200; OIDC 400 (expected — Authelia unreachable)