217 lines
8.9 KiB
TypeScript
217 lines
8.9 KiB
TypeScript
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);
|
||
}
|
||
|