fix(auth): leave nothing of the last account behind on any sign-in

Only the password and passkey success path cleared the offline copies
on this device. A session that ended by expiry reached the sign-in page
without any wipe, and single sign-on and both profile take-over paths
started the next account on top of the previous one's IndexedDB
snapshot and worker caches.

The shell now clears those copies on every /auth/* page once
/api/auth/me answers 401 or 403: once per signed-out stretch, never on
an answer without a verdict. Single sign-on and the take-over by single
sign-on clear them before leaving for the identity provider, and
registration and the password take-over start from an empty query cache
like the sign-in page does.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:10:38 +01:00
1 parent aae029d94e
commit 75b879279f
8 files changed
+378 -23

No files matched your search

@@ -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<void> {
await expect.poll(() => document.querySelector(selector)).toBeTruthy();
await page.elementLocator(document.querySelector(selector)!).fill(value);
}
async function click(slot: string): Promise<void> {
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(
<ClaimProfile token={TOKEN} />,
{
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(<ClaimProfile token={TOKEN} />, { 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],
);
});
});
+7 -7
View File
@@ -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");
},
},