fix(auth): address R4 S1 code-review findings

- link/upgrade OAuth cancel now redirects to /?link=cancelled (a channel App.tsx
  surfaces as a toast) instead of the pre-auth /?login=oauth_cancelled banner a
  signed-in user never sees; a plain sign-in cancel is unchanged
- the login banner (suspended / oauth_cancelled) is now state, cleared on the
  first deliberate action like the verify banner — no more stale lingering
- add a "Didn't get the verification email?" resend entry on the sign-in screen
  so a never-arrived verification mail isn't a dead end (was only reachable from
  the expired-link banner)
- unit-test _oauth_redirect_uri (dev-host derive, unknown-host fallback, prod
  ignores the Host header — the injection guard)
- collapse the 5-deep title/button ternaries into Record<Mode,string> maps
This commit is contained in:
2026-07-23 01:04:09 +02:00
parent b4d2ad75e0
commit c837b59373
8 changed files with 112 additions and 30 deletions
+39 -26
View File
@@ -26,6 +26,23 @@ const PASSWORD_MIN = 10;
type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
// keeps adding a new mode a one-line change and makes a missing key a compile error.
const TITLE_KEY: Record<Mode, string> = {
signin: "welcome.auth.signinTitle",
register: "welcome.auth.createTitle",
forgot: "welcome.auth.forgotTitle",
reset: "welcome.auth.resetTitle",
resend: "welcome.auth.resendTitle",
};
const SUBMIT_KEY: Record<Mode, string> = {
signin: "welcome.auth.signinButton",
register: "welcome.auth.createButton",
forgot: "welcome.auth.forgotButton",
reset: "welcome.auth.resetButton",
resend: "welcome.auth.resendButton",
};
// Public landing + authentication. One scrollable page: a hero with the auth card (email+
// 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
@@ -267,8 +284,10 @@ function AuthCard() {
const resetToken = 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 login = params.get("login"); // Google login blocked → "suspended"
const deleted = params.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"));
@@ -321,9 +340,10 @@ function AuthCard() {
const reset = () => {
setError("");
setDone("");
// The verify banners are a one-time entry notice — dismiss once the user takes any deliberate
// action (mode switch, CTA, submit) so it doesn't linger stalely on later screens.
// 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);
setLogin(null);
};
async function onSubmit(e: React.FormEvent) {
@@ -392,16 +412,7 @@ function AuthCard() {
const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent";
const titleKey =
mode === "register"
? "welcome.auth.createTitle"
: mode === "forgot"
? "welcome.auth.forgotTitle"
: mode === "reset"
? "welcome.auth.resetTitle"
: mode === "resend"
? "welcome.auth.resendTitle"
: "welcome.auth.signinTitle";
const titleKey = TITLE_KEY[mode];
return (
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
@@ -479,19 +490,7 @@ function AuthCard() {
disabled={busy}
className="px-4 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{busy
? t("welcome.auth.working")
: t(
mode === "register"
? "welcome.auth.createButton"
: mode === "forgot"
? "welcome.auth.forgotButton"
: mode === "reset"
? "welcome.auth.resetButton"
: mode === "resend"
? "welcome.auth.resendButton"
: "welcome.auth.signinButton",
)}
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
</button>
</form>
@@ -531,6 +530,20 @@ function AuthCard() {
)}
</div>
{/* A verification email can be lost or never arrive, so offer a resend path from sign-in
too — not only from the expired-link banner (which needs a broken link to appear). */}
{mode === "signin" && (
<button
onClick={() => {
reset();
setMode("resend");
}}
className="mt-2 block w-full text-center text-xs text-muted hover:text-fg"
>
{t("welcome.auth.resendLink")}
</button>
)}
{mode === "signin" && (
<>
<div className="flex items-center gap-3 my-4 text-[11px] uppercase tracking-wide text-muted">