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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user