From 8fdc238981f46dea62223bddd423ec56128cb72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:15:05 +0100 Subject: [PATCH] fix(auth): replace the sign-in page in the history after signing in Back after signing in, registering or taking over a profile showed the form that had just been submitted, because each pushed the next page on top of it. Password, passkey and second-factor sign-in, registration and the password take-over now replace the page instead. --- .../login/__tests__/page.browser.test.tsx | 35 +++++++++++++++++-- src/app/auth/login/page.tsx | 2 +- .../register/__tests__/page.browser.test.tsx | 6 +++- src/app/auth/register/page.tsx | 2 +- .../__tests__/claim-profile.browser.test.tsx | 10 ++++-- src/components/auth/claim-profile.tsx | 2 +- 6 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/app/auth/login/__tests__/page.browser.test.tsx b/src/app/auth/login/__tests__/page.browser.test.tsx index e40943d97..c20442d4c 100644 --- a/src/app/auth/login/__tests__/page.browser.test.tsx +++ b/src/app/auth/login/__tests__/page.browser.test.tsx @@ -3,12 +3,19 @@ * * 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. + * be gone before the page is left. A sign-in that stays in the app replaces + * the sign-in page in the history, so Back does not show the form again. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { stubFetch } from "../../../../../tests/browser/fetch-stub"; +import { page } from "vitest/browser"; + +import { + stubFetch, + type FetchStub, +} from "../../../../../tests/browser/fetch-stub"; import { renderWithApp } from "../../../../../tests/browser/render"; +import { recordHistory } from "../../../../../tests/browser/url"; const clearOffline = vi.hoisted(() => vi.fn(async () => {})); const loadDocument = vi.hoisted(() => vi.fn()); @@ -20,8 +27,10 @@ vi.mock("@/lib/navigation/load-document", () => ({ loadDocument })); import LoginPage from "../page"; +let api: FetchStub; + beforeEach(() => { - stubFetch() + api = stubFetch() .on("GET", "/api/auth/registration-status", { data: { registrationEnabled: false, sessionCookieSecure: false }, }) @@ -50,3 +59,23 @@ describe("sign-in page, single sign-on", () => { ); }); }); + +describe("sign-in page, password", () => { + it("replaces itself with the page asked for", async () => { + api.on("POST", "/api/auth/login", { data: { id: "signed-in" } }); + window.history.replaceState(null, "", "/auth/login?next=%2Fmood%3Fadd%3D1"); + const history = recordHistory(); + const { screen } = await renderWithApp(, { user: null }); + + await screen.getByTestId("login-use-password").click(); + await screen.getByTestId("login-email").fill("someone"); + await page + .elementLocator(document.querySelector("#password")!) + .fill("a long enough passphrase"); + await screen.getByTestId("login-password-submit").click(); + + await expect.poll(() => window.location.pathname).toBe("/mood"); + expect(window.location.search).toBe("?add=1"); + expect(history.pushes()).toEqual([]); + }); +}); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 7aa4db1ce..60eb3b53b 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -236,7 +236,7 @@ export default function LoginPage() { if (target === "/" && isDashboardSnapshotEnabled()) { prefetchDashboardSnapshot(queryClient); } - router.push(target); + router.replace(target); } async function handlePasskeyLogin() { diff --git a/src/app/auth/register/__tests__/page.browser.test.tsx b/src/app/auth/register/__tests__/page.browser.test.tsx index 798139d4d..0182022db 100644 --- a/src/app/auth/register/__tests__/page.browser.test.tsx +++ b/src/app/auth/register/__tests__/page.browser.test.tsx @@ -3,7 +3,8 @@ * * 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. + * existed belongs to it. The app replaces the page in the history, so Back + * does not show the form again. */ import { describe, expect, it } from "vitest"; import { page } from "vitest/browser"; @@ -12,6 +13,7 @@ import { queryKeys } from "@/lib/query-keys"; import { stubFetch } from "../../../../../tests/browser/fetch-stub"; import { renderWithApp } from "../../../../../tests/browser/render"; +import { recordHistory } from "../../../../../tests/browser/url"; import RegisterPage from "../page"; async function fill(selector: string, value: string): Promise { @@ -24,6 +26,7 @@ describe("registration page", () => { .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 history = recordHistory(); const { screen, queryClient } = await renderWithApp(, { user: null, }); @@ -37,6 +40,7 @@ describe("registration page", () => { await expect.poll(() => window.location.pathname).toBe("/"); expect(api.calls("POST", "/api/auth/register")).toHaveLength(1); + expect(history.pushes()).toEqual([]); expect(queryClient.getQueryData(queryKeys.dashboardSnapshot())).toBe( undefined, ); diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index df5167099..d88ba1c32 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -74,7 +74,7 @@ export default function RegisterPage() { }); queryClient.clear(); // nothing cached before belongs to the new account - router.push("/"); + router.replace("/"); } catch (err) { setError( err instanceof ApiError && err.message diff --git a/src/components/auth/__tests__/claim-profile.browser.test.tsx b/src/components/auth/__tests__/claim-profile.browser.test.tsx index 04d5cb3d8..210081d0d 100644 --- a/src/components/auth/__tests__/claim-profile.browser.test.tsx +++ b/src/components/auth/__tests__/claim-profile.browser.test.tsx @@ -2,9 +2,10 @@ * 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. + * browser held before: the password claim stays in the single-page app, + * starts from an empty cache and replaces the claim page in the history; 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"; @@ -16,6 +17,7 @@ import { type FetchStub, } from "../../../../tests/browser/fetch-stub"; import { renderWithApp } from "../../../../tests/browser/render"; +import { recordHistory } from "../../../../tests/browser/url"; const clearOffline = vi.hoisted(() => vi.fn(async () => {})); const loadDocument = vi.hoisted(() => vi.fn()); @@ -70,6 +72,7 @@ async function click(slot: string): Promise { describe("ClaimProfile", () => { it("starts the claimed account from an empty cache", async () => { + const history = recordHistory(); const { queryClient } = await renderWithApp( , { @@ -87,6 +90,7 @@ describe("ClaimProfile", () => { .poll(() => window.location.pathname) .toBe("/onboarding/handover"); expect(api.calls("POST", "/api/auth/claim")).toHaveLength(1); + expect(history.pushes()).toEqual([]); expect(queryClient.getQueryData(queryKeys.dashboardSnapshot())).toBe( undefined, ); diff --git a/src/components/auth/claim-profile.tsx b/src/components/auth/claim-profile.tsx index a930826b4..d15a6db16 100644 --- a/src/components/auth/claim-profile.tsx +++ b/src/components/auth/claim-profile.tsx @@ -122,7 +122,7 @@ export function ClaimProfile({ onSuccess: () => { // As after any sign-in: nothing cached before belongs to this account. queryClient.clear(); - router.push("/onboarding/handover"); + router.replace("/onboarding/handover"); }, }, );