diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec1c42c4..6b7c0784 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -173,14 +173,21 @@ function loadView(): CreateView { } import { TraceDrawer } from "./ui/TraceDrawer"; import { LoginPage } from "./ui/LoginPage"; +import { AuthExpiredDialog } from "./ui/AuthExpiredDialog"; import { Markdown } from "./ui/Markdown"; import { clearLocalUser, logout, + openLoginWindow, resolveIdentity, setLocalUser, type AuthStatus, } from "./adk/identity"; +import { + AUTHENTICATION_REQUIRED_EVENT, + authenticationRestored, + isAuthenticationPending, +} from "./adk/authSession"; import type { A2uiAction, A2uiComponent } from "./a2ui/types"; import { buildSurfaces } from "./a2ui/Surface"; @@ -671,6 +678,10 @@ export default function App() { const closeAgentInfo = useCallback(() => setAgentInfoOpen(false), []); const [greeting, setGreeting] = useState(pickGreeting); const [authStatus, setAuthStatus] = useState(null); + const [authExpired, setAuthExpired] = useState(false); + const [authRecoveryChecking, setAuthRecoveryChecking] = useState(false); + const [authRecoveryError, setAuthRecoveryError] = useState(""); + const authRecoveryActiveRef = useRef(false); const [authError, setAuthError] = useState(null); const [userId, setUserId] = useState(""); const [userInfo, setUserInfo] = useState | undefined>(); @@ -1008,6 +1019,60 @@ export default function App() { resolveAuth(); }, [resolveAuth]); + useEffect(() => { + const showExpiredDialog = () => { + setAuthRecoveryError(""); + setAuthExpired(true); + }; + window.addEventListener(AUTHENTICATION_REQUIRED_EVENT, showExpiredDialog); + if (isAuthenticationPending()) showExpiredDialog(); + return () => + window.removeEventListener( + AUTHENTICATION_REQUIRED_EVENT, + showExpiredDialog, + ); + }, []); + + const recoverAuthentication = useCallback(async () => { + if (authRecoveryActiveRef.current) return; + authRecoveryActiveRef.current = true; + const loginWindow = openLoginWindow(); + if (!loginWindow) { + authRecoveryActiveRef.current = false; + setAuthRecoveryError("登录窗口被浏览器拦截,请允许弹出窗口后重试。"); + return; + } + setAuthRecoveryChecking(true); + setAuthRecoveryError(""); + try { + while (true) { + await new Promise((resolve) => window.setTimeout(resolve, 1000)); + try { + const identity = await resolveIdentity(); + if (identity.status === "authenticated") { + setUserId(identity.userId); + setUserInfo(identity.info); + setLocalMode(!!identity.local); + setAuthStatus(identity.status); + setAuthExpired(false); + authenticationRestored(); + loginWindow.close(); + return; + } + } catch { + // The gateway may return its login page until the popup completes. + } + if (loginWindow.closed) { + setAuthRecoveryError("登录窗口已关闭,请重新登录以继续当前操作。"); + return; + } + } + } finally { + authRecoveryActiveRef.current = false; + setAuthRecoveryChecking(false); + } + }, []); + useEffect(() => { if (localMode && userId) setLocalUser(userId); }, [localMode, userId]); @@ -2724,6 +2789,13 @@ export default function App() { onConfirm={() => void launchSandboxSession()} /> + void recoverAuthentication()} + /> + {confirmLeave && (
setConfirmLeave(false)}>
e.stopPropagation()}> diff --git a/frontend/src/adk/authSession.ts b/frontend/src/adk/authSession.ts new file mode 100644 index 00000000..a262bebf --- /dev/null +++ b/frontend/src/adk/authSession.ts @@ -0,0 +1,56 @@ +export const AUTHENTICATION_REQUIRED_EVENT = "veadk:authentication-required"; + +let pendingAuthentication: Promise | null = null; +let resolveAuthentication: (() => void) | null = null; + +export function isAuthenticationRedirect(response: Response): boolean { + if (!response.redirected || !response.url) return false; + try { + const url = new URL(response.url); + return ( + url.pathname.includes("/authorize") || + url.pathname.includes("/oauth2/login") || + url.hostname.includes(".userpool.auth.") + ); + } catch { + return false; + } +} + +export function waitForAuthentication(signal?: AbortSignal | null): Promise { + if (!pendingAuthentication) { + pendingAuthentication = new Promise((resolve) => { + resolveAuthentication = resolve; + }); + window.dispatchEvent(new Event(AUTHENTICATION_REQUIRED_EVENT)); + } + const authentication = pendingAuthentication; + if (!signal) return authentication; + if (signal.aborted) { + return Promise.reject(signal.reason ?? new Error("Request aborted")); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason ?? new Error("Request aborted")); + signal.addEventListener("abort", onAbort, { once: true }); + authentication.then( + () => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + +export function isAuthenticationPending(): boolean { + return pendingAuthentication !== null; +} + +export function authenticationRestored(): void { + resolveAuthentication?.(); + resolveAuthentication = null; + pendingAuthentication = null; +} diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 3e75afad..f1037611 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -3,6 +3,12 @@ // the Vite dev proxy in development. import { withAuth } from "./auth"; +import { isOAuthLoginRequired, withLocalUser } from "./identity"; +import { + isAuthenticationRedirect, + waitForAuthentication, +} from "./authSession"; +import { parseJsonResponse } from "./jsonResponse"; import { formatRunSseError } from "./runSseError"; import { parseSSE } from "./sse"; import { @@ -12,7 +18,6 @@ import { } from "./timeout"; import type { AgentProject } from "../create/project"; import type { AgentDraft } from "../create/types"; -import { withLocalUser } from "./identity"; /** An ADK event as serialised over `/run_sse` (camelCase, by_alias=True). */ export interface AdkUsage { @@ -226,29 +231,59 @@ function resolve(appName: string): { app: string; ep: AdkEndpoint } { * the apikey; apikey never reaches the browser). * 2. `base` + `apiKey` → backend `/agentkit-proxy` (legacy, key in header). * 3. neither → the local same-origin server. */ -function apiFetch( +async function apiFetch( path: string, init: RequestInit = {}, ep: AdkEndpoint = {}, timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS, ): Promise { - const opts = { + const baseOpts = { ...init, headers: withLocalUser(init.headers), - signal: requestSignal(init.signal, timeoutMs), }; - if (ep.runtimeId) { - const rq = ep.region ? `${path.includes("?") ? "&" : "?"}region=${encodeURIComponent(ep.region)}` : ""; - return fetch(withAuth(`${API_BASE}/web/runtime-proxy/${ep.runtimeId}${path}${rq}`), opts); - } - if (ep.base) { - // Use backend proxy to avoid CORS issues with remote AgentKit - const headers = new Headers(opts.headers); - headers.set("X-AgentKit-Base", ep.base); - if (ep.apiKey) headers.set("X-AgentKit-Key", ep.apiKey); - return fetch(withAuth(`${API_BASE}/agentkit-proxy${path}`), { ...opts, headers }); + const send = () => { + const opts = { + ...baseOpts, + signal: requestSignal(init.signal, timeoutMs), + }; + if (ep.runtimeId) { + const rq = ep.region + ? `${path.includes("?") ? "&" : "?"}region=${encodeURIComponent(ep.region)}` + : ""; + return fetch( + withAuth(`${API_BASE}/web/runtime-proxy/${ep.runtimeId}${path}${rq}`), + opts, + ); + } + if (ep.base) { + // Use backend proxy to avoid CORS issues with remote AgentKit + const headers = new Headers(opts.headers); + headers.set("X-AgentKit-Base", ep.base); + if (ep.apiKey) headers.set("X-AgentKit-Key", ep.apiKey); + return fetch(withAuth(`${API_BASE}/agentkit-proxy${path}`), { + ...opts, + headers, + }); + } + return fetch(withAuth(`${API_BASE}${path}`), opts); + }; + + const requiresLogin = async (response: Response) => { + if (isAuthenticationRedirect(response)) return true; + if (response.status !== 401) return false; + try { + return await isOAuthLoginRequired(); + } catch { + return false; + } + }; + + let response = await send(); + while (await requiresLogin(response)) { + await waitForAuthentication(init.signal); + response = await send(); } - return fetch(withAuth(`${API_BASE}${path}`), opts); + return response; } function formatErrorDetail(detail: unknown): string { @@ -1566,7 +1601,7 @@ export async function createGeneratedAgentTestRun( if (!res.ok) { throw new Error(await httpErrorMessage(res, "创建调试运行失败")); } - return res.json(); + return parseJsonResponse(res, "创建调试运行失败"); } export async function createGeneratedAgentTestSession( @@ -1581,7 +1616,7 @@ export async function createGeneratedAgentTestSession( if (!res.ok) { throw new Error(await httpErrorMessage(res, "创建调试会话失败")); } - const session = await res.json(); + const session = await parseJsonResponse<{ id: string }>(res, "创建调试会话失败"); return session.id; } diff --git a/frontend/src/adk/identity.ts b/frontend/src/adk/identity.ts index 479d5faa..91539964 100644 --- a/frontend/src/adk/identity.ts +++ b/frontend/src/adk/identity.ts @@ -122,6 +122,37 @@ export function login(): void { loginTo("/oauth2/login"); } +/** Open the login flow without unloading the current editing context. */ +export function openLoginWindow(): Window | null { + const here = window.location.pathname + window.location.search + window.location.hash; + const loginWindow = window.open( + "about:blank", + "_blank", + "popup,width=520,height=720", + ); + if (!loginWindow) return null; + try { + loginWindow.opener = null; + loginWindow.location.replace( + `/oauth2/login?redirect=${encodeURIComponent(here)}`, + ); + } catch { + loginWindow.close(); + return null; + } + return loginWindow; +} + +/** Confirm that a 401 came from an expired built-in OAuth session rather than + * an upstream runtime or another API-specific authorization check. */ +export async function isOAuthLoginRequired(): Promise { + const [identity, providers] = await Promise.all([ + resolveIdentity(), + fetchProviders(), + ]); + return identity.status === "unauthenticated" && providers.length > 0; +} + export function logout(): void { window.location.assign("/oauth2/logout"); } diff --git a/frontend/src/adk/jsonResponse.ts b/frontend/src/adk/jsonResponse.ts new file mode 100644 index 00000000..16f628fe --- /dev/null +++ b/frontend/src/adk/jsonResponse.ts @@ -0,0 +1,19 @@ +/** Parse a successful JSON API response without hiding HTML gateway errors. */ +export async function parseJsonResponse( + response: Response, + fallback: string, +): Promise { + const text = await response.text().catch(() => ""); + try { + return JSON.parse(text) as T; + } catch { + const contentType = + response.headers.get("content-type")?.split(";", 1)[0] || + "Content-Type 缺失"; + const excerpt = text.trim().slice(0, 2000); + const detail = excerpt ? `\n响应:${excerpt}` : ""; + throw new Error( + `${fallback}:服务端返回非 JSON 响应(HTTP ${response.status},${contentType})${detail}`, + ); + } +} diff --git a/frontend/src/ui/AuthExpiredDialog.css b/frontend/src/ui/AuthExpiredDialog.css new file mode 100644 index 00000000..aac9fd82 --- /dev/null +++ b/frontend/src/ui/AuthExpiredDialog.css @@ -0,0 +1,119 @@ +.auth-expired-backdrop { + position: fixed; + inset: 0; + z-index: 140; + display: grid; + place-items: center; + padding: 20px; + background: hsl(var(--foreground) / 0.22); + backdrop-filter: blur(5px) saturate(0.88); + -webkit-backdrop-filter: blur(5px) saturate(0.88); +} + +.auth-expired-dialog { + position: relative; + width: min(400px, calc(100vw - 40px)); + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: 16px; + background: hsl(var(--panel)); + box-shadow: + 0 28px 80px hsl(var(--foreground) / 0.2), + 0 2px 8px hsl(var(--foreground) / 0.06); + animation: auth-expired-enter 180ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.auth-expired-mark { + display: grid; + width: 32px; + height: 32px; + margin: 32px auto 0; + place-items: center; + color: hsl(var(--foreground)); +} + +.auth-expired-mark svg { + width: 21px; + height: 21px; + stroke-width: 1.8; +} + +.auth-expired-copy { + padding: 22px 32px 28px; + text-align: center; +} + +.auth-expired-copy h2 { + margin: 0; + color: hsl(var(--foreground)); + font-size: 19px; + font-weight: 650; + letter-spacing: -0.01em; +} + +.auth-expired-copy > p:last-child { + margin: 11px 0 0; + color: hsl(var(--muted-foreground)); + font-size: 13px; + line-height: 1.7; +} + +.auth-expired-copy .auth-expired-error { + margin-top: 10px; + color: hsl(var(--destructive)); +} + +.auth-expired-actions { + padding: 0 16px 16px; +} + +.auth-expired-actions button { + width: 100%; + height: 38px; + border: 1px solid hsl(var(--foreground)); + border-radius: 9px; + background: hsl(var(--foreground)); + color: hsl(var(--background)); + font: inherit; + font-size: 13px; + font-weight: 650; + cursor: pointer; + transition: + transform 120ms ease, + opacity 120ms ease; +} + +.auth-expired-actions button:hover { + opacity: 0.88; +} + +.auth-expired-actions button:active { + transform: translateY(1px); +} + +.auth-expired-actions button:focus-visible { + outline: 3px solid hsl(var(--ring) / 0.28); + outline-offset: 2px; +} + +.auth-expired-actions button:disabled { + cursor: wait; + opacity: 0.58; +} + +@keyframes auth-expired-enter { + from { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .auth-expired-dialog { + animation: none; + } +} diff --git a/frontend/src/ui/AuthExpiredDialog.tsx b/frontend/src/ui/AuthExpiredDialog.tsx new file mode 100644 index 00000000..86c9c1bb --- /dev/null +++ b/frontend/src/ui/AuthExpiredDialog.tsx @@ -0,0 +1,70 @@ +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { CircleAlert } from "lucide-react"; +import "./AuthExpiredDialog.css"; + +interface AuthExpiredDialogProps { + open: boolean; + checking: boolean; + error?: string; + onLogin: () => void; +} + +export function AuthExpiredDialog({ + open, + checking, + error, + onLogin, +}: AuthExpiredDialogProps) { + const loginButtonRef = useRef(null); + + useEffect(() => { + if (!open) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + loginButtonRef.current?.focus(); + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [open]); + + if (!open) return null; + + return createPortal( +
+
+ +
+

登录状态已过期

+

+ 当前编辑内容会保留。重新登录后,刚才的操作将自动继续。 +

+ {error && ( +

+ {error} +

+ )} +
+
+ +
+
+
, + document.body, + ); +} diff --git a/frontend/tests/authExpiredDialog.test.mjs b/frontend/tests/authExpiredDialog.test.mjs new file mode 100644 index 00000000..4f18e120 --- /dev/null +++ b/frontend/tests/authExpiredDialog.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const appSource = readFileSync( + new URL("../src/App.tsx", import.meta.url), + "utf8", +); +const clientSource = readFileSync( + new URL("../src/adk/client.ts", import.meta.url), + "utf8", +); +const dialogSource = readFileSync( + new URL("../src/ui/AuthExpiredDialog.tsx", import.meta.url), + "utf8", +); + +test("the global authentication event opens a blocking relogin dialog", () => { + assert.match(appSource, /setAuthExpired\(true\)/); + assert.match(appSource, /authenticationRestored\(\)/); + assert.match(appSource, /isAuthenticationPending\(\)/); + assert.match(appSource, /openLoginWindow\(\)/); + assert.match(dialogSource, /role="alertdialog"/); + assert.match(dialogSource, /aria-modal="true"/); + assert.match(dialogSource, /重新登录/); + assert.match(dialogSource, /当前编辑内容会保留/); +}); + +test("API requests recover only after a confirmed authentication failure", () => { + assert.match(clientSource, /isAuthenticationRedirect\(response\)/); + assert.match(clientSource, /response\.status !== 401/); + assert.match(clientSource, /isOAuthLoginRequired\(\)/); + assert.match(clientSource, /waitForAuthentication\(init\.signal\)/); +}); diff --git a/frontend/tests/authSession.test.mjs b/frontend/tests/authSession.test.mjs new file mode 100644 index 00000000..00425d1c --- /dev/null +++ b/frontend/tests/authSession.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; + +const source = readFileSync( + new URL("../src/adk/authSession.ts", import.meta.url), + "utf8", +); +const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 }, +}); +const moduleUrl = `data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`; +const { + AUTHENTICATION_REQUIRED_EVENT, + authenticationRestored, + isAuthenticationRedirect, + isAuthenticationPending, + waitForAuthentication, +} = await import(moduleUrl); + +test("detects an API request redirected to the OAuth authorization page", () => { + assert.equal( + isAuthenticationRedirect({ + redirected: true, + url: "https://example.userpool.auth.example.com/authorize?client_id=test", + }), + true, + ); +}); + +test("does not classify an ordinary API response as an expired login", () => { + assert.equal( + isAuthenticationRedirect({ + redirected: false, + url: "https://studio.example.com/web/generated-agent-test-runs", + }), + false, + ); +}); + +test("notifies the app and releases waiting requests after authentication", async () => { + const previousWindow = globalThis.window; + const windowTarget = new EventTarget(); + globalThis.window = windowTarget; + let notified = false; + windowTarget.addEventListener(AUTHENTICATION_REQUIRED_EVENT, () => { + notified = true; + }); + + try { + const waiting = waitForAuthentication(); + assert.equal(notified, true); + assert.equal(isAuthenticationPending(), true); + authenticationRestored(); + await waiting; + assert.equal(isAuthenticationPending(), false); + } finally { + globalThis.window = previousWindow; + } +}); + +test("an aborted caller stops waiting without cancelling global recovery", async () => { + const previousWindow = globalThis.window; + globalThis.window = new EventTarget(); + const controller = new AbortController(); + + try { + const waiting = waitForAuthentication(controller.signal); + controller.abort(new Error("cancelled")); + await assert.rejects(waiting, /cancelled/); + assert.equal(isAuthenticationPending(), true); + authenticationRestored(); + assert.equal(isAuthenticationPending(), false); + } finally { + globalThis.window = previousWindow; + } +}); diff --git a/frontend/tests/debugJsonResponse.test.mjs b/frontend/tests/debugJsonResponse.test.mjs new file mode 100644 index 00000000..1422b935 --- /dev/null +++ b/frontend/tests/debugJsonResponse.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; + +const source = readFileSync( + new URL("../src/adk/jsonResponse.ts", import.meta.url), + "utf8", +); +const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 }, +}); +const moduleUrl = `data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`; +const { parseJsonResponse } = await import(moduleUrl); + +function response({ + body, + contentType = "application/json", + redirected = false, + status = 200, + url = "", +}) { + return { + headers: new Headers({ "content-type": contentType }), + redirected, + status, + text: async () => body, + url, + }; +} + +test("parses a normal JSON response", async () => { + const result = await parseJsonResponse( + response({ body: '{"runId":"run-1"}' }), + "创建调试运行失败", + ); + assert.deepEqual(result, { runId: "run-1" }); +}); + +test("exposes a non-JSON gateway response", async () => { + await assert.rejects( + parseJsonResponse( + response({ + body: "upstream unavailable", + contentType: "text/html; charset=utf-8", + }), + "创建调试运行失败", + ), + (error) => { + assert.match(error.message, /服务端返回非 JSON 响应/); + assert.match(error.message, /HTTP 200/); + assert.match(error.message, /text\/html/); + assert.match(error.message, /upstream unavailable/); + assert.doesNotMatch(error.message, /Unexpected token/); + return true; + }, + ); +}); diff --git a/frontend/tests/identity.test.mjs b/frontend/tests/identity.test.mjs index 6abf3509..8be27590 100644 --- a/frontend/tests/identity.test.mjs +++ b/frontend/tests/identity.test.mjs @@ -18,6 +18,8 @@ const identitySource = readFileSync( ).replace('from "./timeout"', `from "${timeoutUrl}"`); const { fetchProviders, + isOAuthLoginRequired, + openLoginWindow, profilePictureUrl, resolveIdentity, withLocalUser, @@ -27,6 +29,7 @@ const { const originalFetch = globalThis.fetch; const originalWarn = console.warn; +const originalWindow = globalThis.window; test.before(() => { console.warn = () => {}; }); @@ -34,6 +37,7 @@ test.afterEach(() => { globalThis.fetch = originalFetch; delete globalThis.localStorage; delete globalThis.sessionStorage; + globalThis.window = originalWindow; }); test.after(() => { console.warn = originalWarn; @@ -145,3 +149,36 @@ test("provider lookup surfaces failures instead of returning an empty list", asy await assert.rejects(fetchProviders(), /无法解析/); }); }); + +test("distinguishes an expired built-in OAuth session from an API-specific 401", async () => { + globalThis.fetch = async (url) => { + if (url === "/oauth2/userinfo") return new Response("", { status: 401 }); + return Response.json({ providers: [{ id: "oidc", loginUrl: "/oauth2/login" }] }); + }; + assert.equal(await isOAuthLoginRequired(), true); + + globalThis.fetch = async (url) => { + if (url === "/oauth2/userinfo") return Response.json({ sub: "u-1" }); + return Response.json({ providers: [{ id: "oidc", loginUrl: "/oauth2/login" }] }); + }; + assert.equal(await isOAuthLoginRequired(), false); +}); + +test("popup login preserves the editor without exposing its opener", () => { + let destination = ""; + const popup = { + opener: {}, + location: { replace: (url) => { destination = url; } }, + }; + globalThis.window = { + location: { pathname: "/create", search: "?step=debug", hash: "#agent" }, + open: () => popup, + }; + + assert.equal(openLoginWindow(), popup); + assert.equal(popup.opener, null); + assert.equal( + destination, + "/oauth2/login?redirect=%2Fcreate%3Fstep%3Ddebug%23agent", + ); +});