From b89599d66a60516b9e00367bede200a4e4833b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:37:25 +0100 Subject: [PATCH] fix(auth): end the session when a refetch of the account says 401 A failed refetch keeps the last good payload in the query cache, so a session that expired while the app was open still read as signed in: the shell kept its pages, and every save failed with "sign in again". A 401 or 403 now ends the session even with a cached account; a network failure or a 5xx still holds the last known state. --- .../__tests__/use-auth-offline-gate.test.ts | 24 +++++++++++++++++++ src/hooks/use-auth.ts | 11 +++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/hooks/__tests__/use-auth-offline-gate.test.ts b/src/hooks/__tests__/use-auth-offline-gate.test.ts index 1d5c32514..2d7f9f180 100644 --- a/src/hooks/__tests__/use-auth-offline-gate.test.ts +++ b/src/hooks/__tests__/use-auth-offline-gate.test.ts @@ -136,6 +136,30 @@ describe("useAuth — last-known state on a non-verdict failure", () => { }); }); +describe("useAuth — a verdict on a refetch", () => { + it("ends the session on a 401 although the cache still holds the account", () => { + store.set("healthlog-was-authenticated", "1"); + queryState.data = { id: "u1", username: "someone" }; + queryState.isError = true; + queryState.error = new ApiError("unauthorized", 401); + + const auth = useAuth(); + expect(auth.isAuthenticated).toBe(false); + expect(auth.user).toBeNull(); + expect(auth.isAuthUnknown).toBe(false); + }); + + it("keeps the cached account through a network failure on a refetch", () => { + queryState.data = { id: "u1", username: "someone" }; + queryState.isError = true; + queryState.error = new TypeError("Failed to fetch"); + + const auth = useAuth(); + expect(auth.isAuthenticated).toBe(true); + expect(auth.user).toEqual({ id: "u1", username: "someone" }); + }); +}); + describe("session-end marker lifecycle", () => { it("clearCachesForSessionEnd drops the marker so the next cold offline launch lands on the login gate", () => { store.set("healthlog-was-authenticated", "1"); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 63f8b056a..7a162487a 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -536,11 +536,14 @@ export function useAuth() { // /auth/login and wiped every offline cache (data 7→0 entries, pages 1→0 // in the audit measurement), making the whole offline story unreachable. const verdictUnknown = query.isError && isAuthVerdictUnknown(query.error); - const isAuthenticated = - !!query.data || (verdictUnknown && readWasAuthenticated()); + // A 401/403 on a refetch ends the session although the cache still holds + // the last good payload: React Query keeps `data` when a refetch fails. + const sessionEnded = query.isError && !verdictUnknown; + const user = sessionEnded ? null : (query.data ?? null); + const isAuthenticated = !!user || (verdictUnknown && readWasAuthenticated()); return { - user: query.data ?? null, + user, isLoading: query.isLoading, isAuthenticated, /** @@ -550,7 +553,7 @@ export function useAuth() { * wipe: a wipe without a 401/403 verdict would destroy the offline * caches on every network blip. */ - isAuthUnknown: !query.data && verdictUnknown, + isAuthUnknown: !user && verdictUnknown, error: query.error, refetch: query.refetch, };