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:
@@ -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.
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
window.history.replaceState(null, "", url);
|
||||
vi.resetModules();
|
||||
vi.doMock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && typeof opts.contact === "string" ? `${key}:${opts.contact}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
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(<Welcome verify={noVerify} />);
|
||||
});
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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