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:
@@ -146,10 +146,16 @@ export function readPage(): Page {
|
||||
return isPage(p) ? p : "feed";
|
||||
}
|
||||
|
||||
/** Build a URL (path + query) from the CURRENT location's query with `updates` applied — a null value
|
||||
* deletes that key, any other string sets it. Lets each provider own its own nav key(s) — page / chan /
|
||||
* v / pl / the feed-filter keys — and update the address bar without clobbering the others (read-modify-
|
||||
* write on the live query). The caller decides pushState vs replaceState with the returned string. */
|
||||
/** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query —
|
||||
* a null value deletes that key, any other string sets it. Lets each provider own its own nav key(s) —
|
||||
* page / chan / v / pl / the feed-filter keys — and update the address bar without clobbering the others
|
||||
* (read-modify-write on the live query). The caller decides pushState vs replaceState with the string.
|
||||
*
|
||||
* ⚠️ The FRAGMENT must be carried over. The one-time auth tokens ride it (`#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's this URL before the auth card's
|
||||
* chunk has even loaded. Dropping the hash here silently ate the token: the verification link opened a
|
||||
* bare sign-in page and the account could never be verified. */
|
||||
export function urlWithParams(updates: Record<string, string | null>): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
@@ -157,7 +163,7 @@ export function urlWithParams(updates: Record<string, string | null>): string {
|
||||
else params.set(k, v);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return `${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
||||
return `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`;
|
||||
}
|
||||
|
||||
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
||||
@@ -178,6 +184,8 @@ export function clearParams(...keys: string[]): void {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||
// Same fragment rule as urlWithParams: this strips QUERY keys only, so a `#verify=…`/`#reset=…`
|
||||
// token that has not been consumed yet must survive. Welcome's own cleanup is what clears it.
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user