import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; import type { LoginByUsernameResponse } from "./index"; export interface UserListItem { id: string; email: string; system_role: "admin" | "user"; skill_count: number; agent_count: number; /** 问答次数:用户提问的次数(一次提问算一次)。 */ qa_count: number; /** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */ approved: boolean; /** * 账号级「登录口令」(明文,仅管理员可见)。非空 → 该账号用户名直登必须带匹配的 * `?password=`;为空/null → 走原有共享口令门/免口令逻辑。管理员据此拼出登录链接 * `…/login/<用户名>?password=xxxx` 发给用户。 */ login_password: string | null; } export async function listAllUsers(): Promise { const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise; } /** 放行 / 取消放行某个注册用户(登录白名单)。Admin only。 */ export async function setUserApproval(userId: string, approved: boolean): Promise { const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/approval`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approved }), }); if (!res.ok) { const data = await res.json().catch(() => null); throw new Error(extractErrorMessage(data, "保存失败")); } return res.json() as Promise; } /** * 设置 / 清除某个账号的「登录口令」(明文)。Admin only。 * * 传非空字符串 → 设置;传 `null` 或空字符串 → 清除。设置后该账号用户名直登需带匹配的 * `?password=`。返回更新后的用户项(含明文 `login_password`)。 */ export async function setUserLoginPassword(userId: string, password: string | null): Promise { const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }), }); if (!res.ok) { const data = await res.json().catch(() => null); throw new Error(extractErrorMessage(data, "保存失败")); } return res.json() as Promise; } // Normalize a FastAPI error body into a human-readable string. `detail` may be // a plain string, an object like AuthErrorResponse ({ code, message }), or an // array of validation errors — anything but a string would otherwise render as // the dreaded "[object Object]". function extractErrorMessage(body: unknown, fallback = "Login failed."): string { const detail = (body as { detail?: unknown } | null)?.detail ?? body; if (typeof detail === "string") return detail; if (Array.isArray(detail)) { const msg = detail .map((d) => (d && typeof d === "object" ? (d as { msg?: string }).msg : String(d))) .filter(Boolean) .join("; "); return msg || fallback; } if (detail && typeof detail === "object") { const obj = detail as { message?: string; msg?: string }; return obj.message ?? obj.msg ?? fallback; } return fallback; } export async function loginByUsername( username: string, password?: string | null, ): Promise { // When the username-login password gate is enabled server-side, the shared // secret must be passed as ?password=. Omitted when no password is provided // so the passwordless flow keeps working unchanged. const base = `${getBackendBaseURL()}/api/v1/auth/login/username`; const url = password != null && password !== "" ? `${base}?password=${encodeURIComponent(password)}` : base; const response = await apiFetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ username }), }); if (!response.ok) { const error = await response.json().catch(() => null); throw new Error(extractErrorMessage(error)); } return response.json() as Promise; } // The upstream token issuer and the verifier (token_info_url) run on separate // servers whose clocks can drift ~3-4s apart. A token verified right after it // is issued can look "not yet valid / expired" to the verifier, which surfaces // as a 401 here. That window closes on its own once real time advances past the // skew, so we silently retry the exchange a couple of times before giving up — // the LoginPage keeps showing its loading state, so the user never notices. const TOKEN_LOGIN_MAX_ATTEMPTS = 3; // initial try + 2 retries → covers up to ~6s skew const TOKEN_LOGIN_RETRY_DELAY_MS = 3000; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export async function loginByToken( token: string, ): Promise { // Token-exchange login: the gateway resolves the upstream token to a username // and signs the matching local account in. const url = `${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`; let lastError = "Login failed."; for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) { const response = await apiFetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, }); if (response.ok) { return response.json() as Promise; } const error = await response.json().catch(() => null); lastError = extractErrorMessage(error); // Only 401 is plausibly the transient clock-skew case and worth waiting on. // 403 (disabled), 429 (rate-limited), 500 (misconfigured) won't self-heal, // so fail fast instead of stalling the user. const isTransient = response.status === 401; if (!isTransient || attempt === TOKEN_LOGIN_MAX_ATTEMPTS) { throw new Error(lastError); } await wait(TOKEN_LOGIN_RETRY_DELAY_MS); } // Unreachable: the loop either returns or throws above. throw new Error(lastError); }