From a54cb850270e48323b67e34db8550591bfe23029 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 8 Sep 2026 01:47:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(auth):=20d=C3=A9connexion=20forc=C3=A9e=20q?= =?UTF-8?q?uand=20une=20requ=C3=AAte=20401=20en=20cours=20de=20session=20(?= =?UTF-8?q?#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La session expire déjà (JWT 7 j), mais le client web n'y réagissait pas : seul le `GET /auth/me` du montage de `AuthProvider` attrapait un 401 ; un 401 de n'importe quel autre endpoint ne faisait que remonter une erreur locale, l'UI connectée restant affichée jusqu'au prochain rechargement complet. Sur l'app native (rarement redémarrée, token persisté) la session paraissait ne jamais finir. - `client.ts` : sur un `401 NOT_AUTHENTICATED` pour toute requête autre que le probe `GET /auth/me`, `ApiClient._request` purge le token store et appelle un handler `onSessionExpired` (enregistré via `setSessionExpiredHandler`). L'`ApiError` continue d'être levée comme avant. - `AuthContext.tsx` : `AuthProvider` enregistre ce handler au montage — `setUser(null)` + `navigate(/login, { replace: true })`, gardé par un `userRef` pour être un no-op si déjà déconnecté. `ApiClient` restant agnostique du framework, c'est bien lui qui délègue la navigation/état. Pas de changement API : le back renvoie déjà 401 sur JWT expiré (`require-auth.ts`), couvert par sa suite Mocha. Tests : `apps/web/cypress/e2e/session-expiry.cy.ts` — un 401 en cours de session redirige vers `/login` et vide l'état (un retour sur une route gardée re-redirige) ; le 401 du `/auth/me` initial d'un visiteur non connecté ne déclenche pas ce chemin (la landing reste affichée). Suite e2e existante (auth / layout / account / recipes / onboarding / preferences) revérifiée sans régression. Closes #108 Co-Authored-By: Claude Sonnet 5 --- apps/web/cypress/e2e/session-expiry.cy.ts | 66 ++++++++++++++++++++++ apps/web/src/api/client.ts | 30 ++++++++++ apps/web/src/features/auth/AuthContext.tsx | 35 +++++++++++- 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 apps/web/cypress/e2e/session-expiry.cy.ts diff --git a/apps/web/cypress/e2e/session-expiry.cy.ts b/apps/web/cypress/e2e/session-expiry.cy.ts new file mode 100644 index 0000000..3e064e8 --- /dev/null +++ b/apps/web/cypress/e2e/session-expiry.cy.ts @@ -0,0 +1,66 @@ +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml). apps/api's own Mocha suite covers the real +// 401 behavior (an expired/invalid JWT -> `require-auth` -> 401 +// NOT_AUTHENTICATED); this only covers the *web client's* reaction to it. + +import { ErrorCode } from "@batch-cooking/shared"; + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: 1, + dietId: null, +}; + +// Issue #108 — a session that expired (or was invalidated) server-side +// mid-use only took effect on the next full reload: any other request just +// surfaced a local error while the authenticated UI stayed put. `ApiClient` +// now reacts to a `401 NOT_AUTHENTICATED` on any request other than the +// initial `/auth/me` probe by dropping the session and bouncing to +// `/login`. +describe("Session expiry", () => { + it("bounces to /login when a request 401s mid-session", () => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + // Logged in, planning loads fine. + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning"); + + cy.visit("/"); + cy.wait("@getPlanning"); + cy.contains("h1", "Planning de la semaine").should("be.visible"); + + // The session dies server-side: every endpoint now 401s, including the + // `/auth/me` probe a fresh load would make. + const unauthenticated = { + statusCode: 401, + body: { code: ErrorCode.NOT_AUTHENTICATED, message: "not authenticated" }, + }; + cy.intercept("GET", "**/auth/me", unauthenticated); + cy.intercept("GET", /\/planning\?/, unauthenticated); + + // The next planning fetch (a week-nav click) 401s -> bounced to /login. + cy.get(".week-nav__arrow").last().click(); + cy.url().should("include", "/login"); + + // ...and it's a real logout: reloading a guarded route redirects again + // rather than showing stale authed content. + cy.visit("/recettes"); + cy.url().should("include", "/login"); + }); + + it("does not bounce a logged-out visitor — the initial /auth/me 401 is the normal state", () => { + cy.intercept("GET", "**/auth/me", { + statusCode: 401, + body: { code: ErrorCode.NOT_AUTHENTICATED, message: "not authenticated" }, + }); + + cy.visit("/"); + // `/` for a logged-out visitor is the public landing page (issue #41), + // not a redirect to /login — the `/auth/me` probe 401 must not trip the + // session-expiry handler. + cy.contains("a", "Se connecter").should("be.visible"); + cy.url().should("not.include", "/login"); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index e14bf43..503e8dd 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -65,6 +65,23 @@ export function setTokenStore(store: TokenStore): void { activeTokenStore = store; } +/** + * Called by {@link ApiClient._request} the moment any request *other than* + * the initial `GET /auth/me` probe comes back `401 NOT_AUTHENTICATED` — i.e. + * a session that *was* valid has expired (7-day JWT) or been invalidated + * server-side mid-use. The token store is already cleared by the time this + * fires; the app-level handler (registered by `AuthProvider`) resets the + * in-memory auth state and sends the user to `/login`. `null` until + * registered — on the server, in tests, and before `AuthProvider` mounts, + * nobody reacts and the `ApiError` just propagates as before (issue #108). + */ +let onSessionExpired: (() => void) | null = null; + +/** Registers (or, with `null`, clears) the {@link onSessionExpired} handler — `AuthProvider` does both, on mount and unmount. */ +export function setSessionExpiredHandler(handler: (() => void) | null): void { + onSessionExpired = handler; +} + /** * Thrown by {@link ApiClient} whenever the API responds with a non-2xx * status. Carries the same {@link ErrorCode} the API returned, so callers @@ -124,6 +141,19 @@ export class ApiClient { if (!response.ok) { const body = (await response.json().catch(() => null)) as ApiErrorResponse | null; + // A session that was valid has just died server-side (expired JWT, + // or a future "log out everywhere"): drop the local token and let + // the app react (see `onSessionExpired`). Excludes the initial + // `GET /auth/me` probe — a 401 there is the normal "not logged in + // yet" state `AuthProvider` already handles, not a mid-use expiry. + if ( + response.status === 401 && + body?.code === ErrorCode.NOT_AUTHENTICATED && + path !== "/auth/me" + ) { + await activeTokenStore.clear(); + onSessionExpired?.(); + } // Fallback for a response that couldn't even be parsed as JSON — no // hardcoded string, always the real enum member. throw new ApiError( diff --git a/apps/web/src/features/auth/AuthContext.tsx b/apps/web/src/features/auth/AuthContext.tsx index 0539913..7036cda 100644 --- a/apps/web/src/features/auth/AuthContext.tsx +++ b/apps/web/src/features/auth/AuthContext.tsx @@ -1,6 +1,15 @@ import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared"; -import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"; -import { apiClient } from "../../api/client"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { useNavigate } from "react-router-dom"; +import { apiClient, setSessionExpiredHandler } from "../../api/client"; /** Shape of the auth state/actions exposed via {@link useAuth}. */ interface AuthContextValue { @@ -37,6 +46,12 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); + const navigate = useNavigate(); + + // Read by the session-expiry handler below without making it a dependency + // (it registers once): a stray call while already logged out is a no-op. + const userRef = useRef(user); + userRef.current = user; useEffect(() => { apiClient @@ -48,6 +63,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { .finally(() => setIsLoading(false)); }, []); + useEffect(() => { + // `ApiClient` is framework-agnostic (see its own doc comment) so it + // can't navigate or touch React state — it just calls this when a live + // session 401s mid-use (the 7-day JWT expired, say). Without it an + // expired session only took effect on the next full reload; any other + // request just surfaced a local error while the authenticated UI stayed + // put (issue #108). The token store is already cleared client-side by + // the time this runs. + setSessionExpiredHandler(() => { + if (userRef.current === null) return; + setUser(null); + void navigate("/login", { replace: true }); + }); + return () => setSessionExpiredHandler(null); + }, [navigate]); + const signup = useCallback(async (input: SignupInput) => { try { setUser(await apiClient.signup(input)); -- 2.45.2