sync: 鍚屾灞€鍩熺綉鏈€鏂颁唬鐮?(source master @ b69568e4)
This commit is contained in:
1 parent
4f7f07504a
commit
8bc8b0e082
1651 files changed
+278599
-11416
No files matched your search
@@ -0,0 +1,79 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
|
||||
export interface AuthenticationOptions {
|
||||
password_login_enabled: boolean;
|
||||
registration_enabled: boolean;
|
||||
sso_enabled: boolean;
|
||||
single_session_enabled?: boolean;
|
||||
needs_setup?: boolean;
|
||||
}
|
||||
|
||||
function accountError(body: unknown, fallback: string): string {
|
||||
const detail = (body as { detail?: unknown } | null)?.detail ?? body;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((item) => item?.msg).filter(Boolean).join(";") || fallback;
|
||||
}
|
||||
if (detail && typeof detail === "object") {
|
||||
const value = detail as { message?: string; msg?: string };
|
||||
return value.message || value.msg || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Bound unauthenticated requests so an unreachable gateway offers a retry. */
|
||||
async function accountRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 20_000);
|
||||
try {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/v1/auth${path}`, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(accountError(body, "请求失败,请稍后重试"));
|
||||
return body as T;
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new Error("连接超时,请检查网络后重试");
|
||||
if (error instanceof TypeError) throw new Error("无法连接服务器,请检查网络后重试");
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAuthenticationOptions(): Promise<AuthenticationOptions> {
|
||||
const options = await accountRequest<AuthenticationOptions>("/options", { cache: "no-store" });
|
||||
if (!options || [options.password_login_enabled, options.registration_enabled, options.sso_enabled]
|
||||
.some((value) => typeof value !== "boolean")) {
|
||||
throw new Error("无法读取登录设置,请联系管理员或重试");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function loginWithPassword(username: string, password: string): Promise<LoginByUsernameResponse> {
|
||||
return accountRequest("/login/local", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAccount(username: string, password: string): Promise<LoginByUsernameResponse> {
|
||||
return accountRequest("/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function initializeAdministrator(email: string, password: string): Promise<LoginByUsernameResponse> {
|
||||
return accountRequest<LoginByUsernameResponse>("/initialize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface AuthenticationSettings {
|
||||
password_login_enabled: boolean;
|
||||
registration_enabled: boolean;
|
||||
sso_enabled: boolean;
|
||||
single_session_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordSession {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
export interface AccountPasswordStatus {
|
||||
id: string;
|
||||
has_password: boolean;
|
||||
requires_current_password: boolean;
|
||||
uses_default_password: boolean;
|
||||
}
|
||||
|
||||
async function readResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const detail = data?.detail;
|
||||
const message = typeof detail === "string"
|
||||
? detail
|
||||
: Array.isArray(detail)
|
||||
? detail.map((item: { msg?: string }) => item.msg).filter(Boolean).join(";")
|
||||
: detail?.message;
|
||||
throw new Error(message || `请求失败(${response.status})`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function getAuthenticationSettings(): Promise<AuthenticationSettings> {
|
||||
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/system-settings/authentication`));
|
||||
}
|
||||
|
||||
export async function saveAuthenticationSettings(settings: AuthenticationSettings): Promise<AuthenticationSettings> {
|
||||
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/system-settings/authentication`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAccountPasswordStatus(): Promise<AccountPasswordStatus> {
|
||||
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/v1/auth/me`));
|
||||
}
|
||||
|
||||
export async function changeAccountPassword(currentPassword: string, newPassword: string): Promise<PasswordSession> {
|
||||
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/v1/auth/change-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
}));
|
||||
}
|
||||
@@ -14,11 +14,16 @@ export interface UserListItem {
|
||||
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
|
||||
approved: boolean;
|
||||
/**
|
||||
* 账号级「登录口令」(明文,仅管理员可见)。非空 → 该账号用户名直登必须带匹配的
|
||||
* `?password=`;为空/null → 走原有共享口令门/免口令逻辑。管理员据此拼出登录链接
|
||||
* `…/login/<用户名>?password=xxxx` 发给用户。
|
||||
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
|
||||
* 是否已设置凭据以 has_login_password 为准。
|
||||
*/
|
||||
login_password: string | null;
|
||||
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
|
||||
has_login_password: boolean;
|
||||
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
@@ -42,10 +47,7 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 / 清除某个账号的「登录口令」(明文)。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`, {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useSetUserApproval() {
|
||||
});
|
||||
}
|
||||
|
||||
/** 设置 / 清除某账号的「登录口令」(明文),成功后刷新用户列表缓存。 */
|
||||
/** 设置 / 重置账号的统一密码与登录口令;操作日志不记录密码。 */
|
||||
export function useSetUserLoginPassword() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { accountPageSearch, mergeLoginSearch, shouldStartSso, shouldUseStoredTicket } from "./login-entry";
|
||||
|
||||
const options = { password_login_enabled: true, registration_enabled: true, sso_enabled: true };
|
||||
|
||||
test("password entry never starts a guest login; explicit SSO still works", () => {
|
||||
assert.equal(shouldStartSso(options, false, false), false);
|
||||
assert.equal(shouldStartSso(options, true, false), true);
|
||||
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, false, false), true);
|
||||
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, true, false), true);
|
||||
});
|
||||
|
||||
test("expired sessions wait for an explicit re-login instead of repeatedly kicking the other client", () => {
|
||||
const legacy = { ...options, password_login_enabled: false };
|
||||
assert.equal(shouldStartSso(legacy, false, false, true), false);
|
||||
assert.equal(shouldStartSso(options, true, false, true), false);
|
||||
assert.equal(shouldStartSso(legacy, false, false, false), true);
|
||||
});
|
||||
|
||||
test("external login credentials before the hash are read, and route values take precedence", () => {
|
||||
const params = mergeLoginSearch("?authToken=current&password=&taskId=42", "?authToken=stale&password=old&ticket=t&theme=dark-blue");
|
||||
assert.equal(params.get("authToken"), "current");
|
||||
assert.equal(params.get("password"), "");
|
||||
assert.equal(params.get("ticket"), "t");
|
||||
assert.equal(params.get("theme"), "dark-blue");
|
||||
assert.equal(params.get("taskId"), "42");
|
||||
assert.equal(mergeLoginSearch("", "?authToken=outer").get("authToken"), "outer");
|
||||
});
|
||||
|
||||
test("stored tickets cannot override explicit login links or password entry", () => {
|
||||
assert.equal(shouldUseStoredTicket(options, false), false);
|
||||
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, true), false);
|
||||
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, false), true);
|
||||
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false, sso_enabled: false }, false), false);
|
||||
assert.equal(shouldUseStoredTicket(null, false), false);
|
||||
});
|
||||
|
||||
test("missing configuration, disabled SSO and registration never start SSO", () => {
|
||||
assert.equal(shouldStartSso(null, true, false), false);
|
||||
assert.equal(shouldStartSso({ ...options, sso_enabled: false }, true, false), false);
|
||||
assert.equal(shouldStartSso(options, true, true), false);
|
||||
});
|
||||
|
||||
test("switching forms retains the task destination and presentation without credentials", () => {
|
||||
const search = accountPageSearch("?taskId=42&goPath=agentfx&embed=1&theme=dark-blue&password=secret&authToken=token&ticket=ticket&userId=old&yUserId=old&token=token&access_token=token&routeKey=task");
|
||||
const params = new URLSearchParams(search);
|
||||
assert.deepEqual([...params.keys()], ["taskId", "goPath", "embed", "theme"]);
|
||||
assert.equal(params.get("taskId"), "42");
|
||||
assert.equal(params.get("theme"), "dark-blue");
|
||||
});
|
||||
|
||||
test("switching forms preserves a resolved route-key destination and empty search", () => {
|
||||
assert.equal(accountPageSearch(""), "");
|
||||
const params = new URLSearchParams(accountPageSearch("routeKey=task&embed=1", "/page/workspace/chats/new"));
|
||||
assert.equal(params.get("redirect"), "/page/workspace/chats/new");
|
||||
assert.equal(params.has("routeKey"), false);
|
||||
assert.equal(params.get("embed"), "1");
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { AuthenticationOptions } from "./account-api";
|
||||
|
||||
/** A missing options response must never fall back to a legacy guest login. */
|
||||
export function shouldStartSso(
|
||||
options: AuthenticationOptions | null,
|
||||
explicitSso: boolean,
|
||||
registering: boolean,
|
||||
sessionExpired = false,
|
||||
): boolean {
|
||||
return Boolean(options?.sso_enabled && !registering && !sessionExpired &&
|
||||
(explicitSso || !options.password_login_enabled));
|
||||
}
|
||||
|
||||
/** External callers put login parameters before or after the hash route. */
|
||||
export function mergeLoginSearch(routeSearch: string, outerSearch: string): URLSearchParams {
|
||||
const params = new URLSearchParams(outerSearch);
|
||||
// A route parameter wins even when explicitly empty: do not revive an old
|
||||
// outer credential when the current route has replaced it.
|
||||
new URLSearchParams(routeSearch).forEach((value, key) => params.set(key, value));
|
||||
return params;
|
||||
}
|
||||
|
||||
export const LOGIN_CREDENTIAL_PARAMS = [
|
||||
"ticket", "userId", "yUserId", "token", "access_token", "password", "authToken", "routeKey",
|
||||
] as const;
|
||||
|
||||
/** Cached tickets belong only to the legacy, bare entry with login disabled. */
|
||||
export function shouldUseStoredTicket(
|
||||
options: AuthenticationOptions | null,
|
||||
explicitSso: boolean,
|
||||
): boolean {
|
||||
return Boolean(options?.sso_enabled && !options.password_login_enabled && !explicitSso);
|
||||
}
|
||||
|
||||
/** Keep the destination/presentation when switching forms; discard credentials. */
|
||||
export function accountPageSearch(search: string, routeKeyTarget?: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if (routeKeyTarget) params.set("redirect", routeKeyTarget);
|
||||
for (const key of LOGIN_CREDENTIAL_PARAMS) params.delete(key);
|
||||
const result = params.toString();
|
||||
return result ? `?${result}` : "";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||
import {
|
||||
AUTH_STATE_CHANGED_EVENT,
|
||||
clearStoredAuth,
|
||||
getStoredAuth,
|
||||
setStoredAuth,
|
||||
} from "./index";
|
||||
import { checkAccountAccess, isSameAccountSession, type AccountAccess } from "./session-validation";
|
||||
|
||||
type GateState = (Extract<AccountAccess, { status: "allowed" }> & { warning?: string })
|
||||
| Extract<AccountAccess, { status: "unauthorized" }>
|
||||
| { status: "checking" } | { status: "error"; message: string };
|
||||
|
||||
/** Keep protected pages unmounted until the configured authentication check succeeds. */
|
||||
export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
children: ReactNode;
|
||||
allowLegacyAnonymous?: boolean;
|
||||
}) {
|
||||
const { pathname, search } = useLocation();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [state, setState] = useState<GateState>({ status: "checking" });
|
||||
const writingVerifiedAccount = useRef(false);
|
||||
const requestInFlight = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
if (!writingVerifiedAccount.current) setAttempt((value) => value + 1);
|
||||
};
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (!isEmbedActive() && !isIsolatedSession()
|
||||
&& (event.key === "deerflow.auth" || event.key === null)) refresh();
|
||||
};
|
||||
const recheck = () => { if (!requestInFlight.current) refresh(); };
|
||||
const onVisibility = () => { if (document.visibilityState === "visible") recheck(); };
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener("focus", recheck);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
// A different browser can revoke this account's session. Also refresh the
|
||||
// policy so enabling login or single-session mode takes effect in open tabs.
|
||||
const timer = setInterval(onVisibility, 15_000);
|
||||
return () => {
|
||||
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("focus", recheck);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const requestedAuth = getStoredAuth();
|
||||
let disposed = false;
|
||||
requestInFlight.current = true;
|
||||
const timeout = setTimeout(() => controller.abort(), 20_000);
|
||||
// A background recheck must not unmount an active editor on every poll.
|
||||
// A changed account/token still blocks immediately until its own check ends.
|
||||
setState((previous) => previous.status === "allowed"
|
||||
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
|
||||
? previous : { status: "checking" });
|
||||
|
||||
void checkAccountAccess({
|
||||
auth: requestedAuth,
|
||||
baseURL: getBackendBaseURL(),
|
||||
signal: controller.signal,
|
||||
allowLegacyAnonymous,
|
||||
}).then((result) => {
|
||||
if (disposed) return;
|
||||
if (!isSameAccountSession(getStoredAuth(), requestedAuth)) {
|
||||
setAttempt((value) => value + 1);
|
||||
return;
|
||||
}
|
||||
writingVerifiedAccount.current = true;
|
||||
try {
|
||||
if (result.status === "unauthorized") {
|
||||
if (requestedAuth) clearStoredAuth();
|
||||
} else if (result.auth && JSON.stringify(result.auth) !== JSON.stringify(getStoredAuth())) {
|
||||
setStoredAuth(result.auth);
|
||||
}
|
||||
} finally {
|
||||
writingVerifiedAccount.current = false;
|
||||
}
|
||||
setState(result);
|
||||
}).catch((error: unknown) => {
|
||||
if (disposed) return;
|
||||
const message = controller.signal.aborted ? "验证登录超时,请检查网络后重试"
|
||||
: error instanceof TypeError ? "无法连接服务器,请检查网络后重试"
|
||||
: error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试";
|
||||
setState((previous) => previous.status === "allowed"
|
||||
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
|
||||
&& isSameAccountSession(getStoredAuth(), requestedAuth)
|
||||
? { ...previous, warning: message } : { status: "error", message });
|
||||
}).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
if (!disposed) requestInFlight.current = false;
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
requestInFlight.current = false;
|
||||
controller.abort();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [attempt, allowLegacyAnonymous]);
|
||||
|
||||
if (state.status === "unauthorized") {
|
||||
const params = new URLSearchParams({ redirect: `${pathname}${search}` });
|
||||
if (state.reason) params.set("reason", state.reason);
|
||||
return <Navigate to={`/login?${params.toString()}`} replace />;
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background p-6 text-center">
|
||||
<p role="alert" className="text-sm text-muted-foreground">{state.message}</p>
|
||||
<button type="button" className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground"
|
||||
onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
|
||||
</div>;
|
||||
}
|
||||
if (state.status === "allowed" && JSON.stringify(state.auth) === JSON.stringify(getStoredAuth())) {
|
||||
return <div className="contents">
|
||||
{children}
|
||||
{state.warning && <div role="alert" className="fixed right-4 bottom-4 z-50 flex max-w-md items-center gap-3 rounded-md border bg-background p-4 text-sm shadow-lg">
|
||||
<span>{state.warning}</span>
|
||||
<button type="button" className="shrink-0 text-primary underline" onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
return <div role="status" className="flex min-h-screen items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
正在验证登录状态…
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { checkAccountAccess, isSameAccountSession } from "./session-validation";
|
||||
|
||||
const auth = {
|
||||
access_token: "browser-token", token_type: "bearer", expires_in: 3600,
|
||||
user_id: "user-1", email: "claimed@example.com", system_role: "admin", needs_setup: true, created: false,
|
||||
};
|
||||
const user = { id: "user-1", email: "actual@example.com", system_role: "user", needs_setup: false };
|
||||
|
||||
function server(enabled: boolean, me: Response = Response.json(user), singleSession = false) {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const fetcher: typeof fetch = async (input, init) => {
|
||||
calls.push({ url: String(input), init });
|
||||
return String(input).endsWith("/options")
|
||||
? Response.json({ password_login_enabled: enabled, single_session_enabled: singleSession }) : me;
|
||||
};
|
||||
return { calls, fetcher };
|
||||
}
|
||||
|
||||
test("enabled login rejects anonymous accounts including standalone mode before /me", async () => {
|
||||
const { fetcher, calls } = server(true);
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "unauthorized" });
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("a cached token never grants access before /me validates it", async () => {
|
||||
const { fetcher, calls } = server(true, new Response(null, { status: 401 }));
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "https://gateway", fetcher }), { status: "unauthorized", reason: "session-expired" });
|
||||
assert.equal(calls[1]?.url, "https://gateway/api/v1/auth/me");
|
||||
assert.equal(new Headers(calls[1]?.init?.headers).get("Authorization"), "Bearer browser-token");
|
||||
assert.equal(calls[1]?.init?.cache, "no-store");
|
||||
});
|
||||
|
||||
test("verified server permissions replace claimed local permissions", async () => {
|
||||
const { fetcher } = server(true);
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
|
||||
status: "allowed", auth: { ...auth, email: user.email, system_role: user.system_role, needs_setup: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("a token for another account cannot authenticate the cached account", async () => {
|
||||
const { fetcher } = server(true, Response.json({ ...user, id: "another-user" }));
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "unauthorized", reason: "session-expired" });
|
||||
});
|
||||
|
||||
test("disabled login preserves legacy sessions and the standalone anonymous entry", async () => {
|
||||
const { fetcher, calls } = server(false);
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "allowed", auth });
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", fetcher }), { status: "unauthorized" });
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "allowed", auth: null });
|
||||
assert.ok(calls.every(({ url }) => url.endsWith("/options")));
|
||||
});
|
||||
|
||||
test("outages and invalid policy responses fail closed without treating the session as invalid", async () => {
|
||||
const { fetcher } = server(true, new Response(null, { status: 503 }));
|
||||
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher }), /无法验证登录/);
|
||||
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => Response.json({}) }), /无法读取登录设置/);
|
||||
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => { throw new TypeError("offline"); } }), /offline/);
|
||||
});
|
||||
|
||||
test("single-session mode detects revoked sessions even with the password page disabled", async () => {
|
||||
const { fetcher, calls } = server(false, new Response(null, { status: 401 }), true);
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
|
||||
status: "unauthorized", reason: "session-expired",
|
||||
});
|
||||
assert.ok(calls.some(({ url }) => url.endsWith("/me")));
|
||||
});
|
||||
|
||||
test("late failures cannot clear a renewed token or a different account", () => {
|
||||
assert.equal(isSameAccountSession({ ...auth }, auth), true);
|
||||
assert.equal(isSameAccountSession({ ...auth, access_token: "renewed" }, auth), false);
|
||||
assert.equal(isSameAccountSession({ ...auth, user_id: "another-user" }, auth), false);
|
||||
assert.equal(isSameAccountSession(null, auth), false);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
|
||||
type StoredAccount = LoginByUsernameResponse | null;
|
||||
|
||||
export type AccountAccess =
|
||||
| { status: "allowed"; auth: StoredAccount }
|
||||
| { status: "unauthorized"; reason?: "session-expired" };
|
||||
|
||||
/** Compare credentials before applying a response from an earlier request. */
|
||||
export function isSameAccountSession(current: StoredAccount, requested: StoredAccount): boolean {
|
||||
return current?.access_token === requested?.access_token && current?.user_id === requested?.user_id;
|
||||
}
|
||||
|
||||
export function hasAccountSession(auth: StoredAccount): auth is LoginByUsernameResponse {
|
||||
return Boolean(typeof auth?.user_id === "string" && auth.user_id.trim()
|
||||
&& typeof auth.access_token === "string" && auth.access_token.trim());
|
||||
}
|
||||
|
||||
/** The server owns both the login policy and the account's current permissions. */
|
||||
export async function checkAccountAccess({
|
||||
auth,
|
||||
baseURL,
|
||||
signal,
|
||||
allowLegacyAnonymous = false,
|
||||
fetcher = fetch,
|
||||
}: {
|
||||
auth: StoredAccount;
|
||||
baseURL: string;
|
||||
signal?: AbortSignal;
|
||||
allowLegacyAnonymous?: boolean;
|
||||
fetcher?: typeof fetch;
|
||||
}): Promise<AccountAccess> {
|
||||
const optionsResponse = await fetcher(`${baseURL}/api/v1/auth/options`, {
|
||||
credentials: "include", cache: "no-store", signal,
|
||||
});
|
||||
if (!optionsResponse.ok) throw new Error("无法读取登录设置,请稍后重试");
|
||||
const options: unknown = await optionsResponse.json();
|
||||
if (!options || typeof options !== "object"
|
||||
|| !("password_login_enabled" in options)
|
||||
|| typeof options.password_login_enabled !== "boolean") {
|
||||
throw new Error("无法读取登录设置,请稍后重试");
|
||||
}
|
||||
|
||||
const singleSessionEnabled = "single_session_enabled" in options && options.single_session_enabled === true;
|
||||
if (!options.password_login_enabled && !singleSessionEnabled) {
|
||||
return allowLegacyAnonymous || hasAccountSession(auth)
|
||||
? { status: "allowed", auth }
|
||||
: { status: "unauthorized" };
|
||||
}
|
||||
if (!hasAccountSession(auth)) return { status: "unauthorized" };
|
||||
|
||||
const response = await fetcher(`${baseURL}/api/v1/auth/me`, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
headers: { Authorization: `Bearer ${auth.access_token}` },
|
||||
signal,
|
||||
});
|
||||
if (response.status === 401) return { status: "unauthorized", reason: "session-expired" };
|
||||
if (!response.ok) throw new Error("暂时无法验证登录状态,请稍后重试");
|
||||
|
||||
const user = await response.json() as Record<string, unknown> | null;
|
||||
if (!user || typeof user.id !== "string" || typeof user.email !== "string"
|
||||
|| typeof user.system_role !== "string" || typeof user.needs_setup !== "boolean") {
|
||||
throw new Error("服务器返回的登录信息不完整,请稍后重试");
|
||||
}
|
||||
if (user.id !== auth.user_id) return { status: "unauthorized", reason: "session-expired" };
|
||||
return {
|
||||
status: "allowed",
|
||||
auth: { ...auth, email: user.email, system_role: user.system_role, needs_setup: user.needs_setup },
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -125,5 +125,9 @@ export function AuthSyncWatcher({
|
||||
};
|
||||
}, [disabled, location.pathname, navigate, queryClient]);
|
||||
|
||||
return children ? <Fragment key={revision}>{children}</Fragment> : null;
|
||||
// A keyed Fragment is transformed by the development tagger, which injects
|
||||
// data-lov-id. React.Fragment only accepts key and children, so use a
|
||||
// display:contents element to preserve the remount behavior without adding
|
||||
// a layout box.
|
||||
return children ? <div className="contents" key={revision}>{children}</div> : null;
|
||||
}
|
||||
@@ -58,6 +58,7 @@ const BUSINESS_TASK_BASE: Record<string, string> = {
|
||||
|
||||
export interface ParsedGoPath {
|
||||
businessCode: string;
|
||||
goPath: "3qfx" | "rwfx" | "xdfx";
|
||||
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
|
||||
roundtable: boolean;
|
||||
}
|
||||
@@ -70,7 +71,7 @@ export function parseGoPath(goPath: string): ParsedGoPath | null {
|
||||
const base = roundtable ? raw.slice(0, -2) : raw;
|
||||
const businessCode = GOPATH_BUSINESS_CODE[base];
|
||||
if (!businessCode) return null;
|
||||
return { businessCode, roundtable };
|
||||
return { businessCode, goPath: base as ParsedGoPath["goPath"], roundtable };
|
||||
}
|
||||
|
||||
function consumerBaseUrl(): string {
|
||||
@@ -381,6 +382,7 @@ export async function resolveTaskDeeplinkTarget(
|
||||
openingText: buildOpeningText(detail, taskId, actionInfo),
|
||||
actionId,
|
||||
partitionByAction: isXdfx,
|
||||
goPath: parsed.goPath,
|
||||
});
|
||||
return { path: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { taskThreadMetadata } from "./task-purpose";
|
||||
|
||||
test("3qfx and rwfx histories use the resolved task id", () => {
|
||||
assert.deepEqual(
|
||||
taskThreadMetadata(
|
||||
{
|
||||
taskId: "108",
|
||||
agentId: "agent-1",
|
||||
openingText: "",
|
||||
},
|
||||
"rwfx",
|
||||
),
|
||||
{
|
||||
taskId: "108",
|
||||
taskRecordId: "108",
|
||||
taskGoPath: "rwfx",
|
||||
thread_type: "task_deeplink",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("xdfx history uses the original action id while preserving task id", () => {
|
||||
assert.deepEqual(
|
||||
taskThreadMetadata(
|
||||
{
|
||||
taskId: "108",
|
||||
agentId: "agent-2",
|
||||
openingText: "",
|
||||
actionId: "action-9",
|
||||
partitionByAction: true,
|
||||
},
|
||||
"xdfx",
|
||||
),
|
||||
{
|
||||
taskId: "108",
|
||||
actionId: "action-9",
|
||||
taskRecordId: "action-9",
|
||||
taskGoPath: "xdfx",
|
||||
thread_type: "task_deeplink",
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -5,8 +5,8 @@
|
||||
* - **目的树数据**:B 系统里的策划结果,走 ``getTaskPurposeDetail`` / ``delete``(地址、测试
|
||||
* 开关、测试假数据都来自 [[task-deeplink-config]] / 后端 config.yaml),与聊天对话无关。
|
||||
* 新建对话(含首次进入)前用它做「已有数据→覆盖确认」闸门。
|
||||
* - **对话(thread)**:聊天会话;通过 thread 的 ``metadata.taskId`` 标签把多个对话挂到同一个
|
||||
* 任务下(1:N),列表用 useThreadsPage({ metadata:{ taskId } }) 过滤。详见 [[task-deeplink]]。
|
||||
* - **对话(thread)**:聊天会话;真实消息仍是 LangGraph thread,3qfx/rwfx/xdfx 的历史列表
|
||||
* 通过 ``taskRecordId + taskGoPath`` 专用索引读取,不再过滤通用会话表。详见 [[task-deeplink]]。
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -33,7 +33,9 @@ export interface PurposeTreeNode {
|
||||
children: PurposeTreeNode[];
|
||||
}
|
||||
|
||||
/** 当前 rwfx 任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
|
||||
export type TaskDeeplinkGoPath = "3qfx" | "rwfx" | "xdfx";
|
||||
|
||||
/** 当前任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
|
||||
export interface TaskContext {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
@@ -47,6 +49,8 @@ export interface TaskContext {
|
||||
* (仍保留 taskId 标签以维持任务深链对话「全局共享、不分用户」的后端语义)。
|
||||
*/
|
||||
partitionByAction?: boolean;
|
||||
/** 单智能体深链接类型;用于写入专用 taskId + goPath 聊天记录索引。 */
|
||||
goPath?: TaskDeeplinkGoPath;
|
||||
}
|
||||
|
||||
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
|
||||
@@ -365,6 +369,10 @@ export function readTaskContext(): TaskContext | null {
|
||||
openingText: typeof parsed.openingText === "string" ? parsed.openingText : "",
|
||||
actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined,
|
||||
partitionByAction: parsed.partitionByAction === true,
|
||||
goPath:
|
||||
parsed.goPath === "3qfx" || parsed.goPath === "rwfx" || parsed.goPath === "xdfx"
|
||||
? parsed.goPath
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -372,19 +380,37 @@ export function readTaskContext(): TaskContext | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务深链对话的 thread metadata 标签(既用于「新建对话」打标签,也用于对话列表过滤)。
|
||||
* - 普通业务(rwfx/3qfx):`{ taskId }` —— 按任务划分(1:N)。
|
||||
* - xdfx(partitionByAction):`{ taskId, actionId }` —— 额外按「行动id」划分聊天记录;
|
||||
* 仍带 taskId 以保留后端「任务深链对话全局共享」的语义(后端据 metadata.taskId 跳过分用户过滤)。
|
||||
* 任务深链对话的 thread metadata 标签。``taskId`` 保留真实任务 id 和全局共享语义;
|
||||
* ``taskRecordId + taskGoPath`` 驱动独立聊天记录索引。xdfx 的 URL ``taskId`` 实为
|
||||
* 行动 id,因此其 ``taskRecordId`` 取 actionId,避免同一任务的不同行动混在一起。
|
||||
* 无有效 taskId 时返回 undefined(调用方据此不打标签)。
|
||||
*/
|
||||
export function taskThreadMetadata(
|
||||
ctx: TaskContext | null | undefined,
|
||||
goPath?: TaskDeeplinkGoPath,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!ctx?.taskId) return undefined;
|
||||
return ctx.partitionByAction && ctx.actionId
|
||||
? { taskId: ctx.taskId, actionId: ctx.actionId }
|
||||
: { taskId: ctx.taskId };
|
||||
const resolvedGoPath = goPath ?? ctx.goPath;
|
||||
const taskRecordId = taskRecordIdForContext(ctx);
|
||||
return {
|
||||
taskId: ctx.taskId,
|
||||
...(ctx.partitionByAction && ctx.actionId ? { actionId: ctx.actionId } : {}),
|
||||
...(resolvedGoPath
|
||||
? {
|
||||
taskRecordId,
|
||||
taskGoPath: resolvedGoPath,
|
||||
thread_type: "task_deeplink",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Original deep-link ``?taskId=`` used by the dedicated history partition. */
|
||||
export function taskRecordIdForContext(
|
||||
ctx: TaskContext | null | undefined,
|
||||
): string {
|
||||
if (!ctx) return "";
|
||||
return ctx.partitionByAction && ctx.actionId ? ctx.actionId : ctx.taskId;
|
||||
}
|
||||
|
||||
export function clearTaskContext(): void {
|
||||
|
||||
Reference in new issue
Block a user