---
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'
---

<objective>
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.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.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
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add guarded GET /api/login backend route + test</name>
  <files>apps/api/src/index.ts, apps/api/tests/routes/login.test.ts</files>
  <behavior>
    - 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 '/'.
  </behavior>
  <action>
    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.

  </action>
  <verify>
    <automated>cd apps/api && pnpm exec vitest run tests/routes/login.test.ts && pnpm exec tsc --noEmit</automated>
  </verify>
  <done>GET /api/login redirects to '/' under both bypass and OIDC-passthrough paths; login.test.ts passes; apps/api tsc clean.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add one-shot login-redirect helper + tests</name>
  <files>apps/pwa/src/lib/loginRedirect.ts, apps/pwa/src/lib/loginRedirect.test.ts, apps/pwa/src/api/client.ts</files>
  <behavior>
    - 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.
  </behavior>
  <action>
    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.

  </action>
  <verify>
    <automated>cd apps/pwa && pnpm exec vitest run src/lib/loginRedirect.test.ts && pnpm exec tsc --noEmit</automated>
  </verify>
  <done>loginRedirect helper enforces one-shot redirect; tests pass; client.ts comment corrected; apps/pwa tsc clean.</done>
</task>

<task type="auto">
  <name>Task 3: Wire redirect into CalendarShell meQuery handling</name>
  <files>apps/pwa/src/components/CalendarShell.tsx</files>
  <action>
    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 <Sign-in required /> }` 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).

  </action>
  <verify>
    <automated>cd apps/pwa && pnpm exec vitest run src/components/CalendarShell.test.tsx && pnpm exec tsc --noEmit</automated>
  </verify>
  <done>CalendarShell redirects to /api/login on unauthenticated meQuery error (one-shot) and clears the flag on success; CalendarShell smoke test + apps/pwa tsc clean.</done>
</task>

</tasks>

<verification>
- 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.
</verification>

<success_criteria>

- `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.
  </success_criteria>

<output>
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.
</output>
