fix: recover gracefully from stale sessions causing repeated 401s

The SPA's session check (/api/auth/session) is cached indefinitely by
react-query (staleTime: Infinity, no refetch on focus/interval), so once a
protected page mounts successfully, the app never re-verifies auth. If the
server-side session later becomes invalid (server restart with in-memory
sessions, cookie/session expiry, etc.), every subsequent API call
(pages, scheduled-posts, audio-tracks, media, media/upload, ...) starts
failing with 401 in a loop with no way for the user to recover short of a
manual page reload.

Add a shared handleUnauthorized() in queryClient.ts that redirects to
/login on any non-auth API 401, wired into both the shared fetch helpers
(apiRequest/getQueryFn) and the raw fetch() calls used for file uploads
and Remotion rendering, which also lacked this recovery path. Also add the
missing credentials: "include" to those raw fetch() calls for consistency
with the rest of the app's API requests.
This commit is contained in:
Claude committed 2026-07-27 11:56:56 +00:00
1 parent 75e1d88c26
commit 2188fa458d
9 files changed
+81 -24

No files matched your search

+28 -4
View File
@@ -1,7 +1,30 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query";
async function throwIfResNotOk(res: Response) {
// Les routes d'authentification elles-mêmes ne doivent jamais déclencher
// de redirection automatique (ex: mauvais mot de passe sur /login).
function isAuthRoute(url: string): boolean {
return url.startsWith("/api/auth/");
}
// Si la session serveur a expiré (ex: redémarrage du serveur, expiration du
// cookie) alors que la SPA pense toujours l'utilisateur connecté (le check
// de session initial est mis en cache indéfiniment par react-query), toutes
// les requêtes suivantes échouent silencieusement en 401 sans jamais
// renvoyer l'utilisateur vers l'écran de connexion. On force donc un
// rechargement vers /login dès qu'une requête API (hors routes d'auth)
// renvoie 401, ce qui réinitialise aussi le cache react-query.
export function handleUnauthorized(url: string) {
if (isAuthRoute(url) || window.location.pathname === "/login") {
return;
}
window.location.href = "/login";
}
async function throwIfResNotOk(res: Response, url: string) {
if (!res.ok) {
if (res.status === 401) {
handleUnauthorized(url);
}
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
@@ -19,7 +42,7 @@ export async function apiRequest(
credentials: "include",
});
await throwIfResNotOk(res);
await throwIfResNotOk(res, url);
return res;
}
@@ -29,7 +52,8 @@ export const getQueryFn: <T>(options: {
}) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey.join("/") as string, {
const url = queryKey.join("/") as string;
const res = await fetch(url, {
credentials: "include",
});
@@ -37,7 +61,7 @@ export const getQueryFn: <T>(options: {
return null;
}
await throwIfResNotOk(res);
await throwIfResNotOk(res, url);
return await res.json();
};