diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 214813ff..c98d434d 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -6,6 +6,8 @@ helpers) stays in app/auth.py, imported below.""" import ipaddress import secrets from datetime import datetime, timedelta, timezone +from typing import NamedTuple +from urllib.parse import urlencode from authlib.integrations.starlette_client import OAuth, OAuthError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request @@ -148,9 +150,22 @@ def _client_ip(request: Request) -> str: return peer -def upsert_pending_invite(db: Session, email: str) -> Invite | None: - """Idempotently record an access request. Returns the Invite if a *new* pending row - was created (so the caller can notify admins), else None for already-known emails. +class InviteRequest(NamedTuple): + """What an access request settled on. + + `invite` is the row the request now lives under — which is NOT always the address that was just + typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's + address is where mail will go. Callers that tell the user what happened need it (E9.5 S4). + `is_new` is the old boolean return in a name: true only when a row was created or a denied one + reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them. + """ + + invite: Invite | None + is_new: bool + + +def upsert_pending_invite(db: Session, email: str) -> InviteRequest: + """Idempotently record an access request. "Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one Gmail mailbox, and treating them as two is what put a second, silent access request in the admin @@ -160,7 +175,7 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None: """ email = (email or "").lower() if not valid_email(email): - return None + return InviteRequest(None, False) canon = canonical_email(email) rows = db.execute(select(Invite).where(Invite.email_canonical == canon)).scalars().all() # Prefer a decided row over a pending one, so a denied duplicate can't mask an approval. @@ -173,12 +188,37 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None: inv.decided_at = None inv.decided_by = None db.commit() - return inv - return None + return InviteRequest(inv, True) + return InviteRequest(inv, False) inv = Invite(email=email, status="pending") db.add(inv) db.commit() - return inv + return InviteRequest(inv, True) + + +def _access_notice_url(kind: str, email: str = "", inv: Invite | None = None) -> str: + """The landing URL for a Google sign-in that did not reach an account: `requested` (we recorded + the request) or `denied` (nothing to record). + + It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do + (SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this + notice now carries the user's address. Naming it is the point of E9.5 S4: on 2026-09-03 a + registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said + "request recorded" without saying FOR WHAT hid the mismatch for a day. + + `linked` names the address the request actually lives under, and ONLY when that differs from the + one Google just returned — the honest version of "we didn't open a second request". Same + spelling twice is just a repeat, and saying anything there would be noise. + + urlencode (not an f-string): a `+tag` address must arrive as %2B, or the SPA's URLSearchParams + would read it back as a space and mangle exactly the address this epic exists to protect. + """ + params = {"access": kind} + if email: + params["email"] = email + if inv is not None and inv.email != email: + params["linked"] = inv.email + return "/#" + urlencode(params) _DEV_OAUTH_HOSTS = frozenset( @@ -294,12 +334,12 @@ async def callback( # A denied Google login doubles as an access request: record it for the admin and # land the user on a friendly "request received" screen instead of a raw 403. if email: - inv = upsert_pending_invite(db, email) + req = upsert_pending_invite(db, email) admins = admin_notify_emails(db) - if inv is not None and admins: + if req.is_new and admins: background.add_task(email_mod.send_admin_new_request, admins, email) - return RedirectResponse(url="/?access=requested") - return RedirectResponse(url="/?access=denied") + return RedirectResponse(url=_access_notice_url("requested", email, req.invite)) + return RedirectResponse(url=_access_notice_url("denied")) user = db.query(User).filter(User.google_sub == sub).one_or_none() if user is None: @@ -323,7 +363,7 @@ async def callback( # A different Google identity is already bound to this email — refuse rather than # hijack it. (Near-impossible with real Google accounts: email↔sub is stable.) log.warning("Google login email collision (different sub): %s", email) - return RedirectResponse(url="/?access=denied") + return RedirectResponse(url=_access_notice_url("denied")) if not userinfo.get("email_verified", False): # Adopting (and activating) a pre-existing password account requires Google to # actually attest the address — otherwise an unverified Google email that merely @@ -332,7 +372,7 @@ async def callback( "Google login: unverified email, refusing to adopt account: %s", email, ) - return RedirectResponse(url="/?access=denied") + return RedirectResponse(url=_access_notice_url("denied")) user = existing user.google_sub = sub # SEC-10: drop any password pre-set on this account while its email was still unverified, @@ -538,9 +578,9 @@ async def request_access( return {"status": "pending"} if is_allowed(db, email): return {"status": "pending"} # already allowed — don't reveal it - inv = upsert_pending_invite(db, email) + req = upsert_pending_invite(db, email) admins = admin_notify_emails(db) - if inv is not None and admins: + if req.is_new and admins: background.add_task(email_mod.send_admin_new_request, admins, email) return {"status": "pending"} @@ -776,6 +816,33 @@ def _resend_verify_if_eligible(email: str) -> None: email_mod.send_verify_email(user.email, f"{_app_base()}/#verify={raw}") +def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException: + """Every refusal password_login can answer with, in the codebase's structured `{code, reason}` + shape so the SPA can say it in the user's own language (the old bare English strings were + unreadable under a bilingual UI, and unusable as anything but a one-line error). + + The account-shaped reasons carry the address ON THE ACCOUNT plus both gate flags. That is what + turns a flat 403 into an honest screen — it names the mailbox and says which of the two gates is + still shut. It is not an enumeration leak: this branch is only reachable once the password has + been proven, so the caller already knows the account exists. The two reasons that answer BEFORE + that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a + real account and an unknown one. + + Since E9.5 S1 the two gates are order-independent, so `approved` really can be true while + `email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding + rather than assuming a sequence. + """ + detail: dict = {"code": "login", "reason": reason} + if user is not None: + detail |= { + "email": user.email, + "email_verified": user.email_verified, + "approved": user.is_active, + } + detail |= {k: v for k, v in extra.items() if v is not None} + return HTTPException(status_code=status, detail=detail) + + @router.post("/password-login") def password_login( body: EmailPasswordIn, @@ -787,7 +854,7 @@ def password_login( Once the password is proven correct, the owner gets a specific reason if their account is still pending or suspended — that's not an enumeration leak (they already hold the password).""" if not _login_limiter.allow(_client_ip(request)): - raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.") + raise _login_refused(429, "rate_limited") email = (body.email or "").strip().lower() password = body.password or "" user = find_user_by_email(db, email) @@ -796,20 +863,14 @@ def password_login( candidate_hash = user.password_hash if (user and not user.is_demo) else None password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH) if user is None or user.is_demo or not password_ok: - raise HTTPException(status_code=401, detail="Invalid email or password.") + raise _login_refused(401, "invalid") if user.is_suspended: _notify_suspended(background, user.email) - op = operator_contact() - detail = "Your account has been suspended." - if op: - detail += f" If you have questions, contact the operator at {op}." - raise HTTPException(status_code=403, detail=detail) + raise _login_refused(403, "suspended", user, operator=operator_contact() or None) if not user.email_verified: - raise HTTPException( - status_code=403, detail="Please verify your email first (check your inbox)." - ) + raise _login_refused(403, "email_unverified", user) if not user.is_active: - raise HTTPException(status_code=403, detail="Your account is awaiting admin approval.") + raise _login_refused(403, "pending_approval", user) _establish_session(request, user) log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role) return {"ok": True} diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py index b3a3fcd3..905709eb 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -134,7 +134,7 @@ def test_an_approved_but_unverified_account_still_cannot_sign_in(client, make_us r = _login(client, "notyet@example.com", "attacker-chosen") assert r.status_code == 403 - assert "verify" in r.json()["detail"].lower() + assert r.json()["detail"]["reason"] == "email_unverified" def test_verifying_afterwards_lets_the_original_password_in(client, db, make_user): diff --git a/backend/tests/test_email_canonical.py b/backend/tests/test_email_canonical.py index 483230a9..f0934f0f 100644 --- a/backend/tests/test_email_canonical.py +++ b/backend/tests/test_email_canonical.py @@ -137,9 +137,10 @@ def test_a_second_spelling_does_not_open_a_second_access_request(db): db.add(Invite(email="already.approved@gmail.com", status="approved")) db.commit() - created = upsert_pending_invite(db, "alreadyapproved@gmail.com") + req = upsert_pending_invite(db, "alreadyapproved@gmail.com") - assert created is None # nothing new to notify an admin about + assert req.is_new is False # nothing new to notify an admin about + assert req.invite is not None and req.invite.email == "already.approved@gmail.com" assert len(db.execute(select(Invite)).scalars().all()) == 1 diff --git a/backend/tests/test_login_states.py b/backend/tests/test_login_states.py new file mode 100644 index 00000000..9b243853 --- /dev/null +++ b/backend/tests/test_login_states.py @@ -0,0 +1,107 @@ +"""What a refused sign-in SAYS (E9.5 S4), DB-lane. + +The states behind a 403 were true and useless: "Your account is awaiting admin approval." never +revealed WHICH mailbox the account was on. On 2026-09-03 a registration as `npeter83.temu@gmail.com` +met a Google claim of `npeter83temu@gmail.com`, and that missing address hid the mismatch for a day. +So these pin the DETAIL, not just the status code — the address, and both gates, since E9.5 S1 made +the two order-independent and the screen must report whichever is actually outstanding. + +The two refusals that answer BEFORE the password is proven carry nothing, and that is the security +half of the same contract: `invalid` must stay byte-identical for a real account and an unknown one. +""" + +from app.config import settings +from app.models import Invite +from app.routes.auth import _access_notice_url, upsert_pending_invite + +_PW = "correct horse battery" + + +def _login(client, email, password=_PW): + return client.post("/auth/password-login", json={"email": email, "password": password}) + + +def test_pending_approval_names_the_address_the_account_is_on(client, make_user): + # The typed spelling and the stored one are the same mailbox; the screen must show the STORED + # one, because that is the mismatch a user cannot otherwise see. + make_user(email="np.temu@gmail.com", password=_PW, is_active=False) + + detail = _login(client, "nptemu@gmail.com").json()["detail"] + + assert detail["reason"] == "pending_approval" + assert detail["email"] == "np.temu@gmail.com" + assert detail["email_verified"] is True and detail["approved"] is False + + +def test_an_already_approved_account_still_reports_its_missing_verification(client, make_user): + # S1 made the gates order-independent, so this really happens: approved, not yet verified. A + # screen that assumed a sequence would tell this user to wait for an admin who already decided. + make_user( + email="approved.first@example.com", password=_PW, email_verified=False, is_active=True + ) + + detail = _login(client, "approved.first@example.com").json()["detail"] + + assert detail["reason"] == "email_unverified" + assert detail["email_verified"] is False and detail["approved"] is True + + +def test_a_suspended_account_is_told_who_to_contact(client, make_user, monkeypatch): + monkeypatch.setattr(settings, "admin_emails", "ops@example.com") + make_user(email="banned@example.com", password=_PW, is_suspended=True) + + r = _login(client, "banned@example.com") + + assert r.status_code == 403 + assert r.json()["detail"]["reason"] == "suspended" + assert r.json()["detail"]["operator"] == "ops@example.com" + + +def test_a_refusal_before_the_password_is_proven_carries_no_account_detail(client, make_user): + make_user(email="real@example.com", password=_PW, is_active=False) + + wrong_pw = _login(client, "real@example.com", "not the password").json()["detail"] + unknown = _login(client, "nobody@example.com").json()["detail"] + + # No address, no flags — the pending state above must not leak through the wrong-password door. + assert wrong_pw == unknown == {"code": "login", "reason": "invalid"} + + +# --- the access-request notice ------------------------------------------------------------------- + + +def test_the_access_notice_names_the_address_and_rides_the_fragment(): + url = _access_notice_url("requested", "someone@example.com") + + # A fragment never reaches a proxy access log or a Referer header — the same reasoning that put + # the verify/reset tokens there (SB3), applied to an address. + assert url == "/#access=requested&email=someone%40example.com" + + +def test_a_plus_tagged_address_survives_the_round_trip(): + # URLSearchParams reads a bare `+` in a fragment as a SPACE, so the encoder must emit %2B — or + # the very address family this epic exists to protect arrives mangled. + url = _access_notice_url("requested", "me+siftlode@gmail.com") + + assert "me%2Bsiftlode%40gmail.com" in url and "+siftlode" not in url + + +def test_a_request_already_on_file_under_another_spelling_says_so(db): + db.add(Invite(email="hartmann.peter@gmail.com", status="pending")) + db.commit() + + req = upsert_pending_invite(db, "hartmannpeter@gmail.com") + url = _access_notice_url("requested", "hartmannpeter@gmail.com", req.invite) + + assert req.is_new is False # one mailbox, one request — no second row for the admin queue + assert "linked=hartmann.peter%40gmail.com" in url + + +def test_the_same_spelling_twice_is_just_a_repeat_and_says_nothing_extra(db): + db.add(Invite(email="same@example.com", status="pending")) + db.commit() + + req = upsert_pending_invite(db, "same@example.com") + + # Nothing to explain: telling this user we "linked" their request to itself would be noise. + assert "linked" not in _access_notice_url("requested", "same@example.com", req.invite) diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index bc20ef52..a37aecb9 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -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(() => 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(""); // 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(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 (
-

{t(titleKey)}

+

+ {BlockedIcon && ( + + + + )} + {t(titleKey)} +

{/* One-time status banners from a redirect (Google-denied, email verification). */} {verifyOutcome === "ok" && {t("welcome.auth.verifyOk")}} @@ -467,7 +498,26 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { )} - {bounced === "requested" && {t("welcome.auth.accessRequested")}} + {bounced === "requested" && + (bouncedEmail ? ( + + {t("welcome.auth.accessRequestedFor")} +
+ {t("welcome.auth.accessRequestedReview")} + + ) : ( + {t("welcome.auth.accessRequested")} + ))} + {/* 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 && ( + + {t("welcome.auth.accessLinked")} +
+ {t("welcome.auth.accessLinkedTail")} + + )} {bounced === "denied" && {t("welcome.auth.accessDenied")}} {login === "suspended" && {t("welcome.auth.suspended")}} {login === "oauth_cancelled" && ( @@ -475,7 +525,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { )} {deleted && {t("welcome.auth.deleted")}} - {done ? ( + {blocked ? ( + setBlocked(null)} /> + ) : done ? (

{done}

+ {reason === "email_unverified" && ( + + )} +
+
+ ); +} + +// 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 ( + + + {t(`welcome.auth.blocked.gate.${k}${done ? "Done" : "Pending"}`)} + + ); +} + // `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. diff --git a/frontend/src/components/authCardStates.dom.test.tsx b/frontend/src/components/authCardStates.dom.test.tsx new file mode 100644 index 00000000..a713e02e --- /dev/null +++ b/frontend/src/components/authCardStates.dom.test.tsx @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +// +// The honest states of the sign-in card (E9.5 S4). Two things are under test and they share a root: +// a screen that does not name the address it is talking about hides an alias mismatch. On 2026-09-03 +// a registration as `np.temu@gmail.com` met a Google claim of `nptemu@gmail.com` and every screen in +// the funnel stayed silent about it. +// +// entryUrl snapshots window.location at IMPORT time, so the access-notice cases reset the module +// registry and set the URL before importing — that is the behaviour under test, not a workaround. +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import type { VerifyEntry } from "../lib/useVerifyEntry"; + +const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} }; + +afterEach(() => { + cleanup(); + vi.resetModules(); + vi.restoreAllMocks(); + window.history.replaceState(null, "", "/"); +}); + +/** Mount the real card at a given entry URL, with i18n as a key-passthrough and /auth/* stubbed. */ +async function mountCard(url: string, apiOverrides: Record = {}) { + window.history.replaceState(null, "", url); + vi.resetModules(); + vi.doMock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts && typeof opts.contact === "string" ? `${key}:${opts.contact}` : key, + i18n: { language: "en" }, + }), + }; + }); + vi.doMock("../lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + api: { + ...actual.api, + authConfig: () => Promise.resolve({ google_enabled: false }), + ...apiOverrides, + }, + }; + }); + const { HttpError } = await import("../lib/api"); + const { default: Welcome } = await import("./Welcome"); + await act(async () => { + render(); + }); + return { HttpError }; +} + +/** Fill the sign-in form and submit it. */ +async function signIn(email = "someone@example.com") { + fireEvent.change(screen.getByPlaceholderText("welcome.auth.email"), { target: { value: email } }); + fireEvent.change(screen.getByPlaceholderText("welcome.auth.password"), { + target: { value: "correct horse battery" }, + }); + await act(async () => { + fireEvent.click(screen.getByText("welcome.auth.signinButton")); + }); +} + +/** A password-login refusal carrying the server's structured detail. */ +function refusal( + HttpError: typeof import("../lib/api").HttpError, + detail: Record, +) { + return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail })); +} + +describe("a refused sign-in becomes a named state", () => { + it("names the address the account is on, not the one that was typed", async () => { + const passwordLogin = vi.fn(); + const { HttpError } = await mountCard("/", { passwordLogin }); + passwordLogin.mockImplementation( + refusal(HttpError, { + reason: "pending_approval", + email: "np.temu@gmail.com", + email_verified: true, + approved: false, + }), + ); + + await signIn("nptemu@gmail.com"); + + expect(screen.getByText("welcome.auth.blocked.pending_approval.title")).toBeTruthy(); + expect(screen.getByText("np.temu@gmail.com")).toBeTruthy(); + // The password was already proven correct, so the form is not the next step and must be gone — + // leaving it up would only invite a retry that cannot succeed. + expect(screen.queryByPlaceholderText("welcome.auth.password")).toBeNull(); + }); + + it("reports each gate from its own flag, so an approved-but-unverified account is told the truth", async () => { + const passwordLogin = vi.fn(); + const { HttpError } = await mountCard("/", { passwordLogin }); + passwordLogin.mockImplementation( + refusal(HttpError, { + reason: "email_unverified", + email: "approved.first@example.com", + email_verified: false, + approved: true, + }), + ); + + await signIn("approved.first@example.com"); + + // E9.5 S1 made the two gates order-independent; a screen that assumed a sequence would send this + // user off to wait for an admin who has already decided. + expect(screen.getByText("welcome.auth.blocked.gate.emailPending")).toBeTruthy(); + expect(screen.getByText("welcome.auth.blocked.gate.approvalDone")).toBeTruthy(); + expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy(); + }); + + it("shows no checklist for a suspension, which is not a gate that opens on its own", async () => { + const passwordLogin = vi.fn(); + const { HttpError } = await mountCard("/", { passwordLogin }); + passwordLogin.mockImplementation( + refusal(HttpError, { + reason: "suspended", + email: "banned@example.com", + operator: "ops@example.com", + }), + ); + + await signIn("banned@example.com"); + + expect(screen.getByText("welcome.auth.blocked.suspended.title")).toBeTruthy(); + expect(screen.getByText("welcome.auth.blocked.operator:ops@example.com")).toBeTruthy(); + expect(screen.queryByText("welcome.auth.blocked.gate.emailDone")).toBeNull(); + }); + + it("leaves a wrong password as a line under the form, where the form IS the next step", async () => { + const passwordLogin = vi.fn(); + const { HttpError } = await mountCard("/", { passwordLogin }); + passwordLogin.mockImplementation(() => + Promise.reject(new HttpError(401, "localized invalid", { code: "login", reason: "invalid" })), + ); + + await signIn(); + + expect(screen.getByText("localized invalid")).toBeTruthy(); + expect(screen.getByPlaceholderText("welcome.auth.password")).toBeTruthy(); + }); +}); + +describe("the access-request notice from a denied Google sign-in", () => { + it("names the address the request was recorded for", async () => { + await mountCard("/#access=requested&email=nptemu%40gmail.com"); + + expect(screen.getByText("welcome.auth.accessRequestedFor")).toBeTruthy(); + expect(screen.getByText("nptemu@gmail.com")).toBeTruthy(); + expect(screen.queryByText("welcome.auth.accessLinked")).toBeNull(); + }); + + it("explains the alias when the request is already on file under another spelling", async () => { + await mountCard( + "/#access=requested&email=nptemu%40gmail.com&linked=np.temu%2Bsiftlode%40gmail.com", + ); + + expect(screen.getByText("welcome.auth.accessLinked")).toBeTruthy(); + // %2B, not a bare `+`: URLSearchParams reads the latter as a space and would mangle exactly the + // address family this epic exists to protect. + expect(screen.getByText("np.temu+siftlode@gmail.com")).toBeTruthy(); + }); + + it("falls back to the unnamed notice when the server sent no address", async () => { + await mountCard("/#access=requested"); + + expect(screen.getByText("welcome.auth.accessRequested")).toBeTruthy(); + expect(screen.queryByText("welcome.auth.accessRequestedFor")).toBeNull(); + }); +}); diff --git a/frontend/src/i18n/locales/en/welcome.json b/frontend/src/i18n/locales/en/welcome.json index fa33d127..7338cb94 100644 --- a/frontend/src/i18n/locales/en/welcome.json +++ b/frontend/src/i18n/locales/en/welcome.json @@ -93,6 +93,41 @@ "resendDone": "If that email has an unverified account, we've sent a new verification link. Check your inbox.", "oauthCancelled": "Google sign-in was cancelled. You can try again or use email and password.", "accessRequested": "Thanks — we recorded your request. An admin will review it.", + "accessRequestedFor": "We've recorded your access request for:", + "accessRequestedReview": "An admin will review it, and we'll email you either way.", + "accessLinked": "You already had a request on file for this mailbox, under a different spelling:", + "accessLinkedTail": "Google returns one fixed spelling of a Gmail address, so we linked this sign-in to that request instead of opening a second one. Any mail goes to the address above.", + "blocked": { + "addressLabel": "Address on file", + "operator": "If you think this is a mistake, contact the operator at {{contact}}.", + "gate": { + "emailDone": "Email address confirmed", + "emailPending": "Email address — not confirmed yet", + "approvalDone": "Admin approval — granted", + "approvalPending": "Admin approval — pending" + }, + "email_unverified": { + "title": "Confirm your email address", + "body": "Your password is correct — we just can't let you in until the address on this account is confirmed. Open the link we emailed you, or ask for a new one.", + "short": "Confirm your email address first — check your inbox." + }, + "pending_approval": { + "title": "Waiting for approval", + "body": "Your password is correct and your address is confirmed. An admin still has to let you in — we'll email you as soon as they decide.", + "short": "Your account is waiting for admin approval." + }, + "suspended": { + "title": "Account suspended", + "body": "This account has been suspended, so it can't sign in by any method.", + "short": "This account has been suspended." + }, + "invalid": { + "short": "Invalid email or password." + }, + "rate_limited": { + "short": "Too many attempts. Try again shortly." + } + }, "accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.", "suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).", "deleted": "Your account and all its data were permanently deleted. We've emailed you a confirmation." diff --git a/frontend/src/i18n/locales/hu/welcome.json b/frontend/src/i18n/locales/hu/welcome.json index 6b49f788..af1b78e5 100644 --- a/frontend/src/i18n/locales/hu/welcome.json +++ b/frontend/src/i18n/locales/hu/welcome.json @@ -93,6 +93,41 @@ "resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod.", "oauthCancelled": "A Google-belépés megszakadt. Próbáld újra, vagy lépj be e-maillel és jelszóval.", "accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.", + "accessRequestedFor": "Rögzítettük a hozzáférési kérelmedet erre a címre:", + "accessRequestedReview": "Egy admin átnézi, és e-mailben szólunk a döntésről.", + "accessLinked": "Ehhez a postafiókhoz már volt kérelmed, csak más írásmóddal:", + "accessLinkedTail": "A Google egy rögzített írásmódot ad vissza a Gmail-címekből, ezért ezt a belépést a meglévő kérelemhez kötöttük, nem indítottunk újat. A levelek a fenti címre mennek.", + "blocked": { + "addressLabel": "Rögzített cím", + "operator": "Ha tévedésnek gondolod, keresd az üzemeltetőt: {{contact}}.", + "gate": { + "emailDone": "E-mail-cím megerősítve", + "emailPending": "E-mail-cím — még nincs megerősítve", + "approvalDone": "Admin jóváhagyás — megvan", + "approvalPending": "Admin jóváhagyás — folyamatban" + }, + "email_unverified": { + "title": "Erősítsd meg az e-mail-címed", + "body": "A jelszavad jó — csak addig nem engedhetünk be, amíg a fiókon lévő cím nincs megerősítve. Nyisd meg a kiküldött linket, vagy kérj újat.", + "short": "Előbb erősítsd meg az e-mail-címed — nézd meg a postafiókod." + }, + "pending_approval": { + "title": "Jóváhagyásra vár", + "body": "A jelszavad jó, a címed megerősítve. Már csak arra várunk, hogy egy admin beengedjen — szólunk e-mailben, amint döntött.", + "short": "A fiókod admin jóváhagyásra vár." + }, + "suspended": { + "title": "Felfüggesztett fiók", + "body": "Ez a fiók fel van függesztve, így semmilyen módon nem tud belépni.", + "short": "Ez a fiók fel van függesztve." + }, + "invalid": { + "short": "Hibás e-mail-cím vagy jelszó." + }, + "rate_limited": { + "short": "Túl sok próbálkozás. Próbáld újra kicsit később." + } + }, "accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.", "suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).", "deleted": "A fiókod és minden adata véglegesen törölve lett. A megerősítést e-mailben is elküldtük." diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b3e91cfd..fd9349ff 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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"; diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index 545f4aaa..f3df502f 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -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([ + "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 | 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", diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index c3bf4e0a..952229c6 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -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) }); diff --git a/frontend/src/lib/entryUrl.ts b/frontend/src/lib/entryUrl.ts index f84a5993..04d1954f 100644 --- a/frontend/src/lib/entryUrl.ts +++ b/frontend/src/lib/entryUrl.ts @@ -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(/^#/, ""));