--- phase: 06-ux-polish plan: "05" subsystem: pwa/auth tags: [tdd, auth, session-expiry, d-10, d-11] dependency_graph: requires: [] provides: - SessionExpiredError (apps/pwa/src/api/client.ts) - handleAuthResponse (apps/pwa/src/api/client.ts) - AuthSplash component (apps/pwa/src/components/AuthSplash.tsx) - sessionExpired flag (apps/pwa/src/store/calendarStore.ts) - QueryCache/MutationCache onError wiring (apps/pwa/src/main.tsx) - recurrenceUntil/recurrenceCount on CreateEventPayload (client.ts) - hasRrule on CalendarOccurrence client mirror (client.ts) affects: - Plan 06-06 (EventForm consumes recurrenceUntil/recurrenceCount payload fields and occurrence.hasRrule) - CalendarShell (auth-gated render replaces optimistic pre-auth paint) tech_stack: added: [] patterns: - TDD RED→GREEN for typed session-expiry detection (client.ts) - TanStack Query v5 global error handler via QueryCache/MutationCache constructor (NOT defaultOptions.onError) - Auth-gated render gate (meQuery.isSuccess required before CalendarContent paints) - One-shot redirect guard re-arm via clearLoginRedirect() + maybeRedirectToLogin() key_files: created: - apps/pwa/src/components/AuthSplash.tsx modified: - apps/pwa/src/api/client.ts - apps/pwa/src/api/client.test.ts - apps/pwa/src/components/CalendarShell.tsx - apps/pwa/src/main.tsx - apps/pwa/src/store/calendarStore.ts decisions: - D-10: unauthenticated cold load shows only the neutral AuthSplash ("Signing you in") — no calendar shell, skeleton, or pre-auth flash; CalendarContent renders only on meQuery.isSuccess - D-11: mid-use session expiry detected via SessionExpiredError (opaqueredirect || 401) across all fetch wrappers; global QueryCache/MutationCache onError sets sessionExpired flag → AuthSplash "Session expired / Signing you back in…" interstitial → redirect after 1.5s - TanStack v5: QueryCache({onError})/MutationCache({onError}) constructor pattern confirmed; defaultOptions.onError is removed in v5 and was NOT used - One-shot guard: clearLoginRedirect() re-arms the guard before maybeRedirectToLogin() in the session-expiry path (prevents redirect loop) - In-flight write replay deferred (D-11 nice-to-have, RESEARCH Open Question 3) metrics: duration_minutes: 35 completed_date: "2026-06-10" tasks_completed: 4 files_changed: 6 --- # Phase 06 Plan 05: Auth-Flow Gating + Session-Expiry (D-10/D-11) Summary **One-liner:** Typed `SessionExpiredError` centralized across all fetch wrappers (TDD), `AuthSplash` component gating the app render until `meQuery.isSuccess`, and a global `QueryCache`/`MutationCache` `onError` handler that surfaces a "Session expired" interstitial and redirects on any mid-use 401. ## Tasks Completed | Task | Name | Commit | Files | |------|------|--------|-------| | 1 (RED) | Failing SessionExpiredError detection tests | `e5072ff` | client.test.ts | | 1 (GREEN) | Centralize session-expiry detection + extend client types | `d7d4023` | client.ts, client.test.ts | | 2 | AuthSplash component + gate CalendarShell render on auth state | `e7b34a5` | AuthSplash.tsx, CalendarShell.tsx | | 3 | Global session-expiry interstitial via QueryCache/MutationCache onError | `139ef00` | main.tsx, calendarStore.ts, CalendarShell.tsx | | 4 (checkpoint) | playwright-cli — no pre-auth flash; clean session-expiry redirect | (verification only) | — | | Follow-up (RED) | Failing dead-end AuthSplash test for exhausted redirect guard | `36ef7a0` | CalendarShell.test.tsx | | Follow-up (fix) | Make AuthSplash dead-end state reachable + persist redirect guard | `e392c69` | CalendarShell.tsx | ## What Was Built ### Task 1: SessionExpiredError + handleAuthResponse (TDD) Added to `apps/pwa/src/api/client.ts`: - `class SessionExpiredError extends Error` with `Object.setPrototypeOf(this, SessionExpiredError.prototype)` for reliable `instanceof` checks across TypeScript compilation boundaries. - `handleAuthResponse(res, label)` helper: throws `SessionExpiredError` on `res.type === 'opaqueredirect' || res.status === 401`; throws a generic `Error` on other non-ok responses; passes through on ok. - `redirect: 'manual'` added to all six fetch wrappers (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`) — matching the existing `fetchMe` pattern. - Client type additions (exclusive client.ts ownership): `recurrenceUntil?: string` and `recurrenceCount?: number` on `CreateEventPayload` (D-06 payload contract for Plan 06-06); `hasRrule: boolean` on the client-side `CalendarOccurrence` mirror matching `expand.ts` exactly (Pitfall 4 — atomic mirror). TDD gate: RED commit (`e5072ff`) — tests failing with "SessionExpiredError is not a constructor". GREEN commit (`d7d4023`) — all client tests pass; 500 responses throw a generic Error, not SessionExpiredError. ### Task 2: AuthSplash + Gated CalendarShell (D-10) Created `apps/pwa/src/components/AuthSplash.tsx`: - Full-screen centered column (`height: 100dvh`, `--color-surface` background). - Loader2 spinner (24px, `--color-member-0`, global `spin` keyframe). - `role="status"` + `aria-label="Signing you in"`. - `state: 'loading' | 'redirecting' | 'dead-end'` prop driving copy per UI-SPEC Surface 1: - `loading`: "Signing you in" / Loader2 spinner. - `redirecting`: "Taking you to the sign-in page…" / Loader2 spinner. - `dead-end`: "Sign-in required. Tap here to try again." — tap calls `clearLoginRedirect()` then `maybeRedirectToLogin()`. - Logo lockup omitted gracefully (brand asset not present; no block on that). `CalendarShell.tsx` updated: - `meQuery.isLoading` → early-return `` (no skeleton or calendar paints pre-auth). - `meQuery.isError` → `` (replaces the old `role="alert"` "Sign-in required" block; existing `maybeRedirectToLogin()` effect still fires). - `CalendarContent`/`SkeletonCalendar` render only on `meQuery.isSuccess`. ### Task 3: Global Session-Expiry Interstitial (D-11) `calendarStore.ts`: added `sessionExpired: boolean` (default `false`) + `setSessionExpired(v: boolean)` action. `main.tsx`: `QueryClient` constructed with: ```ts queryCache: new QueryCache({ onError(error) { if (error instanceof SessionExpiredError) setSessionExpired(true) } }), mutationCache: new MutationCache({ onError(error) { if (error instanceof SessionExpiredError) setSessionExpired(true) } }), ``` TanStack Query v5 API confirmed via Context7 (`/tanstack/query`): `defaultOptions.onError` was removed in v5; `QueryCache`/`MutationCache` constructor `onError` is the correct path and always fires. `CalendarShell.tsx`: when `sessionExpired` is true, renders `` with the Surface-2 copy ("Session expired / Signing you back in…"); on mount schedules `clearLoginRedirect()` then `maybeRedirectToLogin()` after ~1.5s. ## Checkpoint Verification (Task 4) Verified via playwright-cli against desktop Chromium with `DEV_AUTH_BYPASS=true`: 1. **Cold load (D-10):** First painted frame = neutral "Signing you in" splash (`role=status`). No calendar shell, SkeletonCalendar, or "Sign-in required" alert before the redirect toward `/api/login`. PASS. 2. **Mid-use 401 (D-11):** Intercepted `/api/events` returning 401 → "Session expired / Signing you back in…" interstitial appeared → navigated to `/api/login` within ~2s. No hang, no generic error. PASS. ## Follow-Up Fix After Checkpoint The checkpoint surfaced two related issues: 1. **Dead-end state unreachable:** `CalendarShell` never rendered `AuthSplash state="dead-end"` — the render logic fell through to an empty fragment once the redirect guard was exhausted. 2. **One-shot redirect guard persistence:** The guard (`familysync.loginRedirectAttempted`) was cleared during the navigation to `/api/login`, so it was not available to the new page load; a fresh 401 immediately re-triggered the redirect loop. Fix (commits `36ef7a0` RED, `e392c69` fix): - `CalendarShell` now renders `` once the redirect guard is exhausted after the interstitial fires. - Guard persistence hardened: `clearLoginRedirect()` is called only at the point the user explicitly taps "Sign-in required. Tap here to try again." — not during the automatic redirect path. Re-verified PASS via playwright-cli after fix. ## Residual Device-Only Item **iOS-Safari standalone cold-load/redirect** remains a human/device checkpoint per 06-VALIDATION.md Manual-Only table. The standalone-mode OIDC redirect (no `window.location.href` cross-origin fallback) is not drivable in desktop Chromium. ## TDD Gate Compliance - RED commit (`test(06-05): ...`): `e5072ff` — tests failing with `SessionExpiredError is not a constructor` - GREEN commit (`feat(06-05): ...`): `d7d4023` — all client tests pass - Follow-up RED: `36ef7a0` — failing dead-end guard test - Follow-up fix: `e392c69` — guard and dead-end state corrected; full suite green - RED→GREEN order confirmed via `git log` ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Dead-end AuthSplash state unreachable + redirect guard not persisting** - **Found during:** Task 4 (playwright-cli checkpoint) - **Issue:** CalendarShell never rendered `AuthSplash state="dead-end"` (fall-through to empty fragment); the one-shot redirect guard was cleared during navigation, not on user tap, making the guard unavailable to the landing page on a fresh 401. - **Fix:** CalendarShell now renders the dead-end state once the redirect guard exhausts; guard is cleared only on explicit user tap in the dead-end handler. - **Files modified:** `apps/pwa/src/components/CalendarShell.tsx` - **Commits:** `36ef7a0` (RED), `e392c69` (fix) ## Threat Flags None beyond the plan's STRIDE register. T-06-05-info (pre-auth render gate), T-06-05-redirect (one-shot guard prevents redirect loop), T-06-05-session (opaqueredirect || 401 detection only — no token read), T-06-05-inflight (in-flight write replay deferred, documented). No new surfaces introduced. ## Self-Check: PASSED - `apps/pwa/src/components/AuthSplash.tsx` — FOUND (created) - `apps/pwa/src/api/client.ts` — FOUND (`class SessionExpiredError`, `handleAuthResponse`, `hasRrule`, `recurrenceUntil`) - `apps/pwa/src/main.tsx` — FOUND (`MutationCache`, `QueryCache`; no `defaultOptions.onError`) - `apps/pwa/src/store/calendarStore.ts` — FOUND (`sessionExpired`, `setSessionExpired`) - Commit `e5072ff` — FOUND (RED: test(06-05)) - Commit `d7d4023` — FOUND (GREEN: feat(06-05)) - Commit `e7b34a5` — FOUND (feat(06-05): gate app render) - Commit `139ef00` — FOUND (feat(06-05): global session-expiry interstitial) - Commit `36ef7a0` — FOUND (test(06-05): dead-end guard RED) - Commit `e392c69` — FOUND (fix(06-05): dead-end state reachable)