diff --git a/.env.example b/.env.example index 7bbd71e..d1435ef 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index acd9fc2..d73eb70 100644 --- a/README.md +++ b/README.md @@ -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` 用于覆盖默认中文语音。 ## 常用命令 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 6bac895..c88e7ae 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -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, diff --git a/package.json b/package.json index db839f6..e34aeec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 6bad60e..a74d3e4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 345db01..2acb6dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/public/runtime-config.js b/public/runtime-config.js index 7bad0cd..7abf66e 100644 --- a/public/runtime-config.js +++ b/public/runtime-config.js @@ -1 +1,5 @@ -globalThis.__TJWATER_CONFIG__ = {}; +globalThis.__TJWATER_CONFIG__ = { + TJWATER_AUTH_MODE: "required", + TJWATER_KEYCLOAK_ISSUER: "", + TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater" +}; diff --git a/src/app/app.tsx b/src/app/app.tsx index 609e42e..1f406a7 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -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 ( - + ); diff --git a/src/app/auth-startup-screen.tsx b/src/app/auth-startup-screen.tsx new file mode 100644 index 0000000..f8a4d9e --- /dev/null +++ b/src/app/auth-startup-screen.tsx @@ -0,0 +1,26 @@ +import { Droplets, RefreshCw } from "lucide-react"; +import { Button } from "@/shared/ui/button"; + +export function AuthStartupScreen({ error }: { error?: boolean }) { + return ( + + + + + + + {error ? "统一身份认证连接失败" : "正在连接统一身份认证"} + + + {error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"} + + {error ? ( + window.location.reload()}> + + 重新连接 + + ) : null} + + + ); +} diff --git a/src/features/agent/api/client.test.ts b/src/features/agent/api/client.test.ts index f43b736..71d85c0 100644 --- a/src/features/agent/api/client.test.ts +++ b/src/features/agent/api/client.test.ts @@ -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", diff --git a/src/features/agent/api/client.ts b/src/features/agent/api/client.ts index cdf81a2..dad9d88 100644 --- a/src/features/agent/api/client.ts +++ b/src/features/agent/api/client.ts @@ -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; }; +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 (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(candidates, activeBaseUrl, (nextBaseUrl) => { - activeBaseUrl = nextBaseUrl; - }, "/session", { + return requestJson("/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(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry"); + return requestJson("/frontend-action-registry"); }, async submitFrontendActionResult(sessionId, actionId, result) { - await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, { + await requestJson(`/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( - candidates, - activeBaseUrl, - (nextBaseUrl) => { - activeBaseUrl = nextBaseUrl; - }, + await requestJson( `/session/${encodeURIComponent(sessionId)}/title`, { method: "PATCH", @@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS }, async deleteSession(sessionId) { - await requestJsonWithFallback( - candidates, - activeBaseUrl, - (nextBaseUrl) => { - activeBaseUrl = nextBaseUrl; - }, + await requestJson( `/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" } ); }, async getModels() { - const payload = await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { - activeBaseUrl = nextBaseUrl; - }, "/models"); + const payload = await requestJson("/models"); return toModelsResponse(payload); }, async getUiRegistry() { - return requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { - activeBaseUrl = nextBaseUrl; - }, "/ui-registry"); + return requestJson("/ui-registry"); }, async resolveRenderRef(renderRef, sessionId) { const params = new URLSearchParams({ session_id: sessionId }); - return requestJsonWithFallback( - candidates, - activeBaseUrl, - (nextBaseUrl) => { - activeBaseUrl = nextBaseUrl; - }, + return requestJson( `/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}` ); }, async replyPermission(requestId, options) { - await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { - activeBaseUrl = nextBaseUrl; - }, `/permission/${encodeURIComponent(requestId)}/reply`, { + await requestJson(`/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(candidates, activeBaseUrl, (nextBaseUrl) => { - activeBaseUrl = nextBaseUrl; - }, `/question/${encodeURIComponent(requestId)}/reply`, { + await requestJson(`/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(candidates, activeBaseUrl, (nextBaseUrl) => { - activeBaseUrl = nextBaseUrl; - }, `/question/${encodeURIComponent(requestId)}/reject`, { + await requestJson(`/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(candidates, activeBaseUrl, (nextBaseUrl) => { - activeBaseUrl = nextBaseUrl; - }, "/abort", { + await requestJson("/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( - 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; } diff --git a/src/features/agent/index.ts b/src/features/agent/index.ts index 0e6c0be..fb13b97 100644 --- a/src/features/agent/index.ts +++ b/src/features/agent/index.ts @@ -6,6 +6,7 @@ export { AgentPersona } from "./components/agent-persona"; export { createAgentApiClient } from "./api/client"; export type { AgentApiClient, + AgentApiClientOptions, AgentChatSessionSummary, AgentLoadedChatSession, AgentSessionStreamEvent diff --git a/src/features/workbench/components/scheduled-condition-detail-panel.tsx b/src/features/workbench/components/scheduled-condition-detail-panel.tsx index e08c16a..942faa2 100644 --- a/src/features/workbench/components/scheduled-condition-detail-panel.tsx +++ b/src/features/workbench/components/scheduled-condition-detail-panel.tsx @@ -1141,7 +1141,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) { {index + 1} {item} - 采集中 + + 采集中 + )) ) : ( diff --git a/src/features/workbench/components/workbench-top-bar-menus.tsx b/src/features/workbench/components/workbench-top-bar-menus.tsx index 67dc588..e53a64a 100644 --- a/src/features/workbench/components/workbench-top-bar-menus.tsx +++ b/src/features/workbench/components/workbench-top-bar-menus.tsx @@ -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; }) { return ( @@ -320,7 +322,15 @@ export function UserMenu({ - + void onLogout() : undefined} + className={cn( + "px-2 py-2", + !onLogout && "text-slate-400", + MAP_COMPACT_RADIUS_CLASS_NAME + )} + > 退出登录 diff --git a/src/features/workbench/components/workbench-top-bar.tsx b/src/features/workbench/components/workbench-top-bar.tsx index 595bb5c..3af4924 100644 --- a/src/features/workbench/components/workbench-top-bar.tsx +++ b/src/features/workbench/components/workbench-top-bar.tsx @@ -44,6 +44,7 @@ export type WorkbenchTopBarProps = { onRefreshTiles: () => void; onShowShortcuts: () => void; onExportConfig: () => void; + onLogout?: () => Promise; }; 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(null); const activeScenario = @@ -176,6 +178,7 @@ export function WorkbenchTopBar({ onShowDataStatus={onShowDataStatus} onShowShortcuts={onShowShortcuts} onExportConfig={onExportConfig} + onLogout={onLogout} /> diff --git a/src/features/workbench/hooks/use-workbench-agent.ts b/src/features/workbench/hooks/use-workbench-agent.ts index ae2449f..83b6282 100644 --- a/src/features/workbench/hooks/use-workbench-agent.ts +++ b/src/features/workbench/hooks/use-workbench-agent.ts @@ -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; onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise; + getAccessToken?: AccessTokenProvider; }; -export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) { +export function useWorkbenchAgent({ + onUiEnvelope, + onFrontendAction, + getAccessToken +}: UseWorkbenchAgentOptions) { const collapseTimerRef = useRef(null); const mobileCollapseTimerRef = useRef(null); const sessionStreamAbortRef = useRef(null); - const clientRef = useRef(createAgentApiClient()); + const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken })); const sessionIdRef = useRef(null); const approvalModeRef = useRef("request"); const registryRef = useRef(null); @@ -205,6 +211,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben () => new DefaultChatTransport({ api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`, + headers: async (): Promise> => { + 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({ diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index 15852c9..32bee57 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -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; + getAccessToken?: AccessTokenProvider; +}) { const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN); const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL; const mapContainerRef = useRef(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} /> ); + +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" }); + } + const authentication = await initializeAuthentication(env); + root.render( + + + + ); +} catch (error) { + console.error("Failed to initialize authentication", error); + root.render(); } - -await prepareMocks(); - -ReactDOM.createRoot(document.getElementById("root")!).render( - - - -); diff --git a/src/shared/auth/keycloak-auth.test.ts b/src/shared/auth/keycloak-auth.test.ts new file mode 100644 index 0000000..68e686c --- /dev/null +++ b/src/shared/auth/keycloak-auth.test.ts @@ -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(); + }); +}); diff --git a/src/shared/auth/keycloak-auth.ts b/src/shared/auth/keycloak-auth.ts new file mode 100644 index 0000000..2b7e70e --- /dev/null +++ b/src/shared/auth/keycloak-auth.ts @@ -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; + +export type Authentication = { + enabled: boolean; + user: AuthenticatedUser | null; + getAccessToken: AccessTokenProvider; + logout?: () => Promise; +}; + +type KeycloakClient = { + token?: string; + tokenParsed?: KeycloakTokenParsed; + idTokenParsed?: KeycloakTokenParsed; + onTokenExpired?: () => void; + init: (options: KeycloakInitOptions) => Promise; + login: (options?: KeycloakLoginOptions) => Promise; + logout: (options?: KeycloakLogoutOptions) => Promise; + updateToken: (minValidity: number) => Promise; +}; + +type KeycloakClientFactory = (config: KeycloakConfig) => KeycloakClient; + +export async function initializeAuthentication( + config: RuntimeConfig, + createClient: KeycloakClientFactory = (keycloakConfig) => new Keycloak(keycloakConfig) +): Promise { + 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; +} diff --git a/src/shared/config/env.test.ts b/src/shared/config/env.test.ts index 70cc775..1de0d17 100644 --- a/src/shared/config/env.test.ts +++ b/src/shared/config/env.test.ts @@ -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(); }); }); diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index c1df264..ac757f5 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -22,14 +22,53 @@ const browserHttpUrl = z } }); -const runtimeConfigSchema = z.object({ - 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) -}); +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; diff --git a/src/test/setup.ts b/src/test/setup.ts index f149f27..3ab686c 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,3 @@ import "@testing-library/jest-dom/vitest"; + +globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "disabled" }; diff --git a/tests/browser/button-system.e2e.ts b/tests/browser/button-system.e2e.ts index 8f4f74e..d2520e8 100644 --- a/tests/browser/button-system.e2e.ts +++ b/tests/browser/button-system.e2e.ts @@ -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", diff --git a/tests/browser/support/mock-agent-api.ts b/tests/browser/support/mock-agent-api.ts index 1acdbc0..a6e8b6d 100644 --- a/tests/browser/support/mock-agent-api.ts +++ b/tests/browser/support/mock-agent-api.ts @@ -1,10 +1,22 @@ import type { Page } from "@playwright/test"; type MockAgentApiOptions = { + model?: { + id: string; + label: string; + description: string; + icon: "bolt" | "sparkle"; + }; sessions?: Array>; }; 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; diff --git a/tests/browser/workbench-visual.e2e.ts b/tests/browser/workbench-visual.e2e.ts index 5e802e1..8a37a0c 100644 --- a/tests/browser/workbench-visual.e2e.ts +++ b/tests/browser/workbench-visual.e2e.ts @@ -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({ diff --git a/vite.config.ts b/vite.config.ts index aca7172..86fce2d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,9 @@ const RUNTIME_CONFIG_PATH = "/runtime-config.js"; function renderRuntimeConfig(values: Record) { 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",
+ {error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"} +