--- 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. Create `.planning/quick/260606-tv8-fix-missing-sign-in-redirect-in-the-pwa-/260606-tv8-SUMMARY.md` when done. OUT OF SCOPE (orchestrator handles after merge, in main tree): docker compose rebuild, browser/playwright re-test, tunnel deploy. Do NOT run docker or playwright in any task.