diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 829a742ce..23fb7b3a7 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -48,7 +48,7 @@ export function AuthShell({ */ demoMode?: boolean; }) { - const { user, isAuthenticated, isLoading } = useAuth(); + const { user, isAuthenticated, isAuthUnknown, isLoading } = useAuth(); const { t } = useTranslations(); const pathname = usePathname(); const router = useRouter(); @@ -129,17 +129,35 @@ export function AuthShell({ useEffect(() => { if (!isLoading && !isAuthenticated && !isPublicPage) { - // Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe - // the in-memory query cache (the cross-user guard — the QueryClient - // outlives the navigation on this SPA and the health-data families are - // not user-scoped) plus the persisted IndexedDB + SW caches, so nothing - // leaks to the next account on a shared browser. Logout does the same - // from `useLogout`; this covers the expiry path logout never runs - // through. Best-effort; the redirect proceeds regardless. - clearCachesForSessionEnd(queryClient); + // Session END (expired / invalidated cookie → `/api/auth/me` 401/403). + // Wipe the in-memory query cache (the cross-user guard — the + // QueryClient outlives the navigation on this SPA and the health-data + // families are not user-scoped) plus the persisted IndexedDB + SW + // caches, so nothing leaks to the next account on a shared browser. + // Logout does the same from `useLogout`; this covers the expiry path + // logout never runs through. Best-effort; the redirect proceeds + // regardless. + // + // `isAuthUnknown` = the auth probe failed WITHOUT a session verdict + // (offline relaunch, network blip, 5xx) and no prior-session marker + // exists. Nothing to protect and nothing to render — send the visitor + // to the login gate, but never run the wipe off a non-verdict: the + // wipe on a network failure is exactly what used to destroy the + // offline caches. (With the marker present, `isAuthenticated` holds + // the last-known state and this branch never runs.) + if (!isAuthUnknown) { + clearCachesForSessionEnd(queryClient); + } router.replace("/auth/login"); } - }, [isLoading, isAuthenticated, isPublicPage, router, queryClient]); + }, [ + isLoading, + isAuthenticated, + isAuthUnknown, + isPublicPage, + router, + queryClient, + ]); // Redirect non-admins away from /admin useEffect(() => { diff --git a/src/hooks/__tests__/use-auth-offline-gate.test.ts b/src/hooks/__tests__/use-auth-offline-gate.test.ts new file mode 100644 index 000000000..cb5b54533 --- /dev/null +++ b/src/hooks/__tests__/use-auth-offline-gate.test.ts @@ -0,0 +1,144 @@ +/** + * Offline auth gate — a failed `/api/auth/me` is only a session END when the + * server said so (401/403). + * + * Pre-fix, ANY auth-probe error read as "logged out": an airplane-mode + * relaunch redirected to /auth/login and `clearCachesForSessionEnd()` wiped + * the SW data/page caches and the IndexedDB query snapshot — destroying + * exactly the installed-PWA-opened-offline scenario the offline plumbing + * exists for. These tests pin the classification (`isAuthVerdictUnknown`), + * the last-known-state fallback (`readWasAuthenticated` marker), and the + * marker's session-end lifecycle. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +const clearOfflineCachesForSessionEnd = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/pwa/query-persister", () => ({ + clearOfflineCachesForSessionEnd: () => clearOfflineCachesForSessionEnd(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +// Drivable query state so the hook's classification can be exercised for +// success / network-error / 401 without a fetch layer. +interface MockQueryState { + data: unknown; + isError: boolean; + error: unknown; + isLoading: boolean; +} +let queryState: MockQueryState; +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual< + typeof import("@tanstack/react-query") + >("@tanstack/react-query"); + return { + ...actual, + useQuery: () => ({ ...queryState, refetch: vi.fn() }), + useQueryClient: () => ({}), + useMutation: () => ({ mutate: () => undefined, isPending: false }), + }; +}); + +import { ApiError } from "@/lib/api/api-fetch"; +import { + clearCachesForSessionEnd, + isAuthVerdictUnknown, + readWasAuthenticated, + useAuth, +} from "../use-auth"; + +// Node test env has no localStorage — back it with a Map. +const store = new Map(); +beforeEach(() => { + store.clear(); + queryState = { data: undefined, isError: false, error: null, isLoading: false }; + vi.stubGlobal("localStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + clearOfflineCachesForSessionEnd.mockClear(); +}); + +describe("isAuthVerdictUnknown — failure classification", () => { + it("treats a fetch TypeError (offline / DNS) as no verdict", () => { + expect(isAuthVerdictUnknown(new TypeError("Failed to fetch"))).toBe(true); + }); + + it("treats the SW's synthetic 503 offline envelope as no verdict", () => { + expect( + isAuthVerdictUnknown(new ApiError("offline", 503, { offline: true })), + ).toBe(true); + }); + + it("treats an abort/timeout as no verdict", () => { + expect( + isAuthVerdictUnknown(new DOMException("timeout", "TimeoutError")), + ).toBe(true); + }); + + it("treats a 5xx as no verdict — a server hiccup is not a logout", () => { + expect(isAuthVerdictUnknown(new ApiError("boom", 500))).toBe(true); + }); + + it("treats 401 and 403 as the real session-end verdict", () => { + expect(isAuthVerdictUnknown(new ApiError("unauthorized", 401))).toBe(false); + expect(isAuthVerdictUnknown(new ApiError("forbidden", 403))).toBe(false); + }); +}); + +describe("useAuth — last-known state on a non-verdict failure", () => { + it("stays authenticated on a network failure when a prior session marker exists", () => { + store.set("healthlog-was-authenticated", "1"); + queryState.isError = true; + queryState.error = new TypeError("Failed to fetch"); + + const auth = useAuth(); + expect(auth.isAuthenticated).toBe(true); + expect(auth.isAuthUnknown).toBe(true); + expect(auth.user).toBeNull(); + }); + + it("reads unauthenticated-but-unknown on a network failure without a marker", () => { + queryState.isError = true; + queryState.error = new TypeError("Failed to fetch"); + + const auth = useAuth(); + expect(auth.isAuthenticated).toBe(false); + // The shell must NOT run the session-end wipe off this state. + expect(auth.isAuthUnknown).toBe(true); + }); + + it("reads a true 401 as session end — wipe and redirect may proceed", () => { + store.set("healthlog-was-authenticated", "1"); + queryState.isError = true; + queryState.error = new ApiError("unauthorized", 401); + + const auth = useAuth(); + expect(auth.isAuthenticated).toBe(false); + expect(auth.isAuthUnknown).toBe(false); + }); +}); + +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"); + expect(readWasAuthenticated()).toBe(true); + + clearCachesForSessionEnd(new QueryClient()); + + expect(readWasAuthenticated()).toBe(false); + expect(clearOfflineCachesForSessionEnd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 7330c70c1..c4bb806f7 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -9,7 +9,7 @@ import { import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { queryKeys } from "@/lib/query-keys"; -import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch"; +import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch"; import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useTranslations } from "@/lib/i18n/context"; import type { @@ -149,6 +149,64 @@ export interface AuthUser { moduleAvailability?: Partial>; } +/** + * Non-sensitive "a session existed here" marker (localStorage boolean, no + * identity data). A cold offline launch cannot reach `/api/auth/me`, so the + * auth query errors without ever answering the session question — the marker + * is the last-known state that lets the shell render the cached app chrome + * instead of bouncing to the login gate and wiping the offline caches. Set on + * every successful `/api/auth/me`, cleared on every session END (logout and + * true 401/403). + */ +const WAS_AUTHENTICATED_KEY = "healthlog-was-authenticated"; + +export function readWasAuthenticated(): boolean { + try { + return localStorage.getItem(WAS_AUTHENTICATED_KEY) === "1"; + } catch { + return false; + } +} + +function storeWasAuthenticated(): void { + try { + localStorage.setItem(WAS_AUTHENTICATED_KEY, "1"); + } catch { + /* storage unavailable — the marker is best-effort */ + } +} + +function clearWasAuthenticated(): void { + try { + localStorage.removeItem(WAS_AUTHENTICATED_KEY); + } catch { + /* best effort */ + } +} + +/** + * True when an `/api/auth/me` failure carries NO verdict on the session — + * a network-level failure (fetch `TypeError` offline, abort/timeout), the + * service worker's synthetic 503 offline envelope (`meta.offline`), the + * browser reporting itself offline, or a server-side 5xx. None of these mean + * "logged out"; only an explicit 401/403 does. Treating them as session end + * used to redirect an offline relaunch to /auth/login and wipe the offline + * caches — destroying exactly the installed-PWA-opened-offline scenario the + * SW + query persister exist for. + */ +export function isAuthVerdictUnknown(error: unknown): boolean { + if (error instanceof ApiError) { + // 5xx (incl. the SW's synthetic 503 with `meta.offline: true`) and any + // other non-auth status: the server never ruled on the session. Only an + // explicit 401/403 is a verdict. + return error.status !== 401 && error.status !== 403; + } + // Everything below the HTTP layer is transport-level by construction: + // fetch `TypeError` (offline / DNS / connection refused), `AbortError` / + // `TimeoutError` DOMException from the 15 s default signal. + return true; +} + async function fetchMe(): Promise { // v1.16.4 — routed through the typed wrapper; a non-OK /me (401) // throws `ApiError`, which `useAuth` treats as "not authenticated" @@ -183,6 +241,8 @@ async function fetchMe(): Promise { ? data.dateFormat : "AUTO"; storeDateFormat(dateFormat); + // A confirmed session: refresh the non-sensitive offline-relaunch marker. + storeWasAuthenticated(); return { ...(data as AuthUser), disableCoach: data.disableCoach ?? false, @@ -227,10 +287,30 @@ export function useAuth() { staleTime: 5 * 60 * 1000, }); + // v1.27.x offline fix — classify a failed `/api/auth/me` before acting on + // it. Only an explicit 401/403 means "session ended"; a network-level + // failure (offline relaunch, SW 503 offline envelope, timeout) or a 5xx + // carries no verdict, so the hook holds the LAST-KNOWN auth state (the + // localStorage marker) instead of flipping to unauthenticated. Pre-fix, an + // airplane-mode relaunch read as a logout: the shell redirected to + // /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()); + return { user: query.data ?? null, isLoading: query.isLoading, - isAuthenticated: !!query.data, + isAuthenticated, + /** + * True while the session question is genuinely unanswered — the auth + * probe failed at the transport level (or 5xx) and no cached `/me` + * payload exists. The shell uses it to suppress the session-end cache + * wipe: a wipe without a 401/403 verdict would destroy the offline + * caches on every network blip. + */ + isAuthUnknown: !query.data && verdictUnknown, error: query.error, refetch: query.refetch, }; @@ -257,6 +337,10 @@ export function useAuth() { */ export function clearCachesForSessionEnd(queryClient: QueryClient): void { queryClient.clear(); + // The offline-relaunch marker is session state too: after a real session + // end the next cold offline launch must land on the login gate, not the + // cached shell. + clearWasAuthenticated(); void clearOfflineCachesForSessionEnd(); }