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:
2026-09-03 23:45:31 +02:00
parent 243869401a
commit c134b514c1
15 changed files with 337 additions and 73 deletions
+9 -1
View File
@@ -17,6 +17,7 @@ import {
import { notify, removeByMetaKind } from "./lib/notifications";
import { LS, setAccountRaw } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useVerifyEntry } from "./lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannelLayout } from "./components/ChannelsProvider";
@@ -37,6 +38,7 @@ import { StateMessage } from "./components/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
import VerifyBanner from "./components/VerifyBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
@@ -54,6 +56,11 @@ export default function App() {
const { t, i18n } = useTranslation();
const { page, channelView, ytSearch } = useNavigation();
const { setPage, enterYtSearch } = useNavigationActions();
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
// that branch never renders when signed in — the token was silently dropped. Signed out, the
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
const verifyEntry = useVerifyEntry();
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
@@ -288,7 +295,7 @@ export default function App() {
</StateMessage>
</div>
);
if (!meQuery.data) return <Welcome />;
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
const pageFallback = (
@@ -386,6 +393,7 @@ export default function App() {
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
banners={
<>
<VerifyBanner entry={verifyEntry} />
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
</>
+9 -1
View File
@@ -26,6 +26,7 @@ export default function Banner({
action,
onDismiss,
dismissTitle,
role,
}: {
tone: BannerTone;
icon: LucideIcon;
@@ -33,10 +34,17 @@ export default function Banner({
action?: { label: string; onClick: () => void };
onDismiss?: () => void;
dismissTitle?: string;
/** `"status"` for a bar that APPEARS in response to something the user just did, so a screen
* reader announces it. Left unset for the permanent bars (demo, version): those are part of the
* page when it loads, and announcing them on every visit is noise, not information. */
role?: "status";
}) {
const c = TONES[tone];
return (
<div className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}>
<div
role={role}
className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}
>
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
<span className="min-w-0">{children}</span>
{action && (
+41
View File
@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, AlertTriangle } from "lucide-react";
import Banner from "./Banner";
import type { VerifyEntry } from "../lib/useVerifyEntry";
// The top-of-app outcome of an emailed verification link opened in a browser that ALREADY holds a
// session. That case used to be silent: the token was only ever consumed inside Welcome.tsx, which a
// signed-in visit never reaches, so the link opened a tab, the fragment was stripped, and nothing
// happened. Signed OUT, the same outcome is rendered by the auth card's own banners instead — this
// bar is only for the signed-in half.
//
// No "send a new link" action here, unlike the auth card: this viewer is signed in, so the sign-in
// screen (where resending lives) is not where they are, and the message is a report, not a task. A
// wrong-account case is why the address is named.
export default function VerifyBanner({ entry }: { entry: VerifyEntry }) {
const { t } = useTranslation();
if (!entry.outcome) return null;
const ok = entry.outcome !== "invalid";
const text =
entry.outcome === "invalid"
? t("header.verify.invalid")
: entry.email
? t("header.verify.confirmedNamed", { email: entry.email })
: t("header.verify.confirmed");
return (
<Banner
tone={ok ? "accent" : "warning"}
icon={ok ? CheckCircle2 : AlertTriangle}
role="status"
onDismiss={entry.dismiss}
dismissTitle={t("header.banner.dismiss")}
>
{text}
{entry.outcome === "ok_reset" && (
<span className="text-muted"> {t("header.verify.reset")}</span>
)}
</Banner>
);
}
+28 -48
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useHistorySubview, type SubviewUrl } from "../lib/history";
import { useTranslation } from "react-i18next";
import {
@@ -24,6 +24,8 @@ import { HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { inputCls } from "./ui/form";
import { entryHash, entrySearch } from "../lib/entryUrl";
import type { VerifyEntry } from "../lib/useVerifyEntry";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
@@ -63,7 +65,7 @@ const SUBMIT_KEY: Record<Mode, string> = {
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown
// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the
// backend); errors are surfaced inline (the api calls use the quiet flag).
export default function Welcome() {
export default function Welcome({ verify }: { verify: VerifyEntry }) {
const { t, i18n } = useTranslation();
// Which screenshot (if any) is open in the full-size lightbox.
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
@@ -87,7 +89,7 @@ export default function Welcome() {
<p className="text-muted mt-4 text-lg leading-relaxed">{t("welcome.hero.subtitle")}</p>
<p className="text-sm text-muted mt-4">{t("welcome.hero.trust")}</p>
</div>
<AuthCard />
<AuthCard verify={verify} />
</section>
{/* App preview */}
@@ -301,51 +303,24 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
);
}
function AuthCard() {
function AuthCard({ verify }: { verify: VerifyEntry }) {
const { t } = useTranslation();
// Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below
// without dismissing the banners/reset-mode they drive. Secret tokens (reset, verify) ride the
// URL FRAGMENT so they never reach the server / proxy logs / Referer (SB3); plain status flags
// stay in the query string.
const [params] = useState(() => new URLSearchParams(window.location.search));
const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, "")));
// The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can
// rewrite the address bar out from under this card before it mounts — and so the App-level hook and
// this card read the SAME values regardless of which mounts first. Clearing the address bar is that
// hook's job now, since it is the one thing that runs on every visit, signed in or not.
//
// State, not a const: once the token has been spent the reset view must stop being the card's
// root, or a Back from the "password changed" screen lands on a form whose token is already used.
const [resetToken, setResetToken] = useState<string | null>(() => hashParams.get("reset"));
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
const bounced = params.get("access"); // Google denied → "requested" | "denied"
const deleted = params.get("deleted"); // self-service account deletion just completed
const [resetToken, setResetToken] = useState<string | null>(() => entryHash.get("reset"));
const bounced = entrySearch.get("access"); // Google denied → "requested" | "denied"
const deleted = entrySearch.get("deleted"); // self-service account deletion just completed
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
const [login, setLogin] = useState<string | null>(() => params.get("login"));
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
// Strip BOTH the query string and the fragment so the address bar stays clean and the token
// doesn't linger in history.
useEffect(() => {
if (window.location.search || window.location.hash) {
window.history.replaceState(null, "", window.location.pathname);
}
}, []);
// Confirm a fragment verification token by POSTing it (new SB3 flow). Fire ONCE: the token is
// single-use, so a double POST (StrictMode remount / any re-render) would 400 on the second call
// and wrongly flip the banner to "invalid".
const verifyFired = useRef(false);
useEffect(() => {
if (!verifyToken || verifyFired.current) return;
verifyFired.current = true;
api
.verifyEmail(verifyToken)
// "ok_reset": verified, but SEC-10 had dropped the password this account registered with. Sign-in
// would answer with the uniform credentials error, so point the user at the reset flow instead.
// Same value the legacy ?verify= redirect uses. Rare since E9.5 S1 (admin approval no longer
// revokes) — it still covers accounts stripped before that and the Google-adoption path.
.then((r) => setVerify(r.password_reset_required ? "ok_reset" : "ok"))
.catch(() => setVerify("invalid"));
}, [verifyToken]);
const [login, setLogin] = useState<string | null>(() => entrySearch.get("login"));
// The verification outcome is confirmed in App (above the signed-in branch) and handed down, so
// one emailed link behaves the same whether or not this browser already holds a session.
const verifyOutcome = verify.outcome;
const { view: mode, open: setMode } = useHistorySubview<Mode>(
resetToken ? "reset" : "signin",
@@ -386,7 +361,7 @@ function AuthCard() {
setDone("");
// The verify/login banners are one-time entry notices — dismiss once the user takes any
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
setVerify(null);
verify.dismiss();
setLogin(null);
};
@@ -475,8 +450,8 @@ function AuthCard() {
<h2 className="text-lg font-semibold mb-1">{t(titleKey)}</h2>
{/* One-time status banners from a redirect (Google-denied, email verification). */}
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
{verify === "ok_reset" && (
{verifyOutcome === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
{verifyOutcome === "ok_reset" && (
<Banner tone="warn">
{t("welcome.auth.verifyOkReset")}{" "}
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
@@ -484,7 +459,7 @@ function AuthCard() {
</button>
</Banner>
)}
{verify === "invalid" && (
{verifyOutcome === "invalid" && (
<Banner tone="warn">
{t("welcome.auth.verifyInvalid")}{" "}
<button onClick={goResend} className="font-semibold text-accent hover:underline">
@@ -648,9 +623,14 @@ function AuthCard() {
);
}
// `role="status"` because these appear in response to something that just happened (a verification
// link, a denied Google sign-in, a completed deletion) rather than being part of the card at rest —
// without it a screen-reader user gets no announcement that anything changed.
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-warn/15 text-fg";
return (
<div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>
<div role="status" className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>
{children}
</div>
);
}
@@ -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();
});
});
+6
View File
@@ -55,5 +55,11 @@
"updated": "Updated to v{{version}} — see what's changed.",
"releaseNotes": "Release notes",
"dismiss": "Dismiss"
},
"verify": {
"confirmed": "Email address confirmed.",
"confirmedNamed": "Email address confirmed — {{email}}.",
"invalid": "That verification link is no longer valid — it has expired or was already used.",
"reset": "The password this account registered with was cleared for security; set a new one before signing in."
}
}
+1 -1
View File
@@ -86,7 +86,7 @@
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox.",
"resetDone": "Your password has been set — you can sign in now.",
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
"verifyOkReset": "Email confirmed. Your account was approved before you confirmed it, so the password you registered with was cleared for security — choose a new one to sign in.",
"verifyOkReset": "Email confirmed. The password this account registered with had been cleared for security — choose a new one to sign in.",
"verifyOkResetCta": "Set a new password",
"verifyInvalid": "That verification link is invalid or has expired.",
"resendCta": "Send a new link",
+6
View File
@@ -55,5 +55,11 @@
"updated": "Frissítve a(z) v{{version}} verzióra — nézd meg, mi változott.",
"releaseNotes": "Verziójegyzék",
"dismiss": "Elvetés"
},
"verify": {
"confirmed": "Megerősítettük az e-mail-címedet.",
"confirmedNamed": "Megerősítettük az e-mail-címedet — {{email}}.",
"invalid": "Ez a megerősítő link már nem érvényes — lejárt, vagy már felhasználták.",
"reset": "A fiók regisztrációkori jelszavát biztonsági okból töröltük; belépés előtt állíts be újat."
}
}
+1 -1
View File
@@ -86,7 +86,7 @@
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod.",
"resetDone": "A jelszavad be van állítva — most már beléphetsz.",
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
"verifyOkReset": "E-mail megerősítve. A fiókodat még a megerősítés előtt jóváhagyták, ezért biztonsági okból töröltük a regisztrációkor megadott jelszavadat — válassz újat a belépéshez.",
"verifyOkReset": "E-mail megerősítve. A fiók regisztrációkori jelszavát biztonsági okból korábban töröltük — válassz újat a belépéshez.",
"verifyOkResetCta": "Új jelszó beállítása",
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
"resendCta": "Új link küldése",
+5 -1
View File
@@ -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.
+26
View File
@@ -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);
}
}
+52
View File
@@ -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) };
}
+87
View File
@@ -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("");
});
});