diff --git a/.planning/quick/260606-tv8-fix-missing-sign-in-redirect-in-the-pwa-/260606-tv8-PLAN.md b/.planning/quick/260606-tv8-fix-missing-sign-in-redirect-in-the-pwa-/260606-tv8-PLAN.md
new file mode 100644
index 0000000..a59a14a
--- /dev/null
+++ b/.planning/quick/260606-tv8-fix-missing-sign-in-redirect-in-the-pwa-/260606-tv8-PLAN.md
@@ -0,0 +1,204 @@
+---
+phase: quick-260606-tv8
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - apps/api/src/index.ts
+ - apps/api/tests/routes/login.test.ts
+ - apps/pwa/src/api/client.ts
+ - apps/pwa/src/lib/loginRedirect.ts
+ - apps/pwa/src/lib/loginRedirect.test.ts
+ - apps/pwa/src/components/CalendarShell.tsx
+autonomous: true
+requirements: [AUTH-ENTRY-01]
+must_haves:
+ truths:
+ - "An unauthenticated top-level browser nav to /api/login completes the OIDC login and lands back on the app, logged in"
+ - "An unauthenticated PWA load redirects the browser to /api/login instead of showing a dead-end 'Sign-in required'"
+ - "A genuine /api/me failure does NOT cause an infinite login redirect loop (one-shot guard)"
+ artifacts:
+ - path: apps/api/src/index.ts
+ provides: "Guarded GET /api/login route that redirects to /"
+ contains: "/api/login"
+ - path: apps/pwa/src/lib/loginRedirect.ts
+ provides: "One-shot sessionStorage-guarded login-redirect helper"
+ - path: apps/api/tests/routes/login.test.ts
+ provides: "Backend test: /api/login redirects to /"
+ - path: apps/pwa/src/lib/loginRedirect.test.ts
+ provides: "Frontend test: unauth redirect + one-shot loop guard"
+ key_links:
+ - from: apps/pwa/src/components/CalendarShell.tsx
+ to: apps/pwa/src/lib/loginRedirect.ts
+ via: "useEffect on meQuery.isError calls maybeRedirectToLogin()"
+ pattern: "maybeRedirectToLogin"
+ - from: apps/pwa/src/components/CalendarShell.tsx
+ to: apps/pwa/src/lib/loginRedirect.ts
+ via: "meQuery.isSuccess clears the one-shot flag"
+ pattern: "clearLoginRedirect"
+---
+
+
+Fix the missing sign-in redirect: unauthenticated visitors to the PWA currently hit a
+dead-end "Sign-in required" message with no way to log in, because the SPA reaches the API
+only via `fetch()`, and the OIDC guard's 302 to Authelia is CORS-blocked for XHR.
+
+Two coordinated changes:
+1. Backend: add a guarded `GET /api/login` route that redirects to `/`. A TOP-LEVEL browser
+ navigation (not fetch) to this guarded route triggers the full OIDC login flow and returns
+ to the app cleanly — no CORS problem (Authelia 302 is followed at the document level).
+2. Frontend: when `/api/me` fails because the user is unauthenticated, perform a full-page
+ navigation to `/api/login` (one-shot, loop-guarded via sessionStorage) instead of the
+ dead-end message.
+
+Purpose: Restore the auth entry path for the Phase 03 PWA (Gate 2 live-verification gap).
+Output: Backend login route + tests; frontend redirect helper + wiring + tests.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/STATE.md
+@./CLAUDE.md
+@apps/api/src/index.ts
+@apps/api/src/auth/middleware.ts
+@apps/pwa/src/api/client.ts
+@apps/pwa/src/components/CalendarShell.tsx
+@apps/api/tests/routes/me.test.ts
+@apps/pwa/src/components/CalendarShell.test.tsx
+
+
+
+
+
+ Task 1: Add guarded GET /api/login backend route + test
+ apps/api/src/index.ts, apps/api/tests/routes/login.test.ts
+
+ - GET /api/login returns a redirect (302) to '/' when the request reaches the handler
+ (i.e. the OIDC guard has already let it through, or dev-bypass is active).
+ - The route is mounted AFTER the `app.use('/api/*', oidcAuthMiddleware())` guard so that an
+ unauthenticated top-level nav is intercepted by the guard first (guard 302 → Authelia →
+ /callback → middleware sets `continue` back to /api/login → handler 302 → /).
+ - Under dev-bypass (DEV_AUTH_BYPASS=true), the guard is not mounted, so /api/login still
+ reaches the handler and redirects to '/'.
+
+
+ In apps/api/src/index.ts, register `app.get('/api/login', (c) => c.redirect('/'))` in the
+ protected-routes block AFTER the `if (!devBypassActive) { app.use('/api/*', oidcAuthMiddleware()) }`
+ guard and alongside the existing `app.route('/api/me', ...)` etc. mounts (currently ~lines
+ 48-51). Add a short comment explaining the flow: top-level nav → guard 302 → Authelia → /callback
+ → `continue` cookie returns to /api/login (now authenticated) → redirect to /. Do NOT place it
+ before the guard, and do NOT use `app.route` (it is a single bare GET, not a router).
+
+ Create apps/api/tests/routes/login.test.ts mirroring apps/api/tests/routes/me.test.ts:
+ - Reuse the same hoisted DB mock and the oidcAuthMiddleware passthrough mock pattern from me.test.ts.
+ - Test under DEV_AUTH_BYPASS='true' (set process.env BEFORE importing app; vi.resetModules per test):
+ `await app.request('/api/login')` returns status 302 and the `location` header equals '/'.
+ - Test under no DEV_AUTH_BYPASS with the oidcAuthMiddleware passthrough mock: `/api/login`
+ reaches the handler and returns 302 → '/' (passthrough lets it through, mirroring me.test's
+ OIDC-path block).
+ Use `res.headers.get('location')` to assert the redirect target.
+
+
+ cd apps/api && pnpm exec vitest run tests/routes/login.test.ts && pnpm exec tsc --noEmit
+
+ GET /api/login redirects to '/' under both bypass and OIDC-passthrough paths; login.test.ts passes; apps/api tsc clean.
+
+
+
+ Task 2: Add one-shot login-redirect helper + tests
+ apps/pwa/src/lib/loginRedirect.ts, apps/pwa/src/lib/loginRedirect.test.ts, apps/pwa/src/api/client.ts
+
+ - maybeRedirectToLogin(): if the sessionStorage flag 'familysync.loginRedirectAttempted' is
+ NOT set, set it and assign window.location.href = '/api/login' (returns true = redirected).
+ If the flag IS already set, do nothing and return false (caller falls through to the
+ "Sign-in required" message — we already bounced through login and still can't authenticate).
+ - clearLoginRedirect(): removes the sessionStorage flag (called on a successful /api/me load).
+ - Helper must guard against `typeof window === 'undefined'` / missing sessionStorage so it is
+ safe under SSR/test environments.
+
+
+ Create apps/pwa/src/lib/loginRedirect.ts exporting:
+ - const LOGIN_REDIRECT_KEY = 'familysync.loginRedirectAttempted'
+ - maybeRedirectToLogin(): boolean — implements the one-shot guard above using
+ sessionStorage.getItem/setItem and `window.location.href = '/api/login'`. Guard with a
+ window/sessionStorage availability check; return false if unavailable.
+ - clearLoginRedirect(): void — sessionStorage.removeItem(LOGIN_REDIRECT_KEY) (guarded).
+
+ Update the stale/misleading comment block at apps/pwa/src/api/client.ts top (lines 1-10) and
+ the inline comment at the fetchMe failure path (~line 30): replace the false claim that "the
+ browser will follow the 302 redirect to Authelia automatically" — for XHR/fetch the cross-origin
+ 302 to Authelia is CORS-blocked, so re-auth requires a TOP-LEVEL navigation to /api/login (see
+ loginRedirect.ts). Keep fetchMe itself a pure data fetch (still throws on non-ok); do NOT put the
+ redirect inside fetchMe.
+
+ Create apps/pwa/src/lib/loginRedirect.test.ts (vitest + jsdom):
+ - beforeEach: clear sessionStorage; stub window.location with a writable href (e.g.
+ Object.defineProperty(window, 'location', { value: { href: '' }, writable: true }) or
+ vi.stubGlobal as the existing tests do).
+ - maybeRedirectToLogin() sets href to '/api/login', sets the flag, returns true on first call.
+ - A second maybeRedirectToLogin() call does NOT change href again and returns false (one-shot).
+ - clearLoginRedirect() removes the flag; a subsequent maybeRedirectToLogin() redirects again.
+
+
+ cd apps/pwa && pnpm exec vitest run src/lib/loginRedirect.test.ts && pnpm exec tsc --noEmit
+
+ loginRedirect helper enforces one-shot redirect; tests pass; client.ts comment corrected; apps/pwa tsc clean.
+
+
+
+ Task 3: Wire redirect into CalendarShell meQuery handling
+ apps/pwa/src/components/CalendarShell.tsx
+
+ In apps/pwa/src/components/CalendarShell.tsx, import maybeRedirectToLogin and clearLoginRedirect
+ from '../lib/loginRedirect.js'.
+
+ Add two useEffects (place near the existing eventsService sync effect, ~line 171):
+ - On meQuery.isError: call maybeRedirectToLogin(). If it returns true (a redirect was triggered),
+ the page is navigating away — the "Sign-in required" branch will unmount; if it returns false
+ (already attempted), let the existing dead-end branch render. Depend on [meQuery.isError].
+ - On meQuery.isSuccess: call clearLoginRedirect() so a later session expiry can redirect again.
+ Depend on [meQuery.isSuccess].
+
+ Leave the existing `if (meQuery.isError) { return }` block as the fall-through
+ for the already-attempted case (do not delete it). Do NOT call window.location directly in the
+ component — go through the helper so the one-shot guard is centralized and tested.
+
+ Verify the CalendarShell.test.tsx smoke test still passes (it mocks fetchMe success, so neither
+ redirect path fires); if the test environment lacks window.location/sessionStorage stubs and the
+ success path now calls clearLoginRedirect, ensure the helper's guards keep it a no-op (handled in
+ Task 2). If the existing test mocks meQuery.isError anywhere, confirm it still renders without an
+ unhandled navigation (the helper redirect is guarded and href assignment is inert under jsdom).
+
+
+ cd apps/pwa && pnpm exec vitest run src/components/CalendarShell.test.tsx && pnpm exec tsc --noEmit
+
+ CalendarShell redirects to /api/login on unauthenticated meQuery error (one-shot) and clears the flag on success; CalendarShell smoke test + apps/pwa tsc clean.
+
+
+
+
+
+- Backend: `cd apps/api && pnpm exec vitest run && pnpm exec tsc --noEmit` — login.test.ts green, no type errors.
+- Frontend: `cd apps/pwa && pnpm exec vitest run && pnpm exec tsc --noEmit` — loginRedirect + CalendarShell tests green, no type errors.
+- Grep confirms the previously-missing redirect now exists: `grep -rn "api/login" apps/pwa/src` returns the helper, and `grep -n "/api/login" apps/api/src/index.ts` returns the route.
+
+
+
+- `GET /api/login` is mounted behind the OIDC guard and redirects authenticated requests to '/'.
+- Unauthenticated PWA load triggers a single full-page navigation to '/api/login' (no CORS-blocked XHR, no infinite loop).
+- A genuine backend error after one redirect falls through to "Sign-in required" instead of looping.
+- The stale comment in client.ts no longer claims fetch follows the Authelia 302 automatically.
+- All unit tests pass; both apps type-check clean.
+
+
+