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:
@@ -192,6 +192,9 @@ export default function App() {
|
||||
void meQuery.refetch();
|
||||
setAccountRaw(LS.settingsTab, "account");
|
||||
setPage("settings");
|
||||
} else if (result === "cancelled") {
|
||||
// The user backed out at Google's consent screen — nothing changed, just acknowledge it.
|
||||
notify({ level: "info", message: t("settings.account.googleLink.cancelled") });
|
||||
} else {
|
||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"linked": "Google account linked.",
|
||||
"conflict": "That Google account is already linked to a different Siftlode account.",
|
||||
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
|
||||
"error": "Couldn't link the Google account. Please try again."
|
||||
"error": "Couldn't link the Google account. Please try again.",
|
||||
"cancelled": "Google account linking was cancelled."
|
||||
},
|
||||
"password": {
|
||||
"title": "Password",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"resetButton": "Set password",
|
||||
"resendButton": "Resend verification",
|
||||
"resendTitle": "Resend verification email",
|
||||
"resendLink": "Didn't get the verification email?",
|
||||
"createLink": "Create an account",
|
||||
"forgotLink": "Forgot password?",
|
||||
"backToSignin": "Back to sign in",
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"linked": "Google-fiók összekapcsolva.",
|
||||
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
|
||||
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
|
||||
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra."
|
||||
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra.",
|
||||
"cancelled": "A Google-fiók összekapcsolása megszakadt."
|
||||
},
|
||||
"password": {
|
||||
"title": "Jelszó",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"resetButton": "Jelszó beállítása",
|
||||
"resendButton": "Megerősítő link újraküldése",
|
||||
"resendTitle": "Megerősítő e-mail újraküldése",
|
||||
"resendLink": "Nem kaptad meg a megerősítő e-mailt?",
|
||||
"createLink": "Hozz létre egy fiókot",
|
||||
"forgotLink": "Elfelejtetted a jelszót?",
|
||||
"backToSignin": "Vissza a belépéshez",
|
||||
|
||||
Reference in New Issue
Block a user