Compare commits
No commits in common. "3d9f93e2044fc7881fd38480a726dd8d2b48f64c" and "c17b5b0060b1cef9e01d06382d310ac117bbfca7" have entirely different histories.
3d9f93e204
...
c17b5b0060
3 changed files with 2 additions and 129 deletions
|
|
@ -1,66 +0,0 @@
|
||||||
// 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -65,23 +65,6 @@ export function setTokenStore(store: TokenStore): void {
|
||||||
activeTokenStore = store;
|
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
|
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
|
||||||
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
||||||
|
|
@ -141,19 +124,6 @@ export class ApiClient {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
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
|
// Fallback for a response that couldn't even be parsed as JSON — no
|
||||||
// hardcoded string, always the real enum member.
|
// hardcoded string, always the real enum member.
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,6 @@
|
||||||
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
|
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
|
||||||
import {
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
||||||
createContext,
|
import { apiClient } from "../../api/client";
|
||||||
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}. */
|
/** Shape of the auth state/actions exposed via {@link useAuth}. */
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
|
|
@ -46,12 +37,6 @@ const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<SafeUserProfile | null>(null);
|
const [user, setUser] = useState<SafeUserProfile | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
apiClient
|
apiClient
|
||||||
|
|
@ -63,22 +48,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
.finally(() => setIsLoading(false));
|
.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) => {
|
const signup = useCallback(async (input: SignupInput) => {
|
||||||
try {
|
try {
|
||||||
setUser(await apiClient.signup(input));
|
setUser(await apiClient.signup(input));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue