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:
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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(/^#/, ""));
|
||||
|
||||
Reference in New Issue
Block a user