Files
siftlode/frontend/src/lib/api/auth.ts
T
peter 10c0ca4cf7 feat(email): write to people in their own language (E9.5 S7)
Twelve mails carried hardcoded English while the app itself has enforced HU/EN parity since E5.
A mail is the one user-facing surface nobody can re-render by switching the UI language: it arrives
once, in whatever language we picked, and stays that way. That made the epic's own headline mail —
"one mail, one click" — English for a Hungarian reader.

The text moved to a catalog (`mailtext.py`) with a `text(key, lang, **vars)` renderer, and the parity
rule is now a test rather than a habit: every key must carry both languages AND the same
`{placeholders}`. A translation that drops `{link}` sends a mail with no link in it; one that invents
a placeholder raises inside a background task nobody is watching. Both are silent in production.

Every `send_*` takes the language as its second argument, resolved by the REQUEST handler and never
inside the mail — these all run as background tasks, after the request's session is gone, which is
the same reason the approval mail computes its link in the handler.

Where the language comes from, in order: the account's own preference; failing that, the language the
person asked us in; failing that, English. The middle one is new — `invites.lang`, migration `0082` —
because the mails an access request produces go to an address that usually has no account yet, and
the signal does exist: the page their form was submitted from, or the locale Google reported for a
sign-in we refused. Captured rather than derived, for the third time in this epic (`google_email` in
S3, `preferences.language` in S6 taught the same lesson): a signal that exists only in one moment
cannot be backfilled later. So the admin is asked in exactly one place — the invitation to an address
they typed in themselves, where nobody has ever told us anything.

That send is now a dialog rather than a bare click, which is also where the "you already wrote to
them" warning belongs — it used to live only in a tooltip the admin may never hover, guarding the one
action whose failure mode is mailing a real person twice.

S6's registration `lang` field turned out to be dead on arrival: the backend accepted it, the
frontend never sent it, so every account has been storing nothing. The request builders now stamp the
UI language themselves — a signal that has to be passed in by every caller is one every caller can
forget, which is exactly how it shipped unset.

Admins are notified one mail per language group instead of one blast to everyone. They are people
with their own UI language; a single message would have to pick one of them and be wrong for the rest.

Also removes a duplicated `invited_at` column declaration on `Invite` (two identical definitions
landed in S6).
2026-09-04 20:29:33 +02:00

143 lines
7.1 KiB
TypeScript

// The sign-in funnel: public auth config, email+password register/login/reset, email verification,
// session management, access requests, and the hidden demo login. Errors on the interactive paths
// are surfaced inline (quiet) rather than via the global modal. Only touches core's req.
import { req, uiLang } 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;
};
/** Did a password-reset call fail because the LINK itself is finished — spent, expired, or belonging
* to an account that no longer exists — rather than because the new password was too short? The two
* arrive as the same 400, and only the first one means the form should come down. */
export function deadResetLink(detail: unknown): boolean {
const d = detail as { code?: string; reason?: string } | null | undefined;
return d?.code === "reset" && d.reason === "invalid";
}
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<{
google_enabled: boolean;
allow_registration: boolean;
operator_contact: string | null;
}> => req("/auth/config"),
// --- email + password (errors handled inline via quiet) ---
// `lang` is stamped here rather than passed in: this is the ONE moment the sign-up language
// exists anywhere the server can see it (the account does not exist yet to carry it), and every
// mail this person ever gets is written from what lands here.
register: (email: string, password: string): Promise<{ status: string }> =>
req(
"/auth/register",
{ method: "POST", body: JSON.stringify({ email, password, lang: uiLang() }) },
{ 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",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ quiet: true },
),
requestPasswordReset: (email: string): Promise<{ status: string }> =>
req(
"/auth/password-reset/request",
{ method: "POST", body: JSON.stringify({ email }) },
{ quiet: true },
),
// Is a `#reset=` token still usable? Answers WITHOUT spending it, so the card can refuse to draw a
// form that cannot succeed (a deleted account, an expired or already-used link). Advisory only —
// confirmPasswordReset stays the authority, since the token can die between the two calls.
checkPasswordReset: (token: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-reset/check",
{ method: "POST", body: JSON.stringify({ token }) },
{ quiet: true },
),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-reset/confirm",
{ method: "POST", body: JSON.stringify({ token, password }) },
{ quiet: true },
),
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
// `password_reset_required` = SEC-10 dropped the password this account registered with, so signing
// in would fail with the uniform credentials error. The card sends the user to the reset flow instead
// of leaving them to guess. Rare since E9.5 S1: only accounts stripped before that change, and the
// Google-adoption path, still reach it.
// `email` is the address that was just confirmed — echoed back so the banner can name it, which
// matters when the browser holds a session for a DIFFERENT account.
verifyEmail: (
token: string,
): Promise<{ ok: boolean; password_reset_required?: boolean; email?: string }> =>
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
// reveals whether the email has a pending account.
resendVerification: (email: string): Promise<{ status: string }> =>
req(
"/auth/verify/resend",
{ method: "POST", body: JSON.stringify({ email }) },
{ quiet: true },
),
// SA4: sign every OTHER session for this account out (keeps the current one).
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
// Set or change the signed-in account's password (errors shown inline via quiet).
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
req(
"/auth/set-password",
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
{ quiet: true },
),
// --- onboarding: access requests + demo entry ---
// Same reasoning as register: the requester has no account to carry a language preference, so the
// page they asked from is what decides the language of the approval or denial mail.
requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", {
method: "POST",
body: JSON.stringify({ email, lang: uiLang() }),
}),
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
// sign-in. Returns whether a session was established.
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
};