diff --git a/.planning/todos/pending/session-timeout-redirect-to-signin.md b/.planning/todos/pending/session-timeout-redirect-to-signin.md new file mode 100644 index 0000000..aa008e6 --- /dev/null +++ b/.planning/todos/pending/session-timeout-redirect-to-signin.md @@ -0,0 +1,71 @@ +--- +title: Redirect to sign-in on session timeout instead of hanging / "couldn't load events" +date: 2026-06-07 +priority: high +phase_hint: Phase 03 (auth-entry UX) / Gate 2 polish +--- + +# Redirect to sign-in on session timeout instead of hanging + +## Problem (observed in live Gate 2 testing) + +When the OIDC session expires **mid-use** (e.g. user leaves the tab open past +the session/refresh lifespan, then tries to delete an event), the app does not +recognize the signed-out state. The action (delete) appears to hang — no sync +toast, no progress — and shortly after the calendar shows a generic +**"couldn't load events"** error. The user is fooled into thinking the app is +working/broken rather than understanding they've been signed out and need to +re-authenticate. + +## Root cause + +Re-auth is only wired to the **initial** `/api/me` failure: +- `CalendarShell.tsx` calls `maybeRedirectToLogin()` only on `meQuery.isError`. +- `maybeRedirectToLogin()` (apps/pwa/src/lib/loginRedirect.ts) is **one-shot** + (guarded by the `familysync.loginRedirectAttempted` sessionStorage flag) and + `clearLoginRedirect()` runs on a successful `/api/me`. +- Other calls don't drive re-auth: + - `fetchEvents` uses `redirect: 'follow'`, so a timed-out session 302s to + Authelia (cross-origin) → the XHR rejects → `eventsQuery` just errors + ("couldn't load events"), no redirect. + - Write mutations (create/edit/**delete**) call the API directly; on an + expired session the request 302s/opaque-fails and the mutation hangs/errors + with no re-auth and no clear feedback. + +So a session that expires after the first successful load has no path back to +login except a manual full refresh. + +## Desired behavior + +Any API response that indicates "not authenticated anymore" (401, or an +`opaqueredirect`/`type: 'opaqueredirect'` from the OIDC guard's 302 to Authelia) +should put the app into a clear **signed-out** state and trigger a top-level +re-auth navigation to `/api/login` — from ANY query or mutation, not just the +initial `/api/me`. The user should never be left staring at a hung action or a +generic data-load error when the real cause is an expired session. + +## Scope to decide when promoted + +- Centralize auth-expiry detection in the API client (apps/pwa/src/api/client.ts): + a shared helper that classifies a response as "session expired" (401 or + opaqueredirect) and throws a typed `SessionExpiredError`. +- Apply `redirect: 'manual'` consistently (fetchEvents/mutations) so the guard's + 302 is detectable instead of hanging on the cross-origin follow. +- A single TanStack Query handler (e.g. QueryCache/MutationCache `onError`, or + a small auth-state listener) that, on `SessionExpiredError`, calls a + re-armed `maybeRedirectToLogin()` — the one-shot loop guard must be reset so + a genuine later expiry can redirect again (clear the flag on detected expiry, + not only on successful `/api/me`). +- Decide UX: immediate redirect to `/api/login` vs a brief "Your session + expired — signing you back in…" toast/interstitial before redirecting (the + non-technical Apple member should not see a raw error). +- Make sure an in-flight write that fails on expiry is not silently lost — after + re-auth, either resurface the unsaved action or clearly tell the user it + wasn't saved (ties to T-03-18 no-silent-loss). + +## Notes + +- Relates to the earlier one-shot login redirect work (quick 260606-tv8) and the + OIDC state-cookie-churn fix (commit bb61d21 — gate events query on auth). +- Verify with a real expiry (or shorten `OIDC_AUTH_EXPIRES` / Authelia + `refresh_token_lifespan` in a test) rather than only reasoning about it.