mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user