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
+159 -6
View File
@@ -3,7 +3,9 @@ import { useHistorySubview, type SubviewUrl } from "../lib/history";
import { useTranslation } from "react-i18next";
import {
ArrowRight,
Check,
Clapperboard,
Clock,
Code2,
Download,
Expand,
@@ -11,15 +13,17 @@ import {
Inbox,
ListVideo,
Lock,
MailWarning,
PlayCircle,
Search,
ShieldAlert,
X,
type LucideIcon,
} from "lucide-react";
// Siftlode is open source; the landing footer links to the public repository.
const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode";
import { api } from "../lib/api";
import { api, loginBlocked, type LoginBlocked } from "../lib/api";
import { HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
@@ -313,7 +317,13 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
// State, not a const: once the token has been spent the reset view must stop being the card's
// root, or a Back from the "password changed" screen lands on a form whose token is already used.
const [resetToken, setResetToken] = useState<string | null>(() => entryHash.get("reset"));
const bounced = entrySearch.get("access"); // Google denied → "requested" | "denied"
// The Google access-request notice, off the FRAGMENT (see lib/entryUrl): it names the address now,
// and an address in the query string would land in every reverse-proxy access log. `linked` is set
// only when the request already existed under ANOTHER spelling of the same mailbox — the case worth
// explaining; the same spelling twice is just a repeat.
const bounced = entryHash.get("access"); // Google denied → "requested" | "denied"
const bouncedEmail = entryHash.get("email");
const bouncedLinked = entryHash.get("linked");
const deleted = entrySearch.get("deleted"); // self-service account deletion just completed
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
@@ -341,12 +351,17 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
.catch(() => {});
}, []);
const [done, setDone] = useState<string>(""); // success/info message replacing the form
// Why password sign-in refused, when the server named a state worth a screen rather than a red line
// (E9.5 S4). It REPLACES the form: in all three of those states the password was already proven
// correct, so there is nothing to retype — leaving the form up would only invite a pointless retry.
const [blocked, setBlocked] = useState<LoginBlocked | null>(null);
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
// — without this, Back out of a submitted form would show the old success message under the
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
useEffect(() => {
setDone("");
setError("");
setBlocked(null);
}, [mode]);
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
@@ -359,6 +374,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
const reset = () => {
setError("");
setDone("");
setBlocked(null);
// The verify/login banners are one-time entry notices — dismiss once the user takes any
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
verify.dismiss();
@@ -415,6 +431,13 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
setDone(t("welcome.auth.resetDone"));
}
} catch (err) {
// An account-shaped refusal becomes a named screen; `invalid` / `rate_limited` stay a line
// under the form, because there the form IS the next step.
const refusal = err instanceof HttpError ? loginBlocked(err.detailData) : null;
if (refusal && PANEL_REASONS.has(refusal.reason)) {
setBlocked(refusal);
return;
}
setError(
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
);
@@ -443,11 +466,19 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
}
}
const titleKey = TITLE_KEY[mode];
const titleKey = blocked ? `welcome.auth.blocked.${blocked.reason}.title` : TITLE_KEY[mode];
const BlockedIcon = blocked ? BLOCKED_ICON[blocked.reason] : null;
return (
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
<h2 className="text-lg font-semibold mb-1">{t(titleKey)}</h2>
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
{BlockedIcon && (
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
<BlockedIcon className="w-[18px] h-[18px]" />
</span>
)}
{t(titleKey)}
</h2>
{/* One-time status banners from a redirect (Google-denied, email verification). */}
{verifyOutcome === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
@@ -467,7 +498,26 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
</button>
</Banner>
)}
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
{bounced === "requested" &&
(bouncedEmail ? (
<Banner tone="ok">
<span className="block">{t("welcome.auth.accessRequestedFor")}</span>
<Address value={bouncedEmail} />
<span className="block">{t("welcome.auth.accessRequestedReview")}</span>
</Banner>
) : (
<Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>
))}
{/* The alias insight, stated only when it applies: Google answered with one spelling of a
mailbox whose request is already on file under another. Silence here is what let the
2026-09-03 registrant believe nothing had been recorded at all. */}
{bounced === "requested" && bouncedLinked && (
<Banner tone="warn">
<span className="block">{t("welcome.auth.accessLinked")}</span>
<Address value={bouncedLinked} />
<span className="block">{t("welcome.auth.accessLinkedTail")}</span>
</Banner>
)}
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
{login === "oauth_cancelled" && (
@@ -475,7 +525,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
)}
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
{done ? (
{blocked ? (
<BlockedPanel blocked={blocked} onResend={goResend} onBack={() => setBlocked(null)} />
) : done ? (
<div className="mt-3">
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
<button
@@ -623,6 +675,107 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
);
}
// An email address the user has to READ and compare, character by character — the recorded address on
// a blocked-sign-in panel, the mailbox a request was filed under. Monospaced and breakable because
// that comparison is the whole job: a dot or a `+tag` in a proportional font is exactly what nobody
// noticed on 2026-09-03.
function Address({ value }: { value: string }) {
return <span className="block font-mono font-medium break-all my-0.5">{value}</span>;
}
// Which refusals earn a screen of their own. The other two (`invalid`, `rate_limited`) are answered
// BEFORE the password is proven, carry no account detail, and leave the form as the next step — a
// line under it is the honest size for them.
const PANEL_REASONS = new Set<LoginBlocked["reason"]>([
"email_unverified",
"pending_approval",
"suspended",
]);
const BLOCKED_ICON: Partial<Record<LoginBlocked["reason"], LucideIcon>> = {
email_unverified: MailWarning,
pending_approval: Clock,
suspended: ShieldAlert,
};
// The named state behind a 403, in place of the sign-in form (E9.5 S4). It says WHICH mailbox the
// account is on and WHICH of the two gates is still shut, because the flat "awaiting admin approval"
// string it replaces was true and useless: it never revealed that the address the account carried was
// a different spelling of the one the user had just typed.
function BlockedPanel({
blocked,
onResend,
onBack,
}: {
blocked: LoginBlocked;
onResend: () => void;
onBack: () => void;
}) {
const { t } = useTranslation();
const { reason, email, emailVerified, approved, operator } = blocked;
// Suspension is not a gate that will open on its own, so the checklist would be a lie there.
const gated = reason !== "suspended";
return (
<div className="mt-3 flex flex-col gap-4">
<p className="text-sm text-fg/80 leading-relaxed">
{t(`welcome.auth.blocked.${reason}.body`)}
</p>
{gated && (
<div className="rounded-xl border border-border bg-card/60 px-3.5 py-3 flex flex-col gap-2">
<Gate done={emailVerified === true} k="email" />
<Gate done={approved === true} k="approval" />
</div>
)}
{email && (
<div className="border-l-2 border-border pl-3 flex flex-col gap-0.5">
<span className="text-2xs uppercase tracking-wide text-muted">
{t("welcome.auth.blocked.addressLabel")}
</span>
<span className="text-sm">
<Address value={email} />
</span>
</div>
)}
{operator && (
<p className="text-xs text-muted leading-relaxed">
{t("welcome.auth.blocked.operator", { contact: operator })}
</p>
)}
<div className="flex items-center justify-between gap-3">
<button onClick={onBack} className="text-sm text-accent hover:underline">
{t("welcome.auth.backToSignin")}
</button>
{reason === "email_unverified" && (
<button
onClick={onResend}
className="shrink-0 px-3 py-2 rounded-xl text-xs font-semibold bg-card border border-border hover:border-accent transition"
>
{t("welcome.auth.resendCta")}
</button>
)}
</div>
</div>
);
}
// One row of the two-gate checklist. Both states get their own full sentence rather than a shared
// label plus a status word: Hungarian puts them together differently, and gluing translated
// fragments is how a bilingual UI ends up reading like neither language.
function Gate({ done, k }: { done: boolean; k: "email" | "approval" }) {
const { t } = useTranslation();
const Icon = done ? Check : Clock;
return (
<span className={`flex items-center gap-2 text-xs ${done ? "text-fg/80" : "text-muted"}`}>
<Icon className={`w-4 h-4 shrink-0 ${done ? "text-success" : ""}`} />
{t(`welcome.auth.blocked.gate.${k}${done ? "Done" : "Pending"}`)}
</span>
);
}
// `role="status"` because these appear in response to something that just happened (a verification
// link, a denied Google sign-in, a completed deletion) rather than being part of the card at rest —
// without it a screen-reader user gets no announcement that anything changed.