import { apiFetch } from "@/core/api/fetch-client"; import { fetchWithBindingRetry } from "@/core/auth/login-transport"; 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; /** 近 30 天问答次数:用户提问的次数(一次提问算一次)。 */ qa_count: number; /** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */ approved: boolean; /** * 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列, * 是否已设置凭据以 has_login_password 为准。 */ login_password: string | null; /** 已设置密码/口令,散列凭据不会返回给客户端。 */ has_login_password: boolean; /** 管理员重置自己的密码时,服务器同步刷新当前会话。 */ access_token?: string; token_type?: string; expires_in?: number; } /** * 拉全部注册用户。`includeCounts=false` 时后端只查 users 表、跳过问答次数 * 统计与 skill/agent 计数(返回 0)——白名单管理页用,避免 runs 表慢查询。 */ export async function listAllUsers(includeCounts = true): Promise { const url = new URL(`${getBackendBaseURL()}/api/v1/auth/users`); if (!includeCounts) url.searchParams.set("include_counts", "false"); const res = await apiFetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise; } /** 分页用户列表(后端传 limit 时返回的 UserListPage 形状)。 */ export interface UserListPage { items: UserListItem[]; total: number; approved_count: number; limit: number; offset: number; } /** 服务端分页拉用户(白名单管理页):只查一页 users 表 + 两个 COUNT。 */ export async function listUsersPaged(opts: { q?: string; limit: number; offset: number; }, signal?: AbortSignal): Promise { const url = new URL(`${getBackendBaseURL()}/api/v1/auth/users`); url.searchParams.set("limit", String(opts.limit)); url.searchParams.set("offset", String(opts.offset)); url.searchParams.set("include_counts", "false"); if (opts.q) url.searchParams.set("q", opts.q); const controller = new AbortController(); const cancel = () => controller.abort(); if (signal?.aborted) cancel(); else signal?.addEventListener("abort", cancel, { once: true }); const timer = setTimeout(cancel, 20_000); try { const res = await apiFetch(url.toString(), { signal: controller.signal }); if (!res.ok) { const data = await res.json().catch(() => null); throw new Error(extractErrorMessage(data, `用户列表加载失败(HTTP ${res.status})`)); } return await res.json() as UserListPage; } catch (error) { if (controller.signal.aborted && !signal?.aborted) { throw new Error("用户列表加载超时,请重试"); } throw error; } finally { clearTimeout(timer); signal?.removeEventListener("abort", cancel); } } /** 放行 / 取消放行某个注册用户(登录白名单)。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; } /** * 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。 */ 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; // application/x-www-form-urlencoded is a CORS simple content type. A JSON // body makes old Firefox preflight the login and then abort it. const response = await fetchWithBindingRetry(() => apiFetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ 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, isCurrent: () => boolean = () => true, ): 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) { if (!isCurrent()) throw new Error("登录请求已被新的登录替代"); let response: Response; try { // No JSON content type and no custom header: old Firefox can send this // POST without a preflight. A dropped connection is retried once. response = await fetchWithBindingRetry(() => apiFetch(url, { method: "POST" })); } catch (error) { if (!isCurrent()) throw new Error("登录请求已被新的登录替代"); throw error; } if (response.ok) { if (!isCurrent()) throw new Error("登录请求已被新的登录替代"); 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); }