// 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([ "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<{ 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 }) }), };