Merge pull request 'fix(auth): déconnexion forcée quand une requête 401 en cours de session (#108)' (#116) from fix/client-force-logout-on-401 into main
Some checks failed
CI / lint (push) Successful in 3m51s
CI / build (push) Successful in 4m49s
CI / intent-service-test (push) Successful in 5m10s
CI / e2e (push) Successful in 12m10s
CI / test (push) Failing after 19m12s
CI / build-android (push) Has been skipped

This commit is contained in:
kyuno 2026-09-08 04:14:48 +02:00
commit 3d9f93e204
3 changed files with 129 additions and 2 deletions

View file

@ -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");
});
});

View file

@ -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(

View file

@ -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<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SafeUserProfile | null>(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));