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:
@@ -41,7 +41,11 @@ export const authApi = {
|
||||
// in would fail with the uniform credentials error. The card sends the user to the reset flow instead
|
||||
// of leaving them to guess. Rare since E9.5 S1: only accounts stripped before that change, and the
|
||||
// Google-adoption path, still reach it.
|
||||
verifyEmail: (token: string): Promise<{ ok: boolean; password_reset_required?: boolean }> =>
|
||||
// `email` is the address that was just confirmed — echoed back so the banner can name it, which
|
||||
// matters when the browser holds a session for a DIFFERENT account.
|
||||
verifyEmail: (
|
||||
token: string,
|
||||
): Promise<{ ok: boolean; password_reset_required?: boolean; email?: string }> =>
|
||||
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
||||
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
|
||||
// reveals whether the email has a pending account.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// The URL the app was OPENED with, captured once when this module is first imported — before any
|
||||
// component renders and before anything rewrites the address bar.
|
||||
//
|
||||
// Why a module-level snapshot rather than a `useState(() => …)` in whichever component needs it: the
|
||||
// 2026-09-02 incident was exactly this race. The nav-param mirrors mount ABOVE the lazily-loaded
|
||||
// <App>, and their mount effect replaceState'd the fragment away before AuthCard had snapshotted it,
|
||||
// so the emailed verification token was gone by the time anyone looked for it. 0.78.7 fixed that by
|
||||
// teaching urlWithParams/clearParams to carry the hash, but that leaves the guarantee resting on
|
||||
// every future URL writer remembering to do the same. Reading the entry URL once, at import time,
|
||||
// cannot lose the race no matter who mounts first or which chunk loads when.
|
||||
//
|
||||
// Secret tokens (verify, reset) ride the FRAGMENT so they never reach the server, a proxy log or a
|
||||
// Referer header (SB3); plain status flags ride the query string.
|
||||
|
||||
export const entrySearch = new URLSearchParams(window.location.search);
|
||||
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
|
||||
/** Strip the query string and fragment from the address bar, so a one-time token doesn't linger in
|
||||
* history or get re-sent on a reload. Everything worth keeping is already in the snapshots above.
|
||||
* ONE owner calls this (useVerifyEntry, which App mounts unconditionally) — a second caller would be
|
||||
* harmless but makes it unclear who is responsible. */
|
||||
export function clearEntryUrl(): void {
|
||||
if (window.location.search || window.location.hash) {
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "./api";
|
||||
import { clearEntryUrl, entryHash } from "./entryUrl";
|
||||
|
||||
/** What confirming the emailed token produced. `ok_reset` means the address is confirmed but this
|
||||
* account's password had been revoked (SEC-10), so signing in would only meet the uniform credentials
|
||||
* error — the user has to set a new one. */
|
||||
type VerifyOutcome = "ok" | "ok_reset" | "invalid";
|
||||
|
||||
export type VerifyEntry = {
|
||||
outcome: VerifyOutcome | null;
|
||||
/** The address that was confirmed, when the server named it. */
|
||||
email: string | null;
|
||||
/** Dismiss the outcome — the banner is a one-time entry notice, not persistent state. */
|
||||
dismiss: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm a `#verify=` token from the entry URL, wherever the user opened it.
|
||||
*
|
||||
* App calls this ABOVE its signed-in/signed-out branch, which is the whole point: the token used to be
|
||||
* consumed inside Welcome.tsx, and `App.tsx` renders <Welcome /> only when there is no session. Open
|
||||
* the emailed link in a browser that already holds a Siftlode session and the token was never posted,
|
||||
* 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 so long.
|
||||
*
|
||||
* Fires exactly once. The token is single-use, so a second POST (a StrictMode remount, any re-render)
|
||||
* would 400 and wrongly flip a successful confirmation to "invalid".
|
||||
*/
|
||||
export function useVerifyEntry(): VerifyEntry {
|
||||
const token = entryHash.get("verify");
|
||||
const [outcome, setOutcome] = useState<VerifyOutcome | null>(null);
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const fired = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear the address bar even with no token to consume: a `#reset=` fragment opened in a
|
||||
// signed-in browser is equally spent, and leaving either in history re-sends it on a reload.
|
||||
clearEntryUrl();
|
||||
if (!token || fired.current) return;
|
||||
fired.current = true;
|
||||
api
|
||||
.verifyEmail(token)
|
||||
.then((r) => {
|
||||
setOutcome(r.password_reset_required ? "ok_reset" : "ok");
|
||||
setEmail(r.email ?? null);
|
||||
})
|
||||
.catch(() => setOutcome("invalid"));
|
||||
}, [token]);
|
||||
|
||||
return { outcome, email, dismiss: () => setOutcome(null) };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The emailed verification link, confirmed wherever it is opened. The regression this pins: the token
|
||||
// used to be consumed inside Welcome.tsx, which App renders ONLY when there is no session — so opening
|
||||
// the 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. Incognito worked, which is why it survived.
|
||||
//
|
||||
// entryUrl snapshots window.location at IMPORT time, so each case resets the module registry and sets
|
||||
// the URL before importing — that is the behaviour under test, not a workaround.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
/** Mount the hook at a given entry URL with a stubbed /auth/verify, and hand back what it reported. */
|
||||
async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
|
||||
window.history.replaceState(null, "", url);
|
||||
vi.resetModules();
|
||||
vi.doMock("./api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./api")>();
|
||||
return { ...actual, api: { ...actual.api, verifyEmail } };
|
||||
});
|
||||
const { useVerifyEntry } = await import("./useVerifyEntry");
|
||||
|
||||
function Probe() {
|
||||
const entry = useVerifyEntry();
|
||||
return <div data-testid="outcome">{`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}</div>;
|
||||
}
|
||||
await act(async () => {
|
||||
render(<Probe />);
|
||||
});
|
||||
return () => screen.getByTestId("outcome").textContent;
|
||||
}
|
||||
|
||||
describe("confirming an emailed verification link", () => {
|
||||
it("posts the fragment token and reports the confirmed address", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-123", verifyEmail);
|
||||
|
||||
expect(verifyEmail).toHaveBeenCalledWith("tok-123");
|
||||
expect(outcome()).toBe("ok:someone@example.com");
|
||||
});
|
||||
|
||||
it("posts exactly once, so a re-render cannot spend the single-use token twice", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "once@example.com" });
|
||||
|
||||
await mountAt("/#verify=tok-once", verifyEmail);
|
||||
|
||||
// A second POST would 400 on the already-consumed token and flip a success to "invalid".
|
||||
expect(verifyEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports ok_reset when the account's password had been revoked", async () => {
|
||||
const verifyEmail = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, password_reset_required: true, email: "revoked@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-reset", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("ok_reset:revoked@example.com");
|
||||
});
|
||||
|
||||
it("reports invalid when the link is expired or already used", async () => {
|
||||
const verifyEmail = vi.fn().mockRejectedValue(new Error("400"));
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-dead", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("invalid:-");
|
||||
});
|
||||
|
||||
it("clears the address bar even with nothing to confirm, so a spent token leaves no trace", async () => {
|
||||
const verifyEmail = vi.fn();
|
||||
|
||||
const outcome = await mountAt("/?access=requested#reset=spent", verifyEmail);
|
||||
|
||||
expect(verifyEmail).not.toHaveBeenCalled();
|
||||
expect(outcome()).toBe("none:-");
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user