diff --git a/frontend-web/src/core/api/fetch-client.ts b/frontend-web/src/core/api/fetch-client.ts index f3fe4c4e..a44256ca 100644 --- a/frontend-web/src/core/api/fetch-client.ts +++ b/frontend-web/src/core/api/fetch-client.ts @@ -1,4 +1,8 @@ -import { getAuthorizationHeaderValue } from "@/core/auth"; +import { + clearStoredAuth, + getAuthorizationHeaderValue, + getStoredAuth, +} from "@/core/auth"; import { withCsrfHeaders } from "./csrf"; function withAuthHeaders(headers?: HeadersInit): Headers { @@ -8,13 +12,40 @@ function withAuthHeaders(headers?: HeadersInit): Headers { next.set("Authorization", authHeader); } return next; -} - -export function apiFetch(input: RequestInfo | URL, init?: RequestInit) { - return fetch(input, { +} + +function requestUrl(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return (input as Request).url ?? ""; +} + +export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) { + const response = await fetch(input, { ...init, credentials: init?.credentials ?? "include", headers: withAuthHeaders(init?.headers), }); + + // Session is considered lost ONLY when the backend actually answers 401. + // (Previously a stray localStorage change could log the user out client-side + // even though the session was still valid.) Auth endpoints are excluded so a + // failed/transient login attempt never wipes state, and we only act when we + // believed we were logged in — avoiding redirect loops on the login page. + if (response.status === 401) { + const url = requestUrl(input); + const isAuthEndpoint = url.includes("/api/v1/auth/"); + if (!isAuthEndpoint && getStoredAuth()) { + clearStoredAuth(); + if ( + typeof window !== "undefined" && + !window.location.hash.startsWith("#/login") + ) { + window.location.replace("#/login"); + } + } + } + + return response; } - + diff --git a/frontend-web/src/core/auth/sync.tsx b/frontend-web/src/core/auth/sync.tsx index e9f3b460..880f8824 100644 --- a/frontend-web/src/core/auth/sync.tsx +++ b/frontend-web/src/core/auth/sync.tsx @@ -23,7 +23,12 @@ export function AuthSyncWatcher() { useEffect(() => { function handleStorage(event: StorageEvent) { - if (event.key !== null && event.key !== AUTH_STORAGE_KEY) { + // Only react to explicit changes of the auth key. Ignore unrelated keys + // AND `localStorage.clear()` (event.key === null) — embedded sub-apps / + // iframes wiping their own storage used to spuriously log the user out + // here even though the backend session was still valid. Genuine session + // loss is now detected centrally via 401 responses in apiFetch. + if (event.key !== AUTH_STORAGE_KEY) { return; } const nextUserId = readUserId(); @@ -31,12 +36,11 @@ export function AuthSyncWatcher() { return; } lastUserIdRef.current = nextUserId; - if (nextUserId === null) { - window.location.replace("#/login"); + // Only resync on a genuine account switch in another tab (different, + // non-null user). Do NOT force a login redirect on null here. + if (nextUserId !== null) { window.location.reload(); - return; } - window.location.reload(); } window.addEventListener("storage", handleStorage);