mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge v1.25.1: clear the in-memory query cache at every session boundary
This commit is contained in:
commit
9c1c0d86a9
4 files changed
+135
-27
No files matched your search
@@ -80,6 +80,18 @@ export default function LoginPage() {
|
||||
// can ride in parallel with the route transition + page-chunk
|
||||
// download instead of queueing behind the dashboard mount.
|
||||
function navigateAfterLogin() {
|
||||
// Start the new session from an empty in-memory cache. The root
|
||||
// QueryClient is created once and outlives every client-side navigation,
|
||||
// so a previous user who closed the tab WITHOUT logging out (no logout
|
||||
// fired) leaves their non-user-scoped health-data queries
|
||||
// (`["measurements"]`, `["dashboard","snapshot"]`, …) sitting in memory.
|
||||
// Clearing here — before any prefetch warms THIS user's data and before we
|
||||
// navigate — guarantees a fresh login never inherits another account's
|
||||
// cached entries. Logout / expiry clear the same cache from their own
|
||||
// boundaries; this covers the no-logout path. `clear()` also drops the
|
||||
// stale `["auth","me"]`, so the shell refetches it fresh on mount and the
|
||||
// former explicit `invalidateQueries({ queryKey: auth() })` is redundant.
|
||||
queryClient.clear();
|
||||
const target = getRedirectTarget();
|
||||
if (target === "/" && isDashboardSnapshotEnabled()) {
|
||||
prefetchDashboardSnapshot(queryClient);
|
||||
@@ -105,7 +117,8 @@ export default function LoginPage() {
|
||||
credential,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
// `navigateAfterLogin` clears the in-memory cache (cross-user guard) and
|
||||
// navigates; the shell refetches `["auth","me"]` fresh on mount.
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
// Route rejections carry the envelope error verbatim; everything
|
||||
@@ -150,7 +163,8 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
// `navigateAfterLogin` clears the in-memory cache (cross-user guard) and
|
||||
// navigates; the shell refetches `["auth","me"]` fresh on mount.
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -193,7 +207,8 @@ export default function LoginPage() {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
// `navigateAfterLogin` clears the in-memory cache (cross-user guard)
|
||||
// and navigates; the shell refetches `["auth","me"]` fresh on mount.
|
||||
navigateAfterLogin();
|
||||
} catch {
|
||||
// Autofill aborts (no passkey, user dismisses, password path taken)
|
||||
@@ -228,10 +243,10 @@ export default function LoginPage() {
|
||||
<MfaLoginStep
|
||||
mfaTicket={mfaChallenge.ticket}
|
||||
methods={mfaChallenge.methods}
|
||||
onSuccess={async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.auth(),
|
||||
});
|
||||
onSuccess={() => {
|
||||
// `navigateAfterLogin` clears the in-memory cache (cross-user
|
||||
// guard) and navigates; the shell refetches `["auth","me"]`
|
||||
// fresh on mount.
|
||||
navigateAfterLogin();
|
||||
}}
|
||||
onCancel={() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
@@ -8,8 +9,7 @@ import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
@@ -52,6 +52,7 @@ export function AuthShell({
|
||||
const { t } = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
|
||||
// v1.4.26 — `/privacy` is a long-form legal page that brings its own
|
||||
@@ -129,15 +130,16 @@ export function AuthShell({
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !isPublicPage) {
|
||||
// Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe
|
||||
// every client-side cache that can hold the previous account's health
|
||||
// data before bouncing to login, so it never leaks to the next account
|
||||
// on a shared device. Logout does the same from `useLogout`; this covers
|
||||
// the expiry path that logout never runs through. Best-effort; the
|
||||
// redirect proceeds regardless.
|
||||
void clearOfflineCachesForSessionEnd();
|
||||
// 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);
|
||||
router.replace("/auth/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, isPublicPage, router]);
|
||||
}, [isLoading, isAuthenticated, isPublicPage, router, queryClient]);
|
||||
|
||||
// Redirect non-admins away from /admin
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Cross-user in-memory cache leak (v1.25.1).
|
||||
*
|
||||
* The root QueryClient is created once and survives every client-side
|
||||
* navigation, so logout→login on the same browser used to leave the previous
|
||||
* account's NON-user-scoped health-data queries (`["measurements"]`,
|
||||
* `["dashboard","snapshot"]`, …) sitting in memory — the next account read
|
||||
* them before any refetch landed. `clearCachesForSessionEnd` drops the entire
|
||||
* in-memory cache at the session boundary; this test pins that guarantee on a
|
||||
* real QueryClient.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
// The offline-cache wipe touches IndexedDB + the SW caches, neither of which
|
||||
// exists in the node test env — mock it to a spy so we can assert it still
|
||||
// runs alongside the in-memory clear without booting a browser.
|
||||
const clearOfflineCachesForSessionEnd = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/pwa/query-persister", () => ({
|
||||
clearOfflineCachesForSessionEnd: () => clearOfflineCachesForSessionEnd(),
|
||||
}));
|
||||
|
||||
// `use-auth` pulls in the typed fetch wrapper, the router, and the i18n
|
||||
// context at module load. None of them matter for the pure session-end
|
||||
// helper, so stub them so the import is side-effect free in the test env.
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: vi.fn(),
|
||||
apiFetchRaw: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { clearCachesForSessionEnd } from "../use-auth";
|
||||
|
||||
afterEach(() => {
|
||||
clearOfflineCachesForSessionEnd.mockClear();
|
||||
});
|
||||
|
||||
describe("clearCachesForSessionEnd — cross-user in-memory wipe", () => {
|
||||
it("drops every in-memory query, including non-auth health data, at the session boundary", () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// Seed the cache as a logged-in account would leave it: an auth/me entry
|
||||
// plus a non-user-scoped health-data family.
|
||||
queryClient.setQueryData(queryKeys.authMe(), { id: "account-a" });
|
||||
queryClient.setQueryData(["measurements"], [{ id: "m1", value: 72 }]);
|
||||
queryClient.setQueryData(["dashboard", "snapshot"], { weightKg: 80 });
|
||||
|
||||
expect(queryClient.getQueryCache().getAll().length).toBeGreaterThan(0);
|
||||
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
|
||||
// Nothing survives into the next session: the in-memory cache is empty,
|
||||
// so the health-data entries can never render for the next account.
|
||||
expect(queryClient.getQueryCache().getAll()).toHaveLength(0);
|
||||
expect(queryClient.getQueryData(["measurements"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(["dashboard", "snapshot"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(queryKeys.authMe())).toBeUndefined();
|
||||
|
||||
// The persisted IndexedDB + SW caches are still wiped alongside the
|
||||
// in-memory clear — the existing offline-leak guard stays intact.
|
||||
expect(clearOfflineCachesForSessionEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+31
-11
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -231,6 +236,30 @@ export function useAuth() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe every client-side cache at a session END so no session ever inherits
|
||||
* another account's data on a shared browser.
|
||||
*
|
||||
* Primary cross-user guard: `queryClient.clear()` drops the entire IN-MEMORY
|
||||
* query cache. On a long-lived SPA the root QueryClient instance outlives every
|
||||
* client-side navigation, and the health-data families
|
||||
* (`["measurements"]`, `["dashboard","snapshot"]`, `["labs", …]`, `["mood", …]`,
|
||||
* `["insights", …]`) are NOT user-scoped — so without this wipe the next account
|
||||
* that logs in on the same browser reads the previous account's cached entries
|
||||
* before any refetch lands. `clear()` also drops `["auth","me"]`, superseding the
|
||||
* former explicit `setQueryData(null)` + `invalidateQueries(["auth"])`.
|
||||
*
|
||||
* Then `clearOfflineCachesForSessionEnd()` wipes the persisted layers — the
|
||||
* IndexedDB query snapshot, the SW offline read-data cache (`healthlog-data-*`),
|
||||
* and the SW page cache (`healthlog-pages-*`, cached navigation HTML). The static
|
||||
* cache (hashed chunks, icons) carries no PII and stays intact to avoid a
|
||||
* needless re-download. Best-effort; never blocks the redirect.
|
||||
*/
|
||||
export function clearCachesForSessionEnd(queryClient: QueryClient): void {
|
||||
queryClient.clear();
|
||||
void clearOfflineCachesForSessionEnd();
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
@@ -241,16 +270,7 @@ export function useLogout() {
|
||||
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(queryKeys.authMe(), null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
// Defense-in-depth at the session boundary: wipe every client-side cache
|
||||
// that can hold this account's health data — the IndexedDB query
|
||||
// snapshot, the SW offline read-data cache (`healthlog-data-*`), and the
|
||||
// SW page cache (`healthlog-pages-*`, cached navigation HTML) — so
|
||||
// nothing survives a logout on a shared device. The static cache (hashed
|
||||
// chunks, icons) carries no PII and is left intact to avoid a needless
|
||||
// re-download. Best-effort; never blocks the redirect.
|
||||
void clearOfflineCachesForSessionEnd();
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
router.push("/auth/login");
|
||||
},
|
||||
// v1.16.4 — a network-failed logout used to do nothing at all (the
|
||||
|
||||
Reference in new issue
Block a user