fix(auth): confirm an emailed verification link wherever it is opened (E9.5 S2)
The token was only ever consumed inside Welcome.tsx, which App renders ONLY when there is no session. Opening the emailed link in a browser that already held a Siftlode session posted nothing, the fragment was then stripped, and the user got no signal at all -- the link simply did nothing. Incognito worked, which is why it survived. Confirmation now happens in App, above that branch: signed out the outcome still drives the auth card's banners, signed in it drives a top-of-app bar. The entry URL is snapshotted once at import time (lib/entryUrl) instead of per component, so no future URL writer can strip a token before whoever needs it has read it -- that ordering race is what broke this in the first place. POST /auth/verify now names the address it confirmed, which matters when the browser holds a session for a different account; the same reasoning as password_reset_required already covers the disclosure. Also here, from the 2026-09-02 review tail: the dead GET /auth/verify goes (both senders emit the fragment form, and the last query-token link expired 24h after the 0.78.7 deploy), a spent #verify=/#reset= fragment is cleared while signed in, and the auth-card banners get role=status. The verifyOkReset copy no longer blames admin approval, which E9.5 S1 made false.
This commit is contained in:
@@ -28,6 +28,11 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import Welcome from "./Welcome";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
// App confirms the emailed token and hands the outcome down; these tests are about the card's
|
||||
// sub-views, so there is nothing to report.
|
||||
const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} };
|
||||
|
||||
const back = () =>
|
||||
act(() => {
|
||||
@@ -45,7 +50,7 @@ afterEach(() => {
|
||||
|
||||
describe("sign-in card sub-views and the Back button", () => {
|
||||
it("opens Forgot password as a history entry and comes back to sign in", async () => {
|
||||
render(<Welcome />);
|
||||
render(<Welcome verify={noVerify} />);
|
||||
expect(await screen.findByText("welcome.auth.signinTitle")).toBeTruthy();
|
||||
const entries = window.history.length;
|
||||
|
||||
@@ -62,7 +67,7 @@ describe("sign-in card sub-views and the Back button", () => {
|
||||
});
|
||||
|
||||
it("does the same for Create an account and the verification-email link", async () => {
|
||||
render(<Welcome />);
|
||||
render(<Welcome verify={noVerify} />);
|
||||
await screen.findByText("welcome.auth.signinTitle");
|
||||
|
||||
fireEvent.click(screen.getByText("welcome.auth.createLink"));
|
||||
@@ -76,3 +81,41 @@ describe("sign-in card sub-views and the Back button", () => {
|
||||
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// The signed-OUT half of the verification outcome: App confirms the emailed token above its
|
||||
// signed-in branch and hands the result down, so the card no longer posts anything itself. These pin
|
||||
// that each outcome still reaches the right banner — and that the banner is announced (role=status),
|
||||
// which the 2026-09-02 review asked for and which is the whole point when the alternative was a
|
||||
// screen that said nothing at all.
|
||||
describe("the verification outcome handed down from App", () => {
|
||||
const withOutcome = (outcome: VerifyEntry["outcome"]): VerifyEntry => ({
|
||||
outcome,
|
||||
email: "someone@example.com",
|
||||
dismiss: () => {},
|
||||
});
|
||||
|
||||
it("announces a confirmed address", async () => {
|
||||
render(<Welcome verify={withOutcome("ok")} />);
|
||||
const banner = await screen.findByText("welcome.auth.verifyOk");
|
||||
expect(banner.closest('[role="status"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers the reset flow when the account's password had been revoked", async () => {
|
||||
render(<Welcome verify={withOutcome("ok_reset")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyOkReset")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.verifyOkResetCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers a new link when the token is spent", async () => {
|
||||
render(<Welcome verify={withOutcome("invalid")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyInvalid")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no banner when there was no token to confirm", async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
await screen.findByText("welcome.auth.signinTitle");
|
||||
expect(screen.queryByText("welcome.auth.verifyOk")).toBeNull();
|
||||
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user