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));