feat(auth): R4 S1 — fix auth dead ends

- password reset also sets email_verified (the mail proves the mailbox),
  rescuing an account that never clicked the verify link; is_active (admin
  approval) is deliberately left untouched
- add POST /auth/verify/resend (rate-limited, off-response-path, anti-enum)
  + a "resend" mode/CTA on the verifyInvalid banner, so an expired/lost
  verification link is no longer a dead end (re-register was a silent no-op)
- OAuth cancel/deny now redirects to /?login=oauth_cancelled with a friendly
  banner instead of a raw 400 JSON page; pop the oauth_link markers BEFORE the
  token exchange so an aborted link flow can't poison the next clean sign-in
This commit is contained in:
2026-07-23 00:15:26 +02:00
parent e663247791
commit a851fb213b
5 changed files with 112 additions and 16 deletions
+32 -6
View File
@@ -24,7 +24,7 @@ import LanguageSwitcher from "./LanguageSwitcher";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
type Mode = "signin" | "register" | "forgot" | "reset";
type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
// 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
@@ -327,7 +327,10 @@ function AuthCard() {
e.preventDefault();
reset();
const mail = email.trim().toLowerCase();
if ((mode === "signin" || mode === "register" || mode === "forgot") && !EMAIL_RE.test(mail)) {
if (
(mode === "signin" || mode === "register" || mode === "forgot" || mode === "resend") &&
!EMAIL_RE.test(mail)
) {
setError(t("welcome.auth.invalidEmail"));
return;
}
@@ -348,6 +351,9 @@ function AuthCard() {
} else if (mode === "forgot") {
await api.requestPasswordReset(mail);
setDone(t("welcome.auth.forgotDone"));
} else if (mode === "resend") {
await api.resendVerification(mail);
setDone(t("welcome.auth.resendDone"));
} else if (mode === "reset") {
await api.confirmPasswordReset(resetToken ?? "", password);
setDone(t("welcome.auth.resetDone"));
@@ -390,7 +396,9 @@ function AuthCard() {
? "welcome.auth.forgotTitle"
: mode === "reset"
? "welcome.auth.resetTitle"
: "welcome.auth.signinTitle";
: mode === "resend"
? "welcome.auth.resendTitle"
: "welcome.auth.signinTitle";
return (
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
@@ -398,10 +406,26 @@ function AuthCard() {
{/* One-time status banners from a redirect (Google-denied, email verification). */}
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
{verify === "invalid" && <Banner tone="warn">{t("welcome.auth.verifyInvalid")}</Banner>}
{verify === "invalid" && (
<Banner tone="warn">
{t("welcome.auth.verifyInvalid")}{" "}
<button
onClick={() => {
reset();
setMode("resend");
}}
className="font-semibold text-accent hover:underline"
>
{t("welcome.auth.resendCta")}
</button>
</Banner>
)}
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
{login === "oauth_cancelled" && (
<Banner tone="warn">{t("welcome.auth.oauthCancelled")}</Banner>
)}
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
{done ? (
@@ -431,7 +455,7 @@ function AuthCard() {
className={inputCls}
/>
)}
{mode !== "forgot" && (
{mode !== "forgot" && mode !== "resend" && (
<input
type="password"
autoComplete={mode === "signin" ? "current-password" : "new-password"}
@@ -461,7 +485,9 @@ function AuthCard() {
? "welcome.auth.forgotButton"
: mode === "reset"
? "welcome.auth.resetButton"
: "welcome.auth.signinButton",
: mode === "resend"
? "welcome.auth.resendButton"
: "welcome.auth.signinButton",
)}
</button>
</form>