Files
deerflow-code/frontend-web/src/core/auth/api.ts
T
吉鑫升andClaude Opus 4.8 9572b66ecb feat(白名单): 账号级登录口令——管理员设明文口令,用户凭 /login/<用户名>?password= 直登
白名单管理页可给单个账号设明文登录口令(users.login_password,可空):
设置后该账号用户名直登(/login/username)须带匹配 ?password=,且优先于全局共享口令门;
未设口令的账号行为不变。前端登录 URL 已支持透传 ?password=,无需改登录页。

- 后端:UserRow/User 新增 login_password + 仓储映射;/login/username 账号级口令闸门
  (_lookup_user_by_username 仅查不建);新管理员接口 PUT /users/{id}/login-password;
  UserListItem 暴露明文(仅 admin 列表);migration 20260625_04 + 幂等补列 SQL。
- 前端:白名单页新增「登录口令」列(设置/修改/清除 + 复制登录链接);
  core/auth api+hooks 增 setUserLoginPassword/useSetUserLoginPassword(含 Y-Log)。
- 文档:根 + 后端 CLAUDE.md 同步。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:03:39 +08:00

157 lines
6.3 KiB
TypeScript

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<UserListItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<UserListItem[]>;
}
/** 放行 / 取消放行某个注册用户(登录白名单)。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>;
}
/**
* 设置 / 清除某个账号的「登录口令」(明文)。Admin only。
*
* 传非空字符串 → 设置;传 `null` 或空字符串 → 清除。设置后该账号用户名直登需带匹配的
* `?password=`。返回更新后的用户项(含明文 `login_password`)。
*/
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;
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<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,
): 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) {
const response = await apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (response.ok) {
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);
}