fix(auth): stop the address-bar mirror eating emailed auth tokens, and unlock revoked accounts
Two independent bugs made an email+password registration unrecoverable in prod. The verification link did nothing. urlWithParams/clearParams rebuilt the URL as pathname+query and dropped the fragment — but the one-time auth tokens ride the fragment (#verify=/#reset=, SB3), and the providers that mirror nav params (useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE the lazily-loaded <App> in main.tsx. Their mount effect replaceState'd the token away before the auth card's chunk had loaded, so the card read an empty hash: the link opened a bare sign-in page, no request, no banner. Both helpers now carry the fragment over. The account was then locked out for good. SEC-10 nulls password_hash when an unverified account is attested (here: admin approval landing before the user could verify — which the bug above made impossible). That is indistinguishable from a Google-only account, and reset eligibility keyed off password_hash alone, so no reset link was ever issued: no password to sign in with, and no way to get one. users.password_revoked_at records the revocation, reset eligibility honours it, and setting a password again (reset-confirm or set-password) clears it. An account that never had a password stays ineligible on purpose — its owner adds one from Settings. /auth/verify now also answers password_reset_required so the card can say why the registered password stopped working, instead of leaving the user at the uniform "Invalid email or password". Telling the holder of a valid verify token leaks nothing: they have already proved they own the mailbox. The 0078 migration backfills the stamp for rows carrying the exact lockout signature (local, non-demo, no password, unverified), so existing victims recover unaided.
This commit is contained in:
@@ -339,7 +339,10 @@ function AuthCard() {
|
||||
verifyFired.current = true;
|
||||
api
|
||||
.verifyEmail(verifyToken)
|
||||
.then(() => setVerify("ok"))
|
||||
// "ok_reset": verified, but SEC-10 had dropped the password this account registered with (an
|
||||
// admin approved it before it was verified). 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.
|
||||
.then((r) => setVerify(r.password_reset_required ? "ok_reset" : "ok"))
|
||||
.catch(() => setVerify("invalid"));
|
||||
}, [verifyToken]);
|
||||
|
||||
@@ -392,6 +395,13 @@ function AuthCard() {
|
||||
setMode("resend");
|
||||
};
|
||||
|
||||
// Used by the "verified, but your password was revoked" banner — the only way back into that
|
||||
// account is a reset link, so put the user one click from it.
|
||||
const goForgot = () => {
|
||||
reset();
|
||||
setMode("forgot");
|
||||
};
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
@@ -465,6 +475,14 @@ function AuthCard() {
|
||||
|
||||
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
||||
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||
{verify === "ok_reset" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyOkReset")}{" "}
|
||||
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
|
||||
{t("welcome.auth.verifyOkResetCta")}
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{verify === "invalid" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyInvalid")}{" "}
|
||||
|
||||
Reference in New Issue
Block a user