fix(auth): name the state and the address behind a refused sign-in (E9.5 S4)

"Your account is awaiting admin approval." was true and useless. It never said WHICH mailbox the
account was on, and on 2026-09-03 that silence hid a Gmail alias mismatch for a day: a registration
as np.temu@gmail.com met a Google claim of nptemu@gmail.com, and every screen in the funnel agreed
to say nothing about it.

Every refusal password-login can answer with now uses the codebase's structured {code, reason}
detail, built in one helper. The three that arrive AFTER the password is proven carry the address on
the account plus both gate flags; the two that answer before it (invalid, rate_limited) carry
nothing, so a wrong password and an unknown account stay byte-identical. The frontend renders the
first three as a named screen in place of the form — there is nothing to retype once the password is
correct — with an icon, the two-gate checklist, the address in monospace, and a resend CTA where one
helps. Reporting each gate from its own flag is the payoff of S1: approved-but-unverified is a real
state now, and a screen that assumed a sequence would send that user off to wait for an admin who
has already decided.

The Google access-request notice names the address too, and says so when the request was already on
file under ANOTHER spelling of the same mailbox — the honest version of "we didn't open a second
one", stated only when it applies. It moved from the query string to the fragment: the notice now
carries an address, and a query string lands in every reverse-proxy access log. Same reasoning that
put the verify/reset tokens there, and the same owner already clears it. urlencode, not an f-string,
because URLSearchParams reads a bare + in a fragment as a space and would mangle exactly the
+tag addresses this epic exists to protect.

upsert_pending_invite returns the row it settled on beside the is-new flag, since the row's stored
address is what the notice has to name and what mail will go to.

Deviation from the PendingScreen mockup, deliberately: no "Sign out" button and no separate "Not
your address?" link. The mockup drew the panel in the app shell; the accepted decision (a) keeps the
pending user session-less, so there is no session to end, and the address is named directly above a
"Back to sign in" that answers the same question.

UAT on localdev, both languages: all three refusals with the typed spelling deliberately differing
from the stored one (the panel named the stored one), the resend CTA carrying the typed address into
the resend form, and both access-request banners with a +tag address surviving the fragment round
trip. Throwaway accounts and the language switch reverted.
This commit is contained in:
2026-09-04 01:56:55 +02:00
parent a614ee2435
commit ed91c83dd7
12 changed files with 656 additions and 36 deletions
+1
View File
@@ -27,6 +27,7 @@ import { accountApi } from "./api/account";
export type * from "./api/account";
import { authApi } from "./api/auth";
export type * from "./api/auth";
export { loginBlocked } from "./api/auth";
import { setupApi } from "./api/setup";
export type * from "./api/setup";
import { feedApi } from "./api/feed";
+42
View File
@@ -3,6 +3,47 @@
// are surfaced inline (quiet) rather than via the global modal. Only touches core's req.
import { req } from "./core";
/** Why password sign-in refused. The three account-shaped reasons name the mailbox and both gates,
* so the card can render an honest state instead of a red line; `invalid` and `rate_limited` answer
* BEFORE the password is proven and therefore carry nothing (they must stay identical for a real
* account and an unknown one). Since E9.5 S1 the gates are order-independent — read both flags, never
* infer one from the other. */
export type LoginBlocked = {
reason: "email_unverified" | "pending_approval" | "suspended" | "invalid" | "rate_limited";
/** The address ON THE ACCOUNT — not the spelling that was typed. Absent for the two pre-proof
* reasons. Naming it is what would have exposed the 2026-09-03 Gmail-alias mismatch in a second. */
email?: string;
emailVerified?: boolean;
approved?: boolean;
/** Operator contact, when the instance publishes one (suspended accounts only). */
operator?: string;
};
const LOGIN_REASONS = new Set<LoginBlocked["reason"]>([
"email_unverified",
"pending_approval",
"suspended",
"invalid",
"rate_limited",
]);
/** Read a `{code:"login", …}` error detail into LoginBlocked, or null for anything else — including
* a reason a NEWER server invented, which the card must fall back to a plain message for rather than
* render as a blank panel. */
export function loginBlocked(detail: unknown): LoginBlocked | null {
const d = detail as Record<string, unknown> | null | undefined;
if (!d || d.code !== "login") return null;
const reason = d.reason as LoginBlocked["reason"];
if (!LOGIN_REASONS.has(reason)) return null;
return {
reason,
email: typeof d.email === "string" ? d.email : undefined,
emailVerified: typeof d.email_verified === "boolean" ? d.email_verified : undefined,
approved: typeof d.approved === "boolean" ? d.approved : undefined,
operator: typeof d.operator === "string" ? d.operator : undefined,
};
}
export const authApi = {
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
authConfig: (): Promise<{
@@ -18,6 +59,7 @@ export const authApi = {
{ method: "POST", body: JSON.stringify({ email, password }) },
{ quiet: true },
),
// A refusal carries a structured `{code:"login", reason, …}` detail — see LoginBlocked above.
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-login",
+6
View File
@@ -46,6 +46,12 @@ function localizeDetail(d: unknown): string {
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals (E9.5 S4). The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
+3 -1
View File
@@ -10,7 +10,9 @@
// cannot lose the race no matter who mounts first or which chunk loads when.
//
// Secret tokens (verify, reset) ride the FRAGMENT so they never reach the server, a proxy log or a
// Referer header (SB3); plain status flags ride the query string.
// Referer header (SB3); so does the Google access-request notice (`access`, and the addresses it
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
// with nothing personal in them (login, deleted) ride the query string.
export const entrySearch = new URLSearchParams(window.location.search);
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));