feat: add Keycloak authentication
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
TJWATER_AUTH_MODE=required
|
||||
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
|
||||
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN=
|
||||
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||
TJWATER_GEOSERVER_WORKSPACE=tjwater
|
||||
|
||||
@@ -34,6 +34,9 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
|
||||
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
|
||||
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
|
||||
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
||||
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
||||
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 |
|
||||
@@ -43,6 +46,10 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
||||
|
||||
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。
|
||||
|
||||
浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。
|
||||
|
||||
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
|
||||
|
||||
Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
|
||||
|
||||
## 常用命令
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
set -eu
|
||||
|
||||
runtime_config=$(jq -cn \
|
||||
--arg authMode "${TJWATER_AUTH_MODE:-required}" \
|
||||
--arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \
|
||||
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
|
||||
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
||||
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
||||
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
|
||||
@@ -9,6 +12,9 @@ runtime_config=$(jq -cn \
|
||||
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
||||
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
||||
'{
|
||||
TJWATER_AUTH_MODE: $authMode,
|
||||
TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer,
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId,
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
||||
TJWATER_MAP_URL: $mapUrl,
|
||||
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"edge-tts-ts": "^1.0.0",
|
||||
"katex": "^0.17.0",
|
||||
"keycloak-js": "26.2.4",
|
||||
"lucide-react": "^0.468.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"motion": "^12.40.0",
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
||||
trace: "on-first-retry"
|
||||
},
|
||||
webServer: {
|
||||
command: `TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
||||
command: `TJWATER_AUTH_MODE=disabled TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: false
|
||||
},
|
||||
|
||||
Generated
+8
@@ -83,6 +83,9 @@ importers:
|
||||
katex:
|
||||
specifier: ^0.17.0
|
||||
version: 0.17.0
|
||||
keycloak-js:
|
||||
specifier: 26.2.4
|
||||
version: 26.2.4
|
||||
lucide-react:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0(react@19.2.7)
|
||||
@@ -2859,6 +2862,9 @@ packages:
|
||||
kdbush@4.1.0:
|
||||
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
|
||||
|
||||
keycloak-js@26.2.4:
|
||||
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -6645,6 +6651,8 @@ snapshots:
|
||||
|
||||
kdbush@4.1.0: {}
|
||||
|
||||
keycloak-js@26.2.4: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
globalThis.__TJWATER_CONFIG__ = {};
|
||||
globalThis.__TJWATER_CONFIG__ = {
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||
};
|
||||
|
||||
+7
-2
@@ -1,11 +1,16 @@
|
||||
import { MapToaster } from "@/features/map/core";
|
||||
import { MapWorkbenchPage } from "@/features/workbench";
|
||||
import type { Authentication } from "@/shared/auth/keycloak-auth";
|
||||
import { AppProviders } from "./providers";
|
||||
|
||||
export function App() {
|
||||
export function App({ authentication }: { authentication: Authentication }) {
|
||||
return (
|
||||
<AppProviders>
|
||||
<MapWorkbenchPage />
|
||||
<MapWorkbenchPage
|
||||
user={authentication.user ?? undefined}
|
||||
onLogout={authentication.logout}
|
||||
getAccessToken={authentication.getAccessToken}
|
||||
/>
|
||||
<MapToaster />
|
||||
</AppProviders>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Droplets, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function AuthStartupScreen({ error }: { error?: boolean }) {
|
||||
return (
|
||||
<main className="grid min-h-[100dvh] place-items-center bg-slate-100 px-5 text-slate-900">
|
||||
<section className="surface-reading w-full max-w-md p-6 text-center shadow-xl shadow-slate-900/5">
|
||||
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl bg-blue-600 text-white">
|
||||
<Droplets size={22} aria-hidden="true" />
|
||||
</span>
|
||||
<h1 className="mt-4 text-lg font-semibold">
|
||||
{error ? "统一身份认证连接失败" : "正在连接统一身份认证"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
{error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"}
|
||||
</p>
|
||||
{error ? (
|
||||
<Button className="mt-5" onClick={() => window.location.reload()}>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
重新连接
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -105,6 +105,23 @@ describe("Agent API client sessions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the current Keycloak access token without dropping request headers", async () => {
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const getAccessToken = vi.fn().mockResolvedValue("keycloak-token");
|
||||
|
||||
await createAgentApiClient("http://agent.local", { getAccessToken }).createSession();
|
||||
|
||||
expect(getAccessToken).toHaveBeenCalledOnce();
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
if (!init) throw new Error("Expected Agent request init");
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer keycloak-token");
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("streams session events from the backend SSE endpoint", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
|
||||
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||
|
||||
@@ -112,19 +113,47 @@ export type AgentApiClient = {
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type AgentApiClientOptions = {
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
};
|
||||
|
||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||
|
||||
const CHAT_PATH = "/api/v1/agent/chat";
|
||||
|
||||
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
|
||||
export function createAgentApiClient(
|
||||
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
||||
options: AgentApiClientOptions = {}
|
||||
): AgentApiClient {
|
||||
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
||||
let activeBaseUrl = candidates[0] ?? "";
|
||||
const setActiveBaseUrl = (baseUrl: string) => {
|
||||
activeBaseUrl = baseUrl;
|
||||
};
|
||||
const request = (path: string, init?: RequestInit) =>
|
||||
fetchWithFallback(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
setActiveBaseUrl,
|
||||
path,
|
||||
init,
|
||||
options.getAccessToken
|
||||
);
|
||||
const requestJson = async <T,>(path: string, init?: RequestInit) => {
|
||||
const response = await request(path, init);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getResponseErrorMessage(data, response.status));
|
||||
}
|
||||
|
||||
return data as T;
|
||||
};
|
||||
|
||||
return {
|
||||
async createSession() {
|
||||
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/session", {
|
||||
return requestJson<AgentChatSession>("/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async listSessions() {
|
||||
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
"/sessions"
|
||||
);
|
||||
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
|
||||
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||
},
|
||||
|
||||
async getFrontendActionRegistry() {
|
||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
|
||||
return requestJson<unknown>("/frontend-action-registry");
|
||||
},
|
||||
|
||||
async submitFrontendActionResult(sessionId, actionId, result) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, {
|
||||
await requestJson<unknown>(`/frontend-actions/${encodeURIComponent(actionId)}/result`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
|
||||
body: JSON.stringify(result)
|
||||
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async loadSession(sessionId) {
|
||||
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/session/${encodeURIComponent(sessionId)}`);
|
||||
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async streamSession(sessionId, options) {
|
||||
const response = await fetchWithFallback(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
const response = await request(
|
||||
`/session/${encodeURIComponent(sessionId)}/stream`,
|
||||
{ signal: options.signal }
|
||||
);
|
||||
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
return;
|
||||
}
|
||||
|
||||
await requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
await requestJson<unknown>(
|
||||
`/session/${encodeURIComponent(sessionId)}/title`,
|
||||
{
|
||||
method: "PATCH",
|
||||
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async deleteSession(sessionId) {
|
||||
await requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
await requestJson<unknown>(
|
||||
`/session/${encodeURIComponent(sessionId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
},
|
||||
|
||||
async getModels() {
|
||||
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/models");
|
||||
const payload = await requestJson<unknown>("/models");
|
||||
return toModelsResponse(payload);
|
||||
},
|
||||
|
||||
async getUiRegistry() {
|
||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/ui-registry");
|
||||
return requestJson<unknown>("/ui-registry");
|
||||
},
|
||||
|
||||
async resolveRenderRef(renderRef, sessionId) {
|
||||
const params = new URLSearchParams({ session_id: sessionId });
|
||||
return requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
return requestJson<unknown>(
|
||||
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
|
||||
);
|
||||
},
|
||||
|
||||
async replyPermission(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async replyQuestion(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/question/${encodeURIComponent(requestId)}/reply`, {
|
||||
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async rejectQuestion(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/question/${encodeURIComponent(requestId)}/reject`, {
|
||||
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async abort(sessionId) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/abort", {
|
||||
await requestJson<unknown>("/abort", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId })
|
||||
@@ -304,38 +292,28 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJsonWithFallback<T>(
|
||||
baseUrls: string[],
|
||||
activeBaseUrl: string,
|
||||
setActiveBaseUrl: (baseUrl: string) => void,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
) {
|
||||
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getResponseErrorMessage(data, response.status));
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function fetchWithFallback(
|
||||
baseUrls: string[],
|
||||
activeBaseUrl: string,
|
||||
setActiveBaseUrl: (baseUrl: string) => void,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
init?: RequestInit,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
) {
|
||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||
let lastError: unknown;
|
||||
let lastResponse: Response | null = null;
|
||||
const accessToken = await getAccessToken?.();
|
||||
const requestInit = accessToken
|
||||
? {
|
||||
...init,
|
||||
headers: withBearerToken(init?.headers, accessToken)
|
||||
}
|
||||
: init;
|
||||
|
||||
for (const baseUrl of orderedBaseUrls) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
|
||||
if (response.ok) {
|
||||
setActiveBaseUrl(baseUrl);
|
||||
return response;
|
||||
@@ -359,6 +337,12 @@ async function fetchWithFallback(
|
||||
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
||||
}
|
||||
|
||||
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
|
||||
const headers = new Headers(headersInit);
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function shouldFallbackOnHttpStatus(status: number) {
|
||||
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export { AgentPersona } from "./components/agent-persona";
|
||||
export { createAgentApiClient } from "./api/client";
|
||||
export type {
|
||||
AgentApiClient,
|
||||
AgentApiClientOptions,
|
||||
AgentChatSessionSummary,
|
||||
AgentLoadedChatSession,
|
||||
AgentSessionStreamEvent
|
||||
|
||||
@@ -1141,7 +1141,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="line-clamp-1">{item}</span>
|
||||
<span className="mt-0.5 rounded-full bg-blue-50 px-1.5 py-0.5 text-xs font-semibold leading-4 text-blue-700">采集中</span>
|
||||
<StatusBadge tone="info" activity="loading" className="mt-0.5">
|
||||
采集中
|
||||
</StatusBadge>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -283,7 +283,8 @@ export function UserMenu({
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
onExportConfig,
|
||||
onLogout
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -292,6 +293,7 @@ export function UserMenu({
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
@@ -320,7 +322,15 @@ export function UserMenu({
|
||||
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
||||
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
||||
<MenuSeparator />
|
||||
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<DropdownMenuItem
|
||||
disabled={!onLogout}
|
||||
onSelect={onLogout ? () => void onLogout() : undefined}
|
||||
className={cn(
|
||||
"px-2 py-2",
|
||||
!onLogout && "text-slate-400",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<LogOut size={15} aria-hidden="true" />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -44,6 +44,7 @@ export type WorkbenchTopBarProps = {
|
||||
onRefreshTiles: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
||||
@@ -72,7 +73,8 @@ export function WorkbenchTopBar({
|
||||
onShowDataStatus,
|
||||
onRefreshTiles,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
onExportConfig,
|
||||
onLogout
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
const activeScenario =
|
||||
@@ -176,6 +178,7 @@ export function WorkbenchTopBar({
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DefaultChatTransport } from "ai";
|
||||
import useSWR from "swr";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import {
|
||||
@@ -50,13 +51,18 @@ const AGENT_PANEL_COLLAPSE_MS = 180;
|
||||
type UseWorkbenchAgentOptions = {
|
||||
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
||||
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
};
|
||||
|
||||
export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) {
|
||||
export function useWorkbenchAgent({
|
||||
onUiEnvelope,
|
||||
onFrontendAction,
|
||||
getAccessToken
|
||||
}: UseWorkbenchAgentOptions) {
|
||||
const collapseTimerRef = useRef<number | null>(null);
|
||||
const mobileCollapseTimerRef = useRef<number | null>(null);
|
||||
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
||||
const clientRef = useRef(createAgentApiClient());
|
||||
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken }));
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const approvalModeRef = useRef<AgentApprovalMode>("request");
|
||||
const registryRef = useRef<UIRegistry | null>(null);
|
||||
@@ -205,6 +211,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
() =>
|
||||
new DefaultChatTransport<AgentUiMessage>({
|
||||
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
|
||||
headers: async (): Promise<Record<string, string>> => {
|
||||
const accessToken = await getAccessToken?.();
|
||||
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
|
||||
},
|
||||
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
||||
return {
|
||||
body: {
|
||||
@@ -220,7 +230,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
};
|
||||
}
|
||||
}),
|
||||
[]
|
||||
[getAccessToken]
|
||||
);
|
||||
|
||||
const chat = useChat<AgentUiMessage>({
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type MapSourceStatus
|
||||
} from "@/features/map/core";
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||
import { MapDevPanel } from "./components/map-dev-panel";
|
||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||
@@ -76,7 +77,8 @@ import type {
|
||||
DetailFeature,
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionRecord,
|
||||
WorkbenchAlert
|
||||
WorkbenchAlert,
|
||||
WorkbenchUser
|
||||
} from "./types";
|
||||
import {
|
||||
createScheduledConditionAlerts,
|
||||
@@ -86,7 +88,15 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
|
||||
|
||||
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
||||
|
||||
export function MapWorkbenchPage() {
|
||||
export function MapWorkbenchPage({
|
||||
user = WORKBENCH_USER,
|
||||
onLogout,
|
||||
getAccessToken
|
||||
}: {
|
||||
user?: WorkbenchUser;
|
||||
onLogout?: () => Promise<void>;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
}) {
|
||||
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -129,7 +139,8 @@ export function MapWorkbenchPage() {
|
||||
|
||||
const agent = useWorkbenchAgent({
|
||||
onUiEnvelope: handleAgentUiEnvelope,
|
||||
onFrontendAction: handleFrontendAction
|
||||
onFrontendAction: handleFrontendAction,
|
||||
getAccessToken
|
||||
});
|
||||
const clearActiveTool = useCallback(() => {
|
||||
setActiveToolId(null);
|
||||
@@ -766,7 +777,7 @@ export function MapWorkbenchPage() {
|
||||
scenarios={WORKBENCH_SCENARIOS}
|
||||
activeScenarioId={activeScenarioId}
|
||||
alerts={workbenchAlerts}
|
||||
user={WORKBENCH_USER}
|
||||
user={user}
|
||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||
taskTickerAvailable={taskTickerAvailable}
|
||||
taskTickerVisible={taskTickerVisible}
|
||||
@@ -785,6 +796,7 @@ export function MapWorkbenchPage() {
|
||||
onRefreshTiles={handleRefreshTiles}
|
||||
onShowShortcuts={handleShowShortcuts}
|
||||
onExportConfig={handleExportConfig}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
<WorkbenchAgentPanels
|
||||
|
||||
+21
-11
@@ -1,23 +1,33 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "@/app/app";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { AuthStartupScreen } from "@/app/auth-startup-screen";
|
||||
import { initializeAuthentication } from "@/shared/auth/keycloak-auth";
|
||||
import "@/styles.css";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "streamdown/styles.css";
|
||||
|
||||
async function prepareMocks() {
|
||||
if (!env.TJWATER_ENABLE_MSW) return;
|
||||
const root = ReactDOM.createRoot(document.getElementById("root")!);
|
||||
|
||||
root.render(<AuthStartupScreen />);
|
||||
|
||||
try {
|
||||
const [{ App }, { env }] = await Promise.all([
|
||||
import("@/app/app"),
|
||||
import("@/shared/config/env")
|
||||
]);
|
||||
if (env.TJWATER_ENABLE_MSW) {
|
||||
const { worker } = await import("@/mocks/browser");
|
||||
await worker.start({ onUnhandledRequest: "bypass" });
|
||||
}
|
||||
|
||||
await prepareMocks();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
}
|
||||
const authentication = await initializeAuthentication(env);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<App authentication={authentication} />
|
||||
</React.StrictMode>
|
||||
);
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize authentication", error);
|
||||
root.render(<AuthStartupScreen error />);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parseRuntimeConfig } from "@/shared/config/env";
|
||||
import {
|
||||
initializeAuthentication,
|
||||
toAuthenticatedUser,
|
||||
toKeycloakConfig
|
||||
} from "./keycloak-auth";
|
||||
|
||||
const requiredConfig = parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/auth/realms/tjwater",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||
});
|
||||
|
||||
describe("Keycloak authentication", () => {
|
||||
it("derives the Keycloak server and Realm from the issuer", () => {
|
||||
expect(toKeycloakConfig(requiredConfig.TJWATER_KEYCLOAK_ISSUER, "next-tjwater")).toEqual({
|
||||
url: "https://auth.example.test/auth",
|
||||
realm: "tjwater",
|
||||
clientId: "next-tjwater"
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes the public SPA flow and exposes the refreshed token", async () => {
|
||||
const client = {
|
||||
token: "access-token",
|
||||
idTokenParsed: {
|
||||
sub: "user-1",
|
||||
name: "张调度",
|
||||
email: "operator@example.test"
|
||||
},
|
||||
init: vi.fn().mockResolvedValue(true),
|
||||
login: vi.fn().mockResolvedValue(undefined),
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
updateToken: vi.fn().mockResolvedValue(false)
|
||||
};
|
||||
|
||||
const authentication = await initializeAuthentication(requiredConfig, () => client);
|
||||
|
||||
expect(client.init).toHaveBeenCalledWith({
|
||||
onLoad: "login-required",
|
||||
flow: "standard",
|
||||
pkceMethod: "S256"
|
||||
});
|
||||
expect(authentication.user).toEqual({
|
||||
name: "张调度",
|
||||
role: "operator@example.test"
|
||||
});
|
||||
await expect(authentication.getAccessToken()).resolves.toBe("access-token");
|
||||
expect(client.updateToken).toHaveBeenCalledWith(30);
|
||||
|
||||
await authentication.logout?.();
|
||||
expect(client.logout).toHaveBeenCalledWith({ redirectUri: "http://localhost:3000/" });
|
||||
});
|
||||
|
||||
it("uses stable user claim fallbacks", () => {
|
||||
expect(toAuthenticatedUser({ sub: "user-2", preferred_username: "dispatcher" })).toEqual({
|
||||
name: "dispatcher",
|
||||
role: "统一认证用户"
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to Keycloak instead of using a token after refresh failure", async () => {
|
||||
const refreshError = new Error("refresh failed");
|
||||
const client = {
|
||||
token: "expired-token",
|
||||
tokenParsed: { sub: "user-3" },
|
||||
init: vi.fn().mockResolvedValue(true),
|
||||
login: vi.fn().mockResolvedValue(undefined),
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
updateToken: vi.fn().mockRejectedValue(refreshError)
|
||||
};
|
||||
const authentication = await initializeAuthentication(requiredConfig, () => client);
|
||||
|
||||
await expect(authentication.getAccessToken()).rejects.toBe(refreshError);
|
||||
expect(client.login).toHaveBeenCalledWith({ redirectUri: "http://localhost:3000/" });
|
||||
});
|
||||
|
||||
it("keeps authentication inert when explicitly disabled", async () => {
|
||||
const createClient = vi.fn();
|
||||
const authentication = await initializeAuthentication(
|
||||
parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" }),
|
||||
createClient
|
||||
);
|
||||
|
||||
expect(authentication.enabled).toBe(false);
|
||||
await expect(authentication.getAccessToken()).resolves.toBeNull();
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import Keycloak, {
|
||||
type KeycloakConfig,
|
||||
type KeycloakInitOptions,
|
||||
type KeycloakLoginOptions,
|
||||
type KeycloakLogoutOptions,
|
||||
type KeycloakTokenParsed
|
||||
} from "keycloak-js";
|
||||
import type { RuntimeConfig } from "@/shared/config/env";
|
||||
|
||||
const TOKEN_MIN_VALIDITY_SECONDS = 30;
|
||||
|
||||
export type AuthenticatedUser = {
|
||||
name: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type AccessTokenProvider = () => Promise<string | null>;
|
||||
|
||||
export type Authentication = {
|
||||
enabled: boolean;
|
||||
user: AuthenticatedUser | null;
|
||||
getAccessToken: AccessTokenProvider;
|
||||
logout?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type KeycloakClient = {
|
||||
token?: string;
|
||||
tokenParsed?: KeycloakTokenParsed;
|
||||
idTokenParsed?: KeycloakTokenParsed;
|
||||
onTokenExpired?: () => void;
|
||||
init: (options: KeycloakInitOptions) => Promise<boolean>;
|
||||
login: (options?: KeycloakLoginOptions) => Promise<void>;
|
||||
logout: (options?: KeycloakLogoutOptions) => Promise<void>;
|
||||
updateToken: (minValidity: number) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type KeycloakClientFactory = (config: KeycloakConfig) => KeycloakClient;
|
||||
|
||||
export async function initializeAuthentication(
|
||||
config: RuntimeConfig,
|
||||
createClient: KeycloakClientFactory = (keycloakConfig) => new Keycloak(keycloakConfig)
|
||||
): Promise<Authentication> {
|
||||
if (config.TJWATER_AUTH_MODE === "disabled") {
|
||||
return {
|
||||
enabled: false,
|
||||
user: null,
|
||||
getAccessToken: async () => null
|
||||
};
|
||||
}
|
||||
|
||||
const keycloak = createClient(
|
||||
toKeycloakConfig(
|
||||
config.TJWATER_KEYCLOAK_ISSUER,
|
||||
config.TJWATER_KEYCLOAK_CLIENT_ID
|
||||
)
|
||||
);
|
||||
const redirectUri = `${window.location.origin}/`;
|
||||
const login = () => keycloak.login({ redirectUri });
|
||||
const authenticated = await keycloak.init({
|
||||
onLoad: "login-required",
|
||||
flow: "standard",
|
||||
pkceMethod: "S256"
|
||||
});
|
||||
|
||||
if (!authenticated) {
|
||||
await login();
|
||||
throw new Error("Keycloak authentication was not completed");
|
||||
}
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
try {
|
||||
await keycloak.updateToken(TOKEN_MIN_VALIDITY_SECONDS);
|
||||
} catch (error) {
|
||||
await login();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
keycloak.onTokenExpired = () => {
|
||||
void refreshAccessToken().catch(() => undefined);
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
user: toAuthenticatedUser(keycloak.idTokenParsed ?? keycloak.tokenParsed),
|
||||
getAccessToken: async () => {
|
||||
await refreshAccessToken();
|
||||
if (!keycloak.token) {
|
||||
throw new Error("Keycloak access token is unavailable");
|
||||
}
|
||||
return keycloak.token;
|
||||
},
|
||||
logout: () => keycloak.logout({ redirectUri })
|
||||
};
|
||||
}
|
||||
|
||||
export function toKeycloakConfig(issuer: string, clientId: string): KeycloakConfig {
|
||||
const url = new URL(issuer);
|
||||
const realmMarker = "/realms/";
|
||||
const markerIndex = url.pathname.lastIndexOf(realmMarker);
|
||||
const realm = decodeURIComponent(url.pathname.slice(markerIndex + realmMarker.length));
|
||||
const serverPath = url.pathname.slice(0, markerIndex).replace(/\/+$/, "");
|
||||
|
||||
return {
|
||||
url: `${url.origin}${serverPath}`,
|
||||
realm,
|
||||
clientId
|
||||
};
|
||||
}
|
||||
|
||||
export function toAuthenticatedUser(token: KeycloakTokenParsed | undefined): AuthenticatedUser {
|
||||
const name = readClaim(token, "name") ?? readClaim(token, "preferred_username") ?? readClaim(token, "sub") ?? "已认证用户";
|
||||
return {
|
||||
name,
|
||||
role: readClaim(token, "email") ?? "统一认证用户"
|
||||
};
|
||||
}
|
||||
|
||||
function readClaim(token: KeycloakTokenParsed | undefined, key: string) {
|
||||
const value = token?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ describe("runtime frontend configuration", () => {
|
||||
it("uses container-provided values without relying on Vite build variables", () => {
|
||||
expect(
|
||||
parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater/",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||
@@ -14,6 +17,9 @@ describe("runtime frontend configuration", () => {
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
})
|
||||
).toEqual({
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||
@@ -24,12 +30,18 @@ describe("runtime frontend configuration", () => {
|
||||
});
|
||||
|
||||
it("applies typed defaults for optional feature flags", () => {
|
||||
expect(parseRuntimeConfig({})).toMatchObject({
|
||||
expect(parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" })).toMatchObject({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||
TJWATER_ENABLE_DEV_PANEL: false,
|
||||
TJWATER_ENABLE_MSW: false
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a Realm issuer when authentication is enabled", () => {
|
||||
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "required" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid runtime URLs before the application starts", () => {
|
||||
expect(() => parseRuntimeConfig({ TJWATER_MAP_URL: "not-a-url" })).toThrow();
|
||||
});
|
||||
@@ -39,6 +51,16 @@ describe("runtime frontend configuration", () => {
|
||||
["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
|
||||
["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"]
|
||||
])("rejects unsafe browser runtime address %s", (key, value) => {
|
||||
expect(() => parseRuntimeConfig({ [key]: value })).toThrow();
|
||||
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled", [key]: value })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a Keycloak URL that does not identify a Realm", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,14 +22,53 @@ const browserHttpUrl = z
|
||||
}
|
||||
});
|
||||
|
||||
const runtimeConfigSchema = z.object({
|
||||
const keycloakIssuer = z
|
||||
.string()
|
||||
.trim()
|
||||
.default("")
|
||||
.superRefine((value, context) => {
|
||||
if (!value) return;
|
||||
const parsed = browserHttpUrl.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须是安全的 HTTP(S) 地址" });
|
||||
return;
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (url.search || !/\/realms\/[^/]+\/?$/.test(url.pathname)) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须指向具体 Realm" });
|
||||
}
|
||||
})
|
||||
.transform((value) => value.replace(/\/+$/, ""));
|
||||
|
||||
const runtimeConfigSchema = z
|
||||
.object({
|
||||
TJWATER_AUTH_MODE: z.enum(["required", "disabled"]).default("required"),
|
||||
TJWATER_KEYCLOAK_ISSUER: keycloakIssuer,
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"),
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
||||
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
||||
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"),
|
||||
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
||||
TJWATER_ENABLE_MSW: runtimeBoolean(false)
|
||||
});
|
||||
})
|
||||
.superRefine((config, context) => {
|
||||
if (config.TJWATER_AUTH_MODE !== "required") return;
|
||||
if (!config.TJWATER_KEYCLOAK_ISSUER) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["TJWATER_KEYCLOAK_ISSUER"],
|
||||
message: "启用认证时必须配置 Keycloak issuer"
|
||||
});
|
||||
}
|
||||
if (!config.TJWATER_KEYCLOAK_CLIENT_ID) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["TJWATER_KEYCLOAK_CLIENT_ID"],
|
||||
message: "启用认证时必须配置 Keycloak client ID"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
declare global {
|
||||
var __TJWATER_CONFIG__: unknown;
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "disabled" };
|
||||
|
||||
@@ -151,6 +151,7 @@ async function prepareWorkbench(page: Page) {
|
||||
await route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
type MockAgentApiOptions = {
|
||||
model?: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: "bolt" | "sparkle";
|
||||
};
|
||||
sessions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
|
||||
const model = options.model ?? {
|
||||
id: "test/model",
|
||||
label: "测试模型",
|
||||
description: "Playwright 浏览器测试",
|
||||
icon: "bolt" as const
|
||||
};
|
||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
|
||||
@@ -37,15 +49,8 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
|
||||
if (pathname.endsWith("/models")) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
default_model: "test/model",
|
||||
models: [
|
||||
{
|
||||
id: "test/model",
|
||||
label: "测试模型",
|
||||
description: "Playwright 浏览器测试",
|
||||
icon: "bolt"
|
||||
}
|
||||
]
|
||||
default_model: model.id,
|
||||
models: [model]
|
||||
}
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
|
||||
const EMPTY_RASTER_TILE = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
@@ -116,10 +117,19 @@ test.describe("neutral blue mist workbench", () => {
|
||||
});
|
||||
|
||||
async function prepareDeterministicWorkbench(page: Page) {
|
||||
await mockAgentApi(page, {
|
||||
model: {
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
label: "快速",
|
||||
description: "快速响应",
|
||||
icon: "bolt"
|
||||
}
|
||||
});
|
||||
await page.route("**/runtime-config.js", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
||||
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
||||
@@ -148,7 +158,9 @@ async function prepareDeterministicWorkbench(page: Page) {
|
||||
|
||||
async function openWorkbench(page: Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.locator("main").evaluate((element) => {
|
||||
const workbench = page.locator("main[data-basemap-tone]");
|
||||
await workbench.waitFor();
|
||||
await workbench.evaluate((element) => {
|
||||
element.setAttribute("data-visual-test", "true");
|
||||
});
|
||||
await page.addStyleTag({
|
||||
|
||||
@@ -8,6 +8,9 @@ const RUNTIME_CONFIG_PATH = "/runtime-config.js";
|
||||
|
||||
function renderRuntimeConfig(values: Record<string, string>) {
|
||||
const config = {
|
||||
TJWATER_AUTH_MODE: values.TJWATER_AUTH_MODE || "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: values.TJWATER_KEYCLOAK_ISSUER || "",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
|
||||
TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater",
|
||||
|
||||
Reference in New Issue
Block a user