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:
@@ -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>
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
"createButton": "Create account",
|
||||
"forgotButton": "Send reset link",
|
||||
"resetButton": "Set password",
|
||||
"resendButton": "Resend verification",
|
||||
"resendTitle": "Resend verification email",
|
||||
"createLink": "Create an account",
|
||||
"forgotLink": "Forgot password?",
|
||||
"backToSignin": "Back to sign in",
|
||||
@@ -72,6 +74,9 @@
|
||||
"resetDone": "Your password has been set — you can sign in now.",
|
||||
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
|
||||
"verifyInvalid": "That verification link is invalid or has expired.",
|
||||
"resendCta": "Send a new link",
|
||||
"resendDone": "If that email has an unverified account, we've sent a new verification link. Check your inbox.",
|
||||
"oauthCancelled": "Google sign-in was cancelled. You can try again or use email and password.",
|
||||
"accessRequested": "Thanks — we recorded your request. An admin will review it.",
|
||||
"accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.",
|
||||
"suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).",
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
"createButton": "Fiók létrehozása",
|
||||
"forgotButton": "Visszaállító link küldése",
|
||||
"resetButton": "Jelszó beállítása",
|
||||
"resendButton": "Megerősítő link újraküldése",
|
||||
"resendTitle": "Megerősítő e-mail újraküldése",
|
||||
"createLink": "Hozz létre egy fiókot",
|
||||
"forgotLink": "Elfelejtetted a jelszót?",
|
||||
"backToSignin": "Vissza a belépéshez",
|
||||
@@ -72,6 +74,9 @@
|
||||
"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.",
|
||||
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
|
||||
"resendCta": "Új link küldése",
|
||||
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod.",
|
||||
"oauthCancelled": "A Google-belépés megszakadt. Próbáld újra, vagy lépj be e-maillel és jelszóval.",
|
||||
"accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.",
|
||||
"accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.",
|
||||
"suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).",
|
||||
|
||||
@@ -1557,6 +1557,14 @@ export const api = {
|
||||
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
||||
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
|
||||
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.
|
||||
resendVerification: (email: string): Promise<{ status: string }> =>
|
||||
req(
|
||||
"/auth/verify/resend",
|
||||
{ method: "POST", body: JSON.stringify({ email }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
// SA4: sign every OTHER session for this account out (keeps the current one).
|
||||
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
|
||||
// Set or change the signed-in account's password (errors shown inline via quiet).
|
||||
|
||||
Reference in New Issue
Block a user