diff --git a/src/app/auth/login/__tests__/page.browser.test.tsx b/src/app/auth/login/__tests__/page.browser.test.tsx
new file mode 100644
index 000000000..e40943d97
--- /dev/null
+++ b/src/app/auth/login/__tests__/page.browser.test.tsx
@@ -0,0 +1,52 @@
+/**
+ * The sign-in page, driven by its buttons.
+ *
+ * Single sign-on leaves the page for the identity provider and the session
+ * starts on the way back, so the previous account's offline copies have to
+ * be gone before the page is left.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { stubFetch } from "../../../../../tests/browser/fetch-stub";
+import { renderWithApp } from "../../../../../tests/browser/render";
+
+const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
+const loadDocument = vi.hoisted(() => vi.fn());
+
+vi.mock("@/lib/pwa/query-persister", () => ({
+ clearOfflineCachesForSessionEnd: clearOffline,
+}));
+vi.mock("@/lib/navigation/load-document", () => ({ loadDocument }));
+
+import LoginPage from "../page";
+
+beforeEach(() => {
+ stubFetch()
+ .on("GET", "/api/auth/registration-status", {
+ data: { registrationEnabled: false, sessionCookieSecure: false },
+ })
+ .on("GET", "/api/auth/oidc/status", {
+ data: { enabled: true, buttonLabel: "Example ID", only: false },
+ });
+});
+
+afterEach(() => {
+ clearOffline.mockClear();
+ loadDocument.mockClear();
+});
+
+describe("sign-in page, single sign-on", () => {
+ it("clears the offline copies before leaving for the identity provider", async () => {
+ const { screen } = await renderWithApp( , { user: null });
+ await screen.getByRole("button", { name: "Example ID" }).click();
+
+ await expect.poll(() => loadDocument.mock.calls.length).toBe(1);
+ expect(loadDocument.mock.calls[0][0]).toMatch(
+ /^\/api\/auth\/oidc\/login\?/,
+ );
+ expect(clearOffline).toHaveBeenCalledTimes(1);
+ expect(clearOffline.mock.invocationCallOrder[0]).toBeLessThan(
+ loadDocument.mock.invocationCallOrder[0],
+ );
+ });
+});
diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx
index 49b44b786..7aa4db1ce 100644
--- a/src/app/auth/login/page.tsx
+++ b/src/app/auth/login/page.tsx
@@ -176,12 +176,14 @@ export default function LoginPage() {
staleTime: 60 * 1000,
});
- function handleOidcLogin() {
+ async function handleOidcLogin() {
const params = new URLSearchParams({ next: getRedirectTarget() });
if (clientTransport) {
params.set("client_proto", clientTransport.protocol);
params.set("client_host", clientTransport.host);
}
+ // The session starts on the way back; the last account's copies go first.
+ await clearOfflineCachesForSessionEnd();
loadDocument(`/api/auth/oidc/login?${params}`);
}
@@ -226,15 +228,9 @@ export default function LoginPage() {
// stale `["auth","me"]`, so the shell refetches it fresh on mount and the
// former explicit `invalidateQueries({ queryKey: auth() })` is redundant.
queryClient.clear();
- // Belt-and-suspenders to match the session-END wipe (logout / 401-expiry):
- // also drop the OFFLINE layers — the IndexedDB query snapshot and the SW
- // data/page caches — so a previous account's persisted dashboard never
- // lingers on disk for the new session. The restore is already per-account
- // bound, but a fresh login on a shared browser should leave nothing of the
- // prior account behind. Order is clear → prefetch → navigate: the wipe is
- // started here (best-effort, like logout) before the prefetch warms THIS
- // user's data, and the persister's 1 s debounce gives the single IDB delete
- // ample headroom to land before the new snapshot is written.
+ // The offline layers too, as at every session end. Started before the
+ // prefetch; the persister's 1 s debounce lets the delete land before the
+ // new account's snapshot is written.
void clearOfflineCachesForSessionEnd();
const target = getRedirectTarget();
if (target === "/" && isDashboardSnapshotEnabled()) {
@@ -464,7 +460,7 @@ export default function LoginPage() {
{oidcStatus?.enabled && (
void handleOidcLogin()}
variant={oidcStatus.only ? "default" : "outline"}
className="min-h-11 w-full"
size="lg"
diff --git a/src/app/auth/register/__tests__/page.browser.test.tsx b/src/app/auth/register/__tests__/page.browser.test.tsx
new file mode 100644
index 000000000..798139d4d
--- /dev/null
+++ b/src/app/auth/register/__tests__/page.browser.test.tsx
@@ -0,0 +1,44 @@
+/**
+ * The registration page, driven by its form.
+ *
+ * A new account starts from an empty cache, like every other sign-in: the
+ * client cache outlives the navigation, and nothing read before the account
+ * existed belongs to it.
+ */
+import { describe, expect, it } from "vitest";
+import { page } from "vitest/browser";
+
+import { queryKeys } from "@/lib/query-keys";
+
+import { stubFetch } from "../../../../../tests/browser/fetch-stub";
+import { renderWithApp } from "../../../../../tests/browser/render";
+import RegisterPage from "../page";
+
+async function fill(selector: string, value: string): Promise {
+ await page.elementLocator(document.querySelector(selector)!).fill(value);
+}
+
+describe("registration page", () => {
+ it("starts the new account from an empty cache", async () => {
+ const api = stubFetch()
+ .on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
+ .on("POST", "/api/auth/register", { data: { id: "new-account" } });
+ window.history.replaceState(null, "", "/auth/register");
+ const { screen, queryClient } = await renderWithApp( , {
+ user: null,
+ });
+ // Left over from whoever used this browser before.
+ queryClient.setQueryData(queryKeys.dashboardSnapshot(), { stale: true });
+
+ await fill("#email", "new@example.com");
+ await fill("#username", "newcomer");
+ await fill("#password", "a long enough passphrase");
+ await screen.getByRole("button", { name: "Sign up" }).click();
+
+ await expect.poll(() => window.location.pathname).toBe("/");
+ expect(api.calls("POST", "/api/auth/register")).toHaveLength(1);
+ expect(queryClient.getQueryData(queryKeys.dashboardSnapshot())).toBe(
+ undefined,
+ );
+ });
+});
diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx
index 3fe76dc18..df5167099 100644
--- a/src/app/auth/register/page.tsx
+++ b/src/app/auth/register/page.tsx
@@ -12,7 +12,6 @@ import { Label } from "@/components/ui/label";
import { PasswordStrength } from "@/components/ui/password-strength";
import { useTranslations } from "@/lib/i18n/context";
import { detectBrowserTimezone } from "@/lib/tz/format";
-import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
@@ -74,7 +73,7 @@ export default function RegisterPage() {
...(inviteToken ? { inviteToken } : {}),
});
- await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
+ queryClient.clear(); // nothing cached before belongs to the new account
router.push("/");
} catch (err) {
setError(
diff --git a/src/components/auth/__tests__/claim-profile.browser.test.tsx b/src/components/auth/__tests__/claim-profile.browser.test.tsx
new file mode 100644
index 000000000..04d5cb3d8
--- /dev/null
+++ b/src/components/auth/__tests__/claim-profile.browser.test.tsx
@@ -0,0 +1,110 @@
+/**
+ * Taking over a managed profile, driven by the claim page's buttons.
+ *
+ * Both ways in start a session for a different account than whatever this
+ * browser held before: the password claim stays in the single-page app and
+ * starts from an empty cache, the single-sign-on claim leaves for the
+ * identity provider and clears the offline copies before it goes.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { page } from "vitest/browser";
+
+import { queryKeys } from "@/lib/query-keys";
+
+import {
+ stubFetch,
+ type FetchStub,
+} from "../../../../tests/browser/fetch-stub";
+import { renderWithApp } from "../../../../tests/browser/render";
+
+const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
+const loadDocument = vi.hoisted(() => vi.fn());
+
+vi.mock("@/lib/pwa/query-persister", () => ({
+ clearOfflineCachesForSessionEnd: clearOffline,
+}));
+vi.mock("@/lib/navigation/load-document", () => ({ loadDocument }));
+
+import { ClaimProfile } from "../claim-profile";
+
+const TOKEN = `hlp_${"c".repeat(64)}`;
+
+let api: FetchStub;
+
+beforeEach(() => {
+ window.history.replaceState(null, "", "/auth/claim");
+ api = stubFetch()
+ .on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
+ .on("POST", "/api/auth/claim/preview", {
+ data: {
+ displayName: "Robin",
+ expiresAt: "2030-01-01T00:00:00.000Z",
+ guardians: [],
+ methods: ["password", "sso"],
+ ssoProviderName: "Example ID",
+ },
+ })
+ .on("POST", "/api/auth/claim", {
+ data: { userId: "claimed", username: "robin" },
+ })
+ .on("POST", "/api/auth/claim/sso", {
+ data: { redirectTo: "/api/auth/oidc/login?claim=1" },
+ });
+});
+
+afterEach(() => {
+ clearOffline.mockClear();
+ loadDocument.mockClear();
+});
+
+async function fill(selector: string, value: string): Promise {
+ await expect.poll(() => document.querySelector(selector)).toBeTruthy();
+ await page.elementLocator(document.querySelector(selector)!).fill(value);
+}
+
+async function click(slot: string): Promise {
+ await page
+ .elementLocator(document.querySelector(`[data-slot="${slot}"]`)!)
+ .click();
+}
+
+describe("ClaimProfile", () => {
+ it("starts the claimed account from an empty cache", async () => {
+ const { queryClient } = await renderWithApp(
+ ,
+ {
+ user: null,
+ },
+ );
+ queryClient.setQueryData(queryKeys.dashboardSnapshot(), { stale: true });
+
+ await fill("#claim-username", "robin");
+ await fill("#claim-email", "robin@example.com");
+ await fill("#claim-password", "a long enough passphrase");
+ await click("claim-submit");
+
+ await expect
+ .poll(() => window.location.pathname)
+ .toBe("/onboarding/handover");
+ expect(api.calls("POST", "/api/auth/claim")).toHaveLength(1);
+ expect(queryClient.getQueryData(queryKeys.dashboardSnapshot())).toBe(
+ undefined,
+ );
+ });
+
+ it("clears the offline copies before leaving for the identity provider", async () => {
+ await renderWithApp( , { user: null });
+
+ await fill("#claim-username", "robin");
+ await click("claim-sso-continue");
+
+ await expect.poll(() => loadDocument.mock.calls.length).toBe(1);
+ expect(loadDocument.mock.calls[0][0]).toMatch(
+ /^\/api\/auth\/oidc\/login\?claim=1/,
+ );
+ expect(clearOffline).toHaveBeenCalledTimes(1);
+ expect(clearOffline.mock.invocationCallOrder[0]).toBeLessThan(
+ loadDocument.mock.invocationCallOrder[0],
+ );
+ });
+});
diff --git a/src/components/auth/claim-profile.tsx b/src/components/auth/claim-profile.tsx
index 8231d112c..a930826b4 100644
--- a/src/components/auth/claim-profile.tsx
+++ b/src/components/auth/claim-profile.tsx
@@ -17,12 +17,12 @@ import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { loadDocument } from "@/lib/navigation/load-document";
+import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import {
useClaimProfile,
useClaimProfileSso,
useProfileClaimPreview,
} from "@/lib/queries/use-profile-claim";
-import { queryKeys } from "@/lib/query-keys";
/**
* v1.42 (#959) — the person a managed profile describes takes it over as
@@ -97,7 +97,7 @@ export function ClaimProfile({
claimSso.mutate(
{ token, username },
{
- onSuccess: ({ redirectTo }) => {
+ onSuccess: async ({ redirectTo }) => {
// Only the path and query of the server's answer, so a response
// could never send the browser off this origin.
const next = new URL(redirectTo, window.location.origin);
@@ -105,6 +105,8 @@ export function ClaimProfile({
next.searchParams.set("client_proto", clientTransport.protocol);
next.searchParams.set("client_host", clientTransport.host);
}
+ // The session starts on the way back; the last account's copies go first.
+ await clearOfflineCachesForSessionEnd();
loadDocument(`${next.pathname}${next.search}`);
},
},
@@ -117,11 +119,9 @@ export function ClaimProfile({
claim.mutate(
{ token, username, email, password },
{
- onSuccess: async () => {
- queryClient.removeQueries({
- queryKey: queryKeys.profileClaimPreview(),
- });
- await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
+ onSuccess: () => {
+ // As after any sign-in: nothing cached before belongs to this account.
+ queryClient.clear();
router.push("/onboarding/handover");
},
},
diff --git a/src/components/layout/__tests__/auth-shell-session-end.browser.test.tsx b/src/components/layout/__tests__/auth-shell-session-end.browser.test.tsx
new file mode 100644
index 000000000..7e2d7578d
--- /dev/null
+++ b/src/components/layout/__tests__/auth-shell-session-end.browser.test.tsx
@@ -0,0 +1,127 @@
+/**
+ * What the shell does once the server says there is no session.
+ *
+ * On an `/auth/*` page it clears the previous account's offline copies, once,
+ * so a session that ended without a sign-out (an expired cookie the edge sent
+ * straight to the sign-in page) leaves nothing on the device for the next
+ * account. It never clears on an answer that is not a verdict.
+ */
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { authUser, renderWithApp } from "../../../../tests/browser/render";
+import { stubFetch } from "../../../../tests/browser/fetch-stub";
+
+const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
+
+vi.mock("@/lib/pwa/query-persister", () => ({
+ clearOfflineCachesForSessionEnd: clearOffline,
+ clearOfflineCachesForRecordSwitch: vi.fn(async () => {}),
+}));
+
+// Chrome that is not the subject.
+vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
+vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
+vi.mock("../top-bar", () => ({ TopBar: () => null }));
+vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
+vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
+vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
+ AchievementUnlockNotifier: () => null,
+}));
+vi.mock("@/components/insights/layout-coach-fab", () => ({
+ LayoutCoachFab: () => null,
+}));
+vi.mock("@/components/insights/layout-coach-mount", () => ({
+ LayoutCoachMount: () => null,
+}));
+vi.mock("@/components/onboarding/tour-launcher", () => ({
+ TourLauncher: () => null,
+}));
+vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
+vi.mock("@/components/command-palette/command-palette.lazy", () => ({
+ CommandPaletteMount: () => null,
+}));
+vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
+
+import { AuthShell } from "../auth-shell";
+
+const ME = "/api/auth/me";
+
+function openAt(path: string): void {
+ window.history.replaceState(null, "", path);
+}
+
+async function mountShell(user: Parameters[0] | null) {
+ return renderWithApp(
+
+
+ ,
+ { user },
+ );
+}
+
+async function settle(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+}
+
+afterEach(() => {
+ clearOffline.mockClear();
+});
+
+describe("AuthShell on a sign-in page", () => {
+ it("clears the offline copies once the server says there is no session", async () => {
+ const fetchStub = stubFetch().on("GET", ME, {
+ status: 401,
+ error: "Unauthorized",
+ });
+ openAt("/auth/login");
+ const { queryClient } = await mountShell(null);
+
+ await expect.poll(() => clearOffline.mock.calls.length).toBe(1);
+
+ // A second answer of the same kind does not clear again.
+ await queryClient.refetchQueries();
+ await settle();
+ expect(fetchStub.calls("GET", ME).length).toBeGreaterThan(1);
+ expect(clearOffline).toHaveBeenCalledTimes(1);
+ });
+
+ it("does the same on the profile take-over page", async () => {
+ stubFetch().on("GET", ME, { status: 401, error: "Unauthorized" });
+ openAt("/auth/claim");
+ await mountShell(null);
+ await expect.poll(() => clearOffline.mock.calls.length).toBe(1);
+ });
+
+ it("leaves them alone for a signed-in visitor", async () => {
+ stubFetch();
+ openAt("/auth/register");
+ await mountShell({ id: "signed-in" });
+ await settle();
+ expect(clearOffline).not.toHaveBeenCalled();
+ });
+
+ it("leaves them alone when the answer carries no verdict", async () => {
+ // An offline launch or a 5xx says nothing about the session; clearing on
+ // it is what used to destroy the offline copies of a signed-in device.
+ const fetchStub = stubFetch().on("GET", ME, {
+ status: 503,
+ error: "Unavailable",
+ });
+ openAt("/auth/login");
+ await mountShell(null);
+ // The account query retries a 5xx once; wait for both answers.
+ await expect
+ .poll(() => fetchStub.calls("GET", ME).length, { timeout: 4000 })
+ .toBe(2);
+ await settle();
+ expect(clearOffline).not.toHaveBeenCalled();
+ });
+
+ it("does not clear on a public page outside the sign-in flow", async () => {
+ stubFetch().on("GET", ME, { status: 401, error: "Unauthorized" });
+ openAt("/about");
+ await mountShell(null);
+ await settle();
+ expect(clearOffline).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx
index 2853585d0..9610fc3cc 100644
--- a/src/components/layout/auth-shell.tsx
+++ b/src/components/layout/auth-shell.tsx
@@ -15,6 +15,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { useTranslations } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
+import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
// The leaf module, not the `@/lib/record-settings` barrel. This shell wraps
// every authenticated route, and the barrel also re-exports the managed
// settings-patch schemas — so one destination predicate that reads a slug
@@ -77,6 +78,32 @@ const PUBLIC_PATHS = [
"/claim/",
];
+/**
+ * A session that ended without passing through the shell's session-end wipe
+ * (an expired cookie the edge sent straight to the sign-in page, a sign-in
+ * page opened directly) left the previous account's offline copies on this
+ * device. Clear them on every `/auth/*` page once the server has said there is
+ * no session, before anyone signs in. Once per signed-out stretch, and never
+ * on an answer that is not a verdict (offline, 5xx).
+ */
+function useSignInPageOfflineWipe(pathname: string): void {
+ const { isAuthenticated, isAuthUnknown, isLoading } = useAuth();
+ const cleared = useRef(false);
+ useEffect(() => {
+ if (isAuthenticated) {
+ cleared.current = false;
+ } else if (
+ !isLoading &&
+ !isAuthUnknown &&
+ pathname.startsWith("/auth/") &&
+ !cleared.current
+ ) {
+ cleared.current = true;
+ void clearOfflineCachesForSessionEnd();
+ }
+ }, [isLoading, isAuthenticated, isAuthUnknown, pathname]);
+}
+
export function AuthShell({
children,
demoMode = false,
@@ -223,9 +250,8 @@ export function AuthShell({
// 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.
+ // Logout does the same from `useLogout`; this covers the expiry path it
+ // 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
@@ -247,6 +273,7 @@ export function AuthShell({
router,
queryClient,
]);
+ useSignInPageOfflineWipe(pathname);
// Redirect non-admins away from /admin
useEffect(() => {