Files

217 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<UserListItem[]> {
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<UserListItem[]>;
}
/** 分页用户列表(后端传 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<UserListPage> {
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<UserListItem> {
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<UserListItem>;
}
/**
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
*/
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
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<UserListItem>;
}
// 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<LoginByUsernameResponse> {
// 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<LoginByUsernameResponse>;
}
// 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<void>((resolve) => setTimeout(resolve, ms));
export async function loginByToken(
token: string,
isCurrent: () => boolean = () => true,
): Promise<LoginByUsernameResponse> {
// 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<LoginByUsernameResponse>;
}
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);
}