From c1a3888e2cbab4fee238d6451ea570a9a8b7e77e Mon Sep 17 00:00:00 2001 From: stevenjj33 <75509501+stevenjj33@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:43:32 +0800 Subject: [PATCH] feat(webui): device-authorization login, a sign-out that signs out, and account switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap N (登录与账号): the WebUI had no login at all — it served whatever OAuth credential 'mcode login' (or the desktop) had already written, the usage panel said 登录后查看用量 with no way to log in, and 退出登录 did not actually sign out: it ran the host's invalidateAuth, which clears projections, then the next lease refresh re-authenticated from the credential that was never removed. Signing in and switching accounts were both impossible without leaving the browser. Server — the assembly already builds one MCodeOAuthCore for quota leases; the account session now wraps it: * account-login.ts: one session per service answering begin/status/ cancel. begin starts (or re-attaches to — the core dedupes login() on its own promise) a device-authorization login and resolves with the prompt as soon as the core emits it; status folds the settled outcome, the in-flight prompt, and — with no attempt this process started — the persisted store, so a terminal-client login counts; cancel aborts without recording the core's cancellation rejection as a failure. * signOut now prefers signOutAccount (logout {revoke}) — revoke and remove the credential — falling back to the historical invalidation for older hosts. 切换账号 is the same surface from the other side: sign out, then begin a fresh device flow. * Operations beginAccountLogin / getAccountLoginStatus / cancelAccountLogin; oauth-core.d.ts shim grows the login surface. Client — AccountLoginDialog (portal dialog + pure AccountLoginPanel): the device code, the authorization link (complete URI when the server sends one), a 2s poll, cancel, expired retry, and an authenticated state whose 切换账号 runs the sign-out-then-begin sequence. Entries: the user menu's 账号与登录 item, and a 登录 button in the usage panel's signed-out block — whose mousedown no longer steals focus, because focusin re-triggered the usage anchor's onFocus → loadUsage, whose loading skeleton replaced the pressed node mid-click and the first real click silently never dispatched (reproduced with an event trace; the node that received mousedown was disconnected by mouseup). The browser fixture answers the three operations and gains setAccountLoginState / setUsageQuotaResult so the dialog's phases can be driven deterministically. Tests: account-login.test.ts (7 — prompt-on-begin, re-attach without a second login call, settled outcome, cancellation-is-not-failure, persisted-store status, phase derivation) and account-login.spec.mjs (4 browser cases — the full device flow with authorization completing, the switch path issuing sign-out before the new flow, the usage-panel entry, cancel). The port stubs in webui-service / host-shape-invariant grow the four members. --- .../client/components/AccountLoginDialog.tsx | 312 ++++++++++++++++++ .../src/client/components/SettingsModal.tsx | 3 + .../webui/src/client/components/UserMenu.tsx | 55 ++- packages/webui/src/client/contracts.ts | 6 + packages/webui/src/client/styles/shell.css | 17 + packages/webui/src/client/transport.ts | 3 + packages/webui/src/server/account-login.ts | 158 +++++++++ packages/webui/src/server/assembly.ts | 54 ++- packages/webui/src/server/host.ts | 31 ++ packages/webui/src/server/oauth-core.d.ts | 29 ++ packages/webui/src/server/operation/names.ts | 3 + .../server/operation/operation-handlers.ts | 14 +- .../webui/src/server/operation/operations.ts | 7 +- .../webui/src/server/operation/provider.ts | 37 ++- packages/webui/src/server/port.ts | 32 ++ packages/webui/src/server/service.ts | 4 + packages/webui/test-results/.last-run.json | 4 + .../webui/test/unit/account-login.test.ts | 192 +++++++++++ .../unit/webui-host-shape-invariant.test.ts | 13 + .../webui/test/unit/webui-service.test.ts | 42 +++ release/public-source.json | 5 + test/vitest-suites.json | 1 + test/webui-browser/account-login.spec.mjs | 106 ++++++ test/webui-browser/fixture.mjs | 32 +- 24 files changed, 1148 insertions(+), 12 deletions(-) create mode 100644 packages/webui/src/client/components/AccountLoginDialog.tsx create mode 100644 packages/webui/src/server/account-login.ts create mode 100644 packages/webui/test-results/.last-run.json create mode 100644 packages/webui/test/unit/account-login.test.ts create mode 100644 test/webui-browser/account-login.spec.mjs diff --git a/packages/webui/src/client/components/AccountLoginDialog.tsx b/packages/webui/src/client/components/AccountLoginDialog.tsx new file mode 100644 index 000000000..4efaae81f --- /dev/null +++ b/packages/webui/src/client/components/AccountLoginDialog.tsx @@ -0,0 +1,312 @@ +// AccountLoginDialog — the browser's device-authorization login. +// +// The WebUI previously had no login at all: it served whatever credential the +// terminal client had written, and 退出登录 did not remove it, so neither +// signing in nor switching accounts was possible without leaving the +// browser. This dialog is the missing surface for both rows of the roadmap's +// 登录与账号 group: +// +// * 登录方式 — the device-authorization flow the terminal client uses, +// rendered for the browser: a user code, the page to enter it on, and a +// poll until the server observes the credential. +// * 登出·账号切换 — the same dialog is the switch entry: already signed in, +// it offers 切换账号, which signs the current account out for real and +// starts a fresh device flow in one place. +// +// Split in two so the node suite can cover every phase without a DOM: the +// `AccountLoginPanel` is a pure view over an injected phase (the suite's +// `renderToStaticMarkup` convention), and the dialog owns the effects — +// start on open, poll while pending, stop on close. + +import { useEffect, useRef, useState, type ReactElement } from "react"; +import { createPortal } from "react-dom"; +import type { WebuiAccountLoginView } from "../../server/port.js"; + +/** Everything the panel can show, derived (not fetched). */ +export type WebuiAccountLoginPhase = + | { readonly kind: "loading" } + | { + readonly kind: "pending"; + readonly userCode?: string; + readonly verificationUri?: string; + readonly verificationUriComplete?: string; + } + | { readonly kind: "authenticated" } + | { readonly kind: "error"; readonly error: string }; + +/** + * Derive the view phase from a server answer. `idle` with no attempt means + * the dialog just started (or the host has no session); a `pending` answer + * without a prompt yet is the network being slower than the first reply — + * the panel says 等待设备码 rather than guessing a code. + */ +export function deriveWebuiAccountLoginPhase( + view: WebuiAccountLoginView | undefined, +): WebuiAccountLoginPhase { + if (!view) return { kind: "loading" }; + switch (view.state) { + case "authenticated": + return { kind: "authenticated" }; + case "error": + return { kind: "error", error: view.error }; + case "pending": + return { + kind: "pending", + userCode: view.prompt?.userCode, + verificationUri: view.prompt?.verificationUri, + verificationUriComplete: view.prompt?.verificationUriComplete, + }; + default: + return { kind: "loading" }; + } +} + +export function AccountLoginPanel({ + phase, + onRetry, + onCancel, + onSwitch, + onClose, +}: { + readonly phase: WebuiAccountLoginPhase; + /** Error → start a fresh device flow. */ + readonly onRetry: () => void; + /** Pending → cancel the in-flight attempt. */ + readonly onCancel: () => void; + /** Authenticated → sign out, then start a fresh device flow. */ + readonly onSwitch: () => void; + readonly onClose: () => void; +}): ReactElement { + return ( +
+
+ 账号登录 + +
+ {phase.kind === "loading" ? ( +

+ 正在获取登录码… +

+ ) : null} + {phase.kind === "pending" ? ( +
+ {phase.userCode ? ( + <> +

在打开的页面中输入以下代码

+

+ {phase.userCode} +

+ + ) : ( +

+ 等待设备码… +

+ )} + {phase.verificationUri ? ( + + 打开授权页面 + + ) : null} +

+ 等待授权中…完成页面上的登录后会自动继续 +

+
+ +
+
+ ) : null} + {phase.kind === "authenticated" ? ( +
+

+ 已登录。切换账号会先退出当前账号,再开始新的登录。 +

+
+ +
+
+ ) : null} + {phase.kind === "error" ? ( +
+

+ 登录未完成:{phase.error} +

+
+ +
+
+ ) : null} +
+ ); +} + +export function AccountLoginDialog({ + open, + onClose, + onAuthenticated, + beginAccountLogin, + getAccountLoginStatus, + cancelAccountLogin, + signOut, +}: { + readonly open: boolean; + readonly onClose: () => void; + /** Fires once per transition into the authenticated state, so the host can + * refresh whatever account surfaces it shows. */ + readonly onAuthenticated?: () => void; + readonly beginAccountLogin?: () => Promise; + readonly getAccountLoginStatus?: () => Promise; + readonly cancelAccountLogin?: () => Promise<{ readonly ok: true }>; + readonly signOut?: () => Promise<{ readonly success?: boolean }>; +}): ReactElement | null { + const [phase, setPhase] = useState({ kind: "loading" }); + const authenticatedNotifiedRef = useRef(false); + + useEffect(() => { + if (!open) return; + // Reset per open: a closed dialog forgets the previous attempt's state, + // and the authenticated notification must fire again next time. + authenticatedNotifiedRef.current = false; + let cancelled = false; + setPhase({ kind: "loading" }); + const start = () => { + if (!beginAccountLogin) { + setPhase({ kind: "error", error: "当前服务未提供账号登录" }); + return; + } + void beginAccountLogin() + .then((view) => { + if (!cancelled) setPhase(deriveWebuiAccountLoginPhase(view)); + }) + .catch((error: unknown) => { + if (!cancelled) + setPhase({ + kind: "error", + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + start(); + // Poll while the dialog is open: the credential lands on the server, and + // only the server can see the authorization complete. + const timer = window.setInterval(() => { + if (!getAccountLoginStatus) return; + void getAccountLoginStatus() + .then((view) => { + if (cancelled) return; + const next = deriveWebuiAccountLoginPhase(view); + // The first reply may still be `loading` while the attempt starts; + // never walk a live phase back to loading — only forward matters. + setPhase((current) => + next.kind === "loading" && current.kind !== "loading" ? current : next, + ); + if (next.kind === "authenticated" && !authenticatedNotifiedRef.current) { + authenticatedNotifiedRef.current = true; + onAuthenticated?.(); + } + }) + .catch(() => undefined); + }, 2_000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + // `phase` is deliberately not a dependency: the poll closure reading it + // would restart the interval on every poll's own setState. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, beginAccountLogin, getAccountLoginStatus]); + + if (!open) return null; + const dialog = ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > + { + setPhase({ kind: "loading" }); + void beginAccountLogin?.() + .then((view) => setPhase(deriveWebuiAccountLoginPhase(view))) + .catch((error: unknown) => + setPhase({ + kind: "error", + error: error instanceof Error ? error.message : String(error), + }), + ); + }} + onCancel={() => { + void cancelAccountLogin?.().catch(() => undefined); + onClose(); + }} + onSwitch={() => { + // The real switch: sign out (revoke + wipe) and immediately start + // a fresh device flow, in the same surface. + setPhase({ kind: "loading" }); + void signOut?.() + .then(() => beginAccountLogin?.()) + .then((view) => { + if (view) setPhase(deriveWebuiAccountLoginPhase(view)); + }) + .catch((error: unknown) => + setPhase({ + kind: "error", + error: error instanceof Error ? error.message : String(error), + }), + ); + }} + onClose={onClose} + /> +
+ ); + return typeof document !== "undefined" + ? createPortal(dialog, document.body) + : dialog; +} diff --git a/packages/webui/src/client/components/SettingsModal.tsx b/packages/webui/src/client/components/SettingsModal.tsx index 3377d53af..e3c2102b8 100644 --- a/packages/webui/src/client/components/SettingsModal.tsx +++ b/packages/webui/src/client/components/SettingsModal.tsx @@ -68,6 +68,9 @@ export type WebuiSettingsModalCapabilities = Pick< | "selectModel" | "getUsageQuota" | "getAccountStatus" + | "beginAccountLogin" + | "getAccountLoginStatus" + | "cancelAccountLogin" | "listUserModelProviders" | "listArchivedSessions" | "archiveSession" diff --git a/packages/webui/src/client/components/UserMenu.tsx b/packages/webui/src/client/components/UserMenu.tsx index 1739cd07d..54b4807b6 100644 --- a/packages/webui/src/client/components/UserMenu.tsx +++ b/packages/webui/src/client/components/UserMenu.tsx @@ -18,6 +18,7 @@ import type { } from "../../server/port.js"; import type { WebuiTransport } from "../contracts.js"; import { SettingsModal, type WebuiSettingsModalCapabilities } from "./SettingsModal.js"; +import { AccountLoginDialog } from "./AccountLoginDialog.js"; import { evaluateOutsideClose } from "../projection/outside-close.js"; type AccountStatus = Record; @@ -581,7 +582,13 @@ function videoValueLabel(video: WebuiUsageQuotaVideoView): string { const AUTH_ERROR_PATTERN = /cookie|unauthori[sz]ed|token|login/iu; -function UsageReady({ result }: { readonly result?: WebuiUsageQuotaResult }): ReactElement { +function UsageReady({ + result, + onLogin, +}: { + readonly result?: WebuiUsageQuotaResult; + readonly onLogin?: () => void; +}): ReactElement { if (!result || result.signedIn === false) { return (
登录后查看用量 + {/* The button's mousedown is default-prevented: focus would bubble + * (focusin) to the usage anchor's onFocus → loadUsage, whose loading + * state swaps this block for the skeleton — the pressed node is + * replaced mid-click and the browser never dispatches the click. + * Keeping the focus where it was makes the first click land like + * every later one. */} + {onLogin ? ( + + ) : null}
); } @@ -660,9 +684,12 @@ function UsageReady({ result }: { readonly result?: WebuiUsageQuotaResult }): Re export function UsagePanel({ state, onRetry, + onLogin, }: { readonly state: UsageState; readonly onRetry: () => void; + /** Opens the account login dialog from the signed-out usage block. */ + readonly onLogin?: () => void; }): ReactElement { return (
@@ -680,7 +707,7 @@ export function UsagePanel({
) : null} - {state.status === "ready" ? : null} + {state.status === "ready" ? : null} ); } @@ -697,13 +724,21 @@ export function UserMenu({ getSigninPanel, claimSignin, }: UserMenuProps): ReactElement { - const { getUsageQuota, getAccountStatus } = useMemo(() => ({ + // Bound once per transport, not per render: the login dialog's effects + // key on these callbacks, and a fresh binding every render would restart + // its poll (and its begin) on every unrelated re-render. + const { getUsageQuota, getAccountStatus, beginAccountLogin, getAccountLoginStatus, cancelAccountLogin, signOut } = useMemo(() => ({ getUsageQuota: transport?.getUsageQuota?.bind(transport), getAccountStatus: transport?.getAccountStatus?.bind(transport), + beginAccountLogin: transport?.beginAccountLogin?.bind(transport), + getAccountLoginStatus: transport?.getAccountLoginStatus?.bind(transport), + cancelAccountLogin: transport?.cancelAccountLogin?.bind(transport), + signOut: transport?.signOut?.bind(transport), }), [transport]); const anchorRef = useRef(null); const [open, setOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [loginOpen, setLoginOpen] = useState(false); const [usageOpen, setUsageOpen] = useState(false); const [usage, setUsage] = useState({ status: "idle" }); const [signinOpen, setSigninOpen] = useState(false); @@ -840,6 +875,7 @@ export function UserMenu({
UID : {uid ?? "—"}
+
loadSignin()} onFocus={() => loadSignin()} onClick={(event) => { event.stopPropagation(); if (!signinOpen) loadSignin(); }}> @@ -863,11 +899,22 @@ export function UserMenu({
loadUsage()} onFocus={() => loadUsage()}> - {usageOpen ? loadUsage(true)} /> : null} + {usageOpen ? loadUsage(true)} onLogin={() => { setOpen(false); setLoginOpen(true); }} /> : null}
: null} + setLoginOpen(false)} + onAuthenticated={() => { + loadUsage(true); + }} + beginAccountLogin={beginAccountLogin} + getAccountLoginStatus={getAccountLoginStatus} + cancelAccountLogin={cancelAccountLogin} + signOut={signOut} + /> {typeof document !== "undefined" ? createPortal( setSettingsOpen(false)} dataDir={dataDir} version={version} sessionId={sessionId} workspaceDir={workspaceDir} onOpenFileLine={onOpenFileLine} transport={transport} />, document.body) : setSettingsOpen(false)} dataDir={dataDir} version={version} sessionId={sessionId} workspaceDir={workspaceDir} onOpenFileLine={onOpenFileLine} transport={transport} />} ; } diff --git a/packages/webui/src/client/contracts.ts b/packages/webui/src/client/contracts.ts index a8e11593b..b9cfd4ef2 100644 --- a/packages/webui/src/client/contracts.ts +++ b/packages/webui/src/client/contracts.ts @@ -109,6 +109,7 @@ import type { WebuiUpdateSessionResult, WebuiUsageQuotaResult, WebuiVersionInfo, + WebuiAccountLoginView, WebuiAttachmentInput, WebuiWorkspaceEnvironment, WebuiWorkspaceDirectoryListing, @@ -650,6 +651,11 @@ export interface WebuiTransport { }) => Promise; readonly getSigninPanel?: () => Promise; readonly claimSignin?: () => Promise; + /** Account login (device authorization) and its polling/cancel pair. + * Optional like every capability: an un-wired host shows no login entry. */ + readonly beginAccountLogin?: () => Promise; + readonly getAccountLoginStatus?: () => Promise; + readonly cancelAccountLogin?: () => Promise<{ readonly ok: true }>; readonly getAccountStatus?: (request?: { readonly sessionId?: string; }) => Promise>; diff --git a/packages/webui/src/client/styles/shell.css b/packages/webui/src/client/styles/shell.css index bdd19dfe9..c1e7119b5 100644 --- a/packages/webui/src/client/styles/shell.css +++ b/packages/webui/src/client/styles/shell.css @@ -219,6 +219,19 @@ .webui-xterm-host .xterm-viewport { background: var(--bg_default_secondary) !important; } .webui-terminal-actions { display: flex; gap: var(--spacing_8); } .webui-settings-mask { position: fixed; inset: 0; z-index: 100; display: flex; background: var(--utility_scrim); animation: webui-overlay-enter 160ms ease-out both; } + /* The account login dialog: the settings mask's overlay treatment around a + * small centered card — a dialog, not a full page, because the entry points + * (user menu, usage panel) are popovers over the live shell. */ + .webui-account-login-mask { position: fixed; inset: 0; z-index: 110; display: flex; align-items: center; justify-content: center; background: var(--utility_scrim); animation: webui-overlay-enter 160ms ease-out both; } + .webui-account-login { display: flex; width: min(420px, calc(100vw - 48px)); flex-direction: column; gap: 12px; padding: 20px; border-radius: var(--radius_12); background: var(--bg_default_secondary_elevated); color: var(--text_default_primary); animation: webui-modal-enter 200ms cubic-bezier(.2, .7, .2, 1) both; } + .webui-account-login-header { display: flex; align-items: center; justify-content: space-between; } + .webui-account-login-header button { border: 0; background: transparent; color: var(--text_default_secondary); font-size: 18px; cursor: pointer; } + .webui-account-login-body { display: flex; flex-direction: column; gap: 10px; align-items: center; text-align: center; } + .webui-account-login-hint { margin: 0; color: var(--text_default_secondary); } + .webui-account-login-code { margin: 0; font-size: 28px; font-weight: 600; letter-spacing: .12em; font-variant-numeric: tabular-nums; } + .webui-account-login-uri { color: var(--text_default_primary); } + .webui-account-login-status { margin: 0; color: var(--text_default_tertiary); } + .webui-account-login-actions { display: flex; gap: 8px; justify-content: center; } .webui-settings-modal { display: flex; width: 100vw; height: 100vh; overflow: hidden; background: var(--bg_default_primary); color: var(--text_default_primary); animation: webui-modal-enter 200ms cubic-bezier(.2, .7, .2, 1) both; } .webui-settings-sidebar { width: 260px; flex: 0 0 260px; overflow-y: hidden; } .webui-settings-back, .webui-settings-nav-item { display: flex; width: 100%; align-items: center; gap: var(--spacing_8); border: 0; background: transparent; color: var(--text_default_secondary); text-align: left; } @@ -3495,6 +3508,10 @@ } .webui-user-menu-usage-signed-out { + /* Flex so the 登录 button sits beside the copy, not under it. */ + display: flex; + align-items: center; + gap: 8px; margin: 0; color: var(--text_default_secondary); font-size: 13px; diff --git a/packages/webui/src/client/transport.ts b/packages/webui/src/client/transport.ts index 5accf8a85..87aa9e9d0 100644 --- a/packages/webui/src/client/transport.ts +++ b/packages/webui/src/client/transport.ts @@ -479,6 +479,9 @@ export function createWebuiTransport({ triggerScheduledTaskNow: (body) => request("triggerScheduledTaskNow", body), getScheduledTaskCapability: () => request("getScheduledTaskCapability", {}), signOut: () => request("signOut", {}), + beginAccountLogin: () => request("beginAccountLogin", {}), + getAccountLoginStatus: () => request("getAccountLoginStatus", {}), + cancelAccountLogin: () => request("cancelAccountLogin", {}), runCommand: (body) => request("runCommand", body), } satisfies Required; } diff --git a/packages/webui/src/server/account-login.ts b/packages/webui/src/server/account-login.ts new file mode 100644 index 000000000..0392c2718 --- /dev/null +++ b/packages/webui/src/server/account-login.ts @@ -0,0 +1,158 @@ +// Account login session — the WebUI's device-authorization login, owned by +// the server because the credential store lives there. +// +// The WebUI previously had no login at all: it served whatever OAuth +// credential `mcode login` (or the desktop) had already written, and its +// 退出登录 only cleared projections — the credential survived and the next +// lease refresh signed the process right back in. Signing in and switching +// accounts were therefore both impossible without leaving the browser. +// +// This module wraps one `MCodeOAuthCore` (the assembly already builds it for +// quota leases) with the request/reply shape the browser needs: +// +// * `begin()` starts (or re-attaches to) a device-authorization login and +// resolves with the prompt — the user code and verification URL the user +// opens — without waiting for the human to authorize. The core already +// dedupes concurrent `login()` calls on its own `loginPromise`, so a +// second `begin()` while one is running re-attaches instead of racing. +// * `status()` folds three sources into one answer: the settled outcome of +// the last login attempt, the in-flight prompt, and — with no attempt +// this process started — the core's persisted state (a TUI login counts). +// * `cancel()` aborts an in-flight attempt; the credential is untouched. +// +// The prompt itself arrives on the core's `onDeviceAuthorization` callback, +// one HTTP round trip into the login. `begin()` waits briefly for it so the +// first reply usually carries the code; if the network is slow the caller +// sees `pending` without a prompt and the next `status()` poll picks it up. + +import type { + AuthStatusSnapshot, + DeviceAuthorizationPrompt, +} from "@mavis/oauth-core"; + +/** The minimal core surface this session needs — the assembly passes its + * live `MCodeOAuthCore`; tests pass a fake. */ +export interface WebuiAccountLoginCore { + login(options: { + onDeviceAuthorization?: (authorization: DeviceAuthorizationPrompt) => void; + }): Promise<{ readonly status: "authenticated"; readonly generation: number }>; + cancelLogin(): Promise; + getStatus(): Promise; +} + +export interface WebuiAccountLoginPrompt { + readonly userCode: string; + readonly verificationUri: string; + readonly verificationUriComplete?: string; + readonly expiresAtMs: number; +} + +export type WebuiAccountLoginState = + | { readonly state: "idle" } + | { + readonly state: "pending"; + readonly prompt?: WebuiAccountLoginPrompt; + } + | { readonly state: "authenticated" } + | { readonly state: "error"; readonly error: string }; + +const PROMPT_CAPTURE_TIMEOUT_MS = 5_000; + +export interface WebuiAccountLoginSession { + begin(): Promise; + status(): Promise; + cancel(): Promise; +} + +export function createWebuiAccountLoginSession( + core: WebuiAccountLoginCore, + now: () => number = Date.now, +): WebuiAccountLoginSession { + let prompt: WebuiAccountLoginPrompt | undefined; + let running = false; + let cancelled = false; + let outcome: WebuiAccountLoginState | undefined; + + const start = (): Promise => { + running = true; + cancelled = false; + prompt = undefined; + outcome = undefined; + // Resolves on the first prompt, or early if the attempt settles (or + // fails) before the device code even arrived — the caller then sees the + // same state the next `status()` poll would have reported. + return new Promise((resolve) => { + let settled = false; + const settle = (state: WebuiAccountLoginState) => { + if (settled) return; + settled = true; + resolve(state); + }; + const timer = setTimeout( + () => settle({ state: "pending" }), + PROMPT_CAPTURE_TIMEOUT_MS, + ); + void core + .login({ + onDeviceAuthorization: (authorization) => { + prompt = { + userCode: authorization.userCode, + verificationUri: authorization.verificationUri, + ...(authorization.verificationUriComplete + ? { verificationUriComplete: authorization.verificationUriComplete } + : {}), + expiresAtMs: now() + authorization.expiresInSec * 1_000, + }; + clearTimeout(timer); + settle({ state: "pending", prompt }); + }, + }) + .then(() => { + outcome = { state: "authenticated" }; + clearTimeout(timer); + settle(outcome); + }) + .catch((error: unknown) => { + // A cancelled attempt is the user's own action, not a failure to + // show; the settled state goes back to whatever the store says. + outcome = cancelled + ? undefined + : { + state: "error", + error: error instanceof Error ? error.message : String(error), + }; + clearTimeout(timer); + settle(outcome ?? { state: "idle" }); + }) + .finally(() => { + running = false; + }); + }); + }; + + return { + begin: () => (running ? Promise.resolve({ state: "pending" as const, prompt }) : start()), + status: async () => { + if (outcome) return outcome; + if (running) return { state: "pending", prompt }; + // No attempt this process started: the persisted state is the truth, + // and a login made in the terminal client counts here too. + try { + const snapshot = await core.getStatus(); + return snapshot.status === "authenticated" + ? { state: "authenticated" } + : { state: "idle" }; + } catch { + return { state: "idle" }; + } + }, + cancel: async () => { + if (!running) return; + cancelled = true; + await core.cancelLogin().catch(() => undefined); + running = false; + prompt = undefined; + outcome = undefined; + }, + }; +} diff --git a/packages/webui/src/server/assembly.ts b/packages/webui/src/server/assembly.ts index 29e331a5a..53fb0ae4a 100644 --- a/packages/webui/src/server/assembly.ts +++ b/packages/webui/src/server/assembly.ts @@ -36,6 +36,7 @@ import { createWebuiAuthContextReader, type WebuiAuthContext, } from "./auth-context.js"; +import { createWebuiAccountLoginSession } from "./account-login.js"; import { createHarnessPortFromHost } from "./host.js"; import type { WebuiRuntimeCliService } from "./host.js"; import type { WebuiHarnessPort } from "./port.js"; @@ -66,6 +67,16 @@ export interface WebuiAssembledHost { }) => Promise; readonly getSigninPanel: () => Promise; readonly claimSignin: () => Promise; + /** + * Account login (device authorization) over the same `MCodeOAuthCore` the + * quota lease reads, plus the sign-out that removes the credential. The + * core is assembly-owned (never produced by the runtime factory), so these + * are enrichment slots like `getUsageQuota`, not harness capabilities. + */ + readonly beginAccountLogin: () => Promise; + readonly getAccountLoginStatus: () => Promise; + readonly cancelAccountLogin: () => Promise; + readonly signOutAccount: () => Promise<{ readonly status: string; readonly generation: number }>; /** * Conversation compaction is opt-in on the live harness: `local-runtime-v2` * exposes `CliService.requestCompaction?` and the WebUI host surface @@ -155,7 +166,14 @@ export type WebuiRuntimeHostFactory = ( ) => Promise< Omit< WebuiAssembledHost, - "invalidateAuth" | "getUsageQuota" | "getSigninPanel" | "claimSignin" + | "invalidateAuth" + | "getUsageQuota" + | "getSigninPanel" + | "claimSignin" + | "beginAccountLogin" + | "getAccountLoginStatus" + | "cancelAccountLogin" + | "signOutAccount" > >; @@ -394,7 +412,14 @@ export async function createWebuiRuntimeHost( // because the typecheck can't see the upstream shape. let host: Omit< WebuiAssembledHost, - "invalidateAuth" | "getUsageQuota" | "getSigninPanel" | "claimSignin" + | "invalidateAuth" + | "getUsageQuota" + | "getSigninPanel" + | "claimSignin" + | "beginAccountLogin" + | "getAccountLoginStatus" + | "cancelAccountLogin" + | "signOutAccount" >; try { host = await factory( @@ -500,6 +525,10 @@ export async function createWebuiRuntimeHost( // enrichments must live on `host` itself — otherwise `getUsageQuota` // only exists on `harnessPort` and the live panel fails with // "runtime host does not expose the usage quota client". + // The account-login session shares the quota core deliberately: one + // credential store, one watch — a login (here or in the terminal client) + // refreshes both, and a sign-out clears both. + const accountLogin = createWebuiAccountLoginSession(quotaOauthCore); const hostHandle = { ...host, appVersion: options.appVersion ?? host.appVersion ?? "webui", @@ -508,6 +537,18 @@ export async function createWebuiRuntimeHost( usageQuota.getUsageQuota(request), getSigninPanel: () => dailyCheckin.getSigninPanel(), claimSignin: () => dailyCheckin.claimSignin(), + beginAccountLogin: () => accountLogin.begin(), + getAccountLoginStatus: () => accountLogin.status(), + cancelAccountLogin: () => accountLogin.cancel(), + // The real sign-out: revoke + remove the credential, then clear this + // process's projections. `invalidateAuth` alone only cleared projections + // — the credential survived and the next lease refresh signed straight + // back in, which is why 退出登录 never actually logged out. + signOutAccount: async () => { + const result = await quotaOauthCore.logout({ revoke: true }); + invalidateAuth(); + return result; + }, }; const harnessPort = createHarnessPortFromHost(hostHandle); return { @@ -532,6 +573,13 @@ const defaultWebuiRuntimeHostFactory: WebuiRuntimeHostFactory = async ( options, )) as unknown as Omit< WebuiAssembledHost, - "invalidateAuth" | "getUsageQuota" | "getSigninPanel" | "claimSignin" + | "invalidateAuth" + | "getUsageQuota" + | "getSigninPanel" + | "claimSignin" + | "beginAccountLogin" + | "getAccountLoginStatus" + | "cancelAccountLogin" + | "signOutAccount" >; }; diff --git a/packages/webui/src/server/host.ts b/packages/webui/src/server/host.ts index 796a8b9a5..6b05a5d4e 100644 --- a/packages/webui/src/server/host.ts +++ b/packages/webui/src/server/host.ts @@ -335,6 +335,16 @@ export interface WebuiRuntimeHostHandle { */ readonly getSigninPanel?: () => Promise; readonly claimSignin?: () => Promise; + /** + * Account login (device authorization) and real sign-out. Supplied by the + * assembly from its `MCodeOAuthCore` — the same credential store the quota + * lease reads — so a login completed in the browser is the same credential + * the terminal client uses, and a sign-out actually removes it. + */ + readonly beginAccountLogin?: () => Promise; + readonly getAccountLoginStatus?: () => Promise; + readonly cancelAccountLogin?: () => Promise; + readonly signOutAccount?: () => Promise<{ readonly status: string; readonly generation: number }>; /** * Source of every harness command the WebUI maps to operations. Owned by * the runtime host; the WebUI only needs the structural shape to forward. @@ -721,6 +731,27 @@ export function createHarnessPortFromHost( throw new Error("runtime host does not expose the daily check-in client"); return host.claimSignin(); }, + async beginAccountLogin() { + if (!host.beginAccountLogin) + throw new Error("runtime host does not expose account login"); + return host.beginAccountLogin(); + }, + async getAccountLoginStatus() { + if (!host.getAccountLoginStatus) + throw new Error("runtime host does not expose account login"); + return host.getAccountLoginStatus(); + }, + async cancelAccountLogin() { + if (!host.cancelAccountLogin) + throw new Error("runtime host does not expose account login"); + await host.cancelAccountLogin(); + return { ok: true as const }; + }, + async signOutAccount() { + if (!host.signOutAccount) + throw new Error("runtime host does not expose account sign-out"); + return host.signOutAccount(); + }, async getAccountStatus(request) { return requireCliService(host).getAccountStatus(request); }, diff --git a/packages/webui/src/server/oauth-core.d.ts b/packages/webui/src/server/oauth-core.d.ts index 4ea96fb33..2b48be7f4 100644 --- a/packages/webui/src/server/oauth-core.d.ts +++ b/packages/webui/src/server/oauth-core.d.ts @@ -56,6 +56,22 @@ declare module "@mavis/oauth-core" { readonly audience: "agent-backend"; } + /** The device-authorization prompt `login()` emits before the human + * authorizes — the terminal client prints it; the WebUI renders it. */ + export interface DeviceAuthorizationPrompt { + readonly userCode: string; + readonly verificationUri: string; + readonly verificationUriComplete?: string; + readonly expiresInSec: number; + } + + export interface AuthStatusSnapshot { + readonly status: string; + readonly generation: number; + readonly scopes: readonly string[]; + readonly expiresAtMs?: number; + } + export class MCodeOAuthCore { constructor(options: { readonly namespace: unknown; @@ -72,5 +88,18 @@ declare module "@mavis/oauth-core" { readonly loginEpoch?: string; }): Promise<"retry" | "logout">; watch(listener: (status: { readonly status: string }) => void): () => void; + /** Device-authorization login; resolves once the user authorizes. + * Concurrent calls share one attempt (the core's own dedupe). */ + login(options?: { + onDeviceAuthorization?: (authorization: DeviceAuthorizationPrompt) => void; + }): Promise<{ readonly status: "authenticated"; readonly generation: number }>; + cancelLogin(): Promise; + /** Removes the credential. `revoke: true` also revokes it server-side; + * the local wipe happens either way. */ + logout(options: { readonly revoke: boolean }): Promise<{ + readonly status: "anonymous" | "logout_pending"; + readonly generation: number; + }>; + getStatus(): Promise; } } diff --git a/packages/webui/src/server/operation/names.ts b/packages/webui/src/server/operation/names.ts index ebf2194fe..acc854122 100644 --- a/packages/webui/src/server/operation/names.ts +++ b/packages/webui/src/server/operation/names.ts @@ -62,6 +62,9 @@ export const GET_USAGE_QUOTA_OPERATION_NAME = "getUsageQuota" as const; export const GET_SIGNIN_PANEL_OPERATION_NAME = "getSigninPanel" as const; export const CLAIM_SIGNIN_OPERATION_NAME = "claimSignin" as const; export const GET_ACCOUNT_STATUS_OPERATION_NAME = "getAccountStatus" as const; +export const BEGIN_ACCOUNT_LOGIN_OPERATION_NAME = "beginAccountLogin" as const; +export const GET_ACCOUNT_LOGIN_STATUS_OPERATION_NAME = "getAccountLoginStatus" as const; +export const CANCEL_ACCOUNT_LOGIN_OPERATION_NAME = "cancelAccountLogin" as const; export const RUN_COMMAND_OPERATION_NAME = "runCommand" as const; export const SIGN_OUT_OPERATION_NAME = "signOut" as const; export const ARCHIVE_SESSION_OPERATION_NAME = "archiveSession" as const; diff --git a/packages/webui/src/server/operation/operation-handlers.ts b/packages/webui/src/server/operation/operation-handlers.ts index 9115cdceb..8dab4b4ff 100644 --- a/packages/webui/src/server/operation/operation-handlers.ts +++ b/packages/webui/src/server/operation/operation-handlers.ts @@ -85,6 +85,10 @@ export type WebuiOperationPort = Pick< | "getUsageQuota" | "getSigninPanel" | "claimSignin" + | "beginAccountLogin" + | "getAccountLoginStatus" + | "cancelAccountLogin" + | "signOutAccount" | "getAccountStatus" | "listUserModelProviders" | "createUserModelProvider" @@ -215,6 +219,9 @@ export function createOperationHandlers( getSessionUsage: async (_context, body) => ({ body: await port.getSessionUsage(body) }), getUsageQuota: async (_context, body) => ({ body: await port.getUsageQuota(body) }), getSigninPanel: async () => ({ body: await port.getSigninPanel() }), + beginAccountLogin: async () => ({ body: await port.beginAccountLogin() }), + getAccountLoginStatus: async () => ({ body: await port.getAccountLoginStatus() }), + cancelAccountLogin: async () => ({ body: await port.cancelAccountLogin() }), claimSignin: async () => ({ body: await port.claimSignin() }), getAccountStatus: async (_context, body) => ({ body: await port.getAccountStatus(body) }), listUserModelProviders: async () => ({ body: await port.listUserModelProviders() }), @@ -238,7 +245,12 @@ export function createOperationHandlers( refreshModels: async () => ({ body: await port.refreshModels() }), runCommand: async (_context, body) => ({ body: await runWebuiCommand(port, body) }), signOut: async () => { - await port.invalidateAuth(); + // The real sign-out removes the credential (revoke + wipe). The + // invalidation-only path remains as a fallback for hosts assembled + // before `signOutAccount` existed; on those, sign-out degrades to the + // historical behaviour instead of failing the operation. + if (port.signOutAccount) await port.signOutAccount(); + else await port.invalidateAuth(); return { body: { success: true as const } }; }, watchEvents: (context) => ({ diff --git a/packages/webui/src/server/operation/operations.ts b/packages/webui/src/server/operation/operations.ts index 819a62caf..fbe521211 100644 --- a/packages/webui/src/server/operation/operations.ts +++ b/packages/webui/src/server/operation/operations.ts @@ -8,7 +8,7 @@ import { abortSessionOperation, listQueueMessagesOperation, deleteQueueItemOpera import { pluginManagementOperation } from "./plugin-management.js"; import { getPermissionModeOperation, setPermissionModeOperation } from "./permission-mode.js"; import { listScheduledTasksOperation, createScheduledTaskOperation, updateScheduledTaskOperation, deleteScheduledTaskOperation, triggerScheduledTaskNowOperation, getScheduledTaskCapabilityOperation } from "./scheduled-task.js"; -import { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation } from "./provider.js"; +import { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation, beginAccountLoginOperation, getAccountLoginStatusOperation, cancelAccountLoginOperation } from "./provider.js"; export { versionOperation, listSessionsOperation, listVisibleProjectsOperation, getSessionTreeOperation, createSessionOperation, getSessionOperation, getActiveTurnOperation } from "./session.js"; export { listWorkspaceFileTreeOperation, browseWorkspaceDirsOperation, readWorkspaceFileOperation, getWorkspaceEnvironmentOperation, mutateWorkspaceGitOperation, getWorkspaceReviewSummaryOperation, listWorkspaceReviewFileDiffsOperation, getWorkspaceReviewFileContentOperation, searchWorkspaceReviewDiffsOperation, readCanvasOperation, applyCanvasOperation, readWorkspaceArchiveOperation, extractWorkspaceArchiveOperation, createTerminalOperation, listTerminalsOperation, writeTerminalOperation, resizeTerminalOperation, disposeTerminalOperation, watchTerminalOperation } from "./workspace.js"; export { getMessagesOperation, getSessionDiffOperation, getTurnDiffOperation, revertTurnDiffOperation, reapplyTurnDiffOperation, getSessionRewindPreviewOperation, rewindSessionOperation, editSessionMessageOperation } from "./messages.js"; @@ -19,7 +19,7 @@ export { abortSessionOperation, listQueueMessagesOperation, deleteQueueItemOpera export { pluginManagementOperation } from "./plugin-management.js"; export { getPermissionModeOperation, setPermissionModeOperation } from "./permission-mode.js"; export { listScheduledTasksOperation, createScheduledTaskOperation, updateScheduledTaskOperation, deleteScheduledTaskOperation, triggerScheduledTaskNowOperation, getScheduledTaskCapabilityOperation } from "./scheduled-task.js"; -export { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation } from "./provider.js"; +export { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation, beginAccountLoginOperation, getAccountLoginStatusOperation, cancelAccountLoginOperation } from "./provider.js"; import { createOperationHandlers, type WebuiOperationPort } from "./operation-handlers.js"; import type { WebuiOperationHandler, @@ -128,6 +128,9 @@ export function createOperationRegistry( registerOperation(registry, { operation: getUsageQuotaOperation, handle: handlers.getUsageQuota }); registerOperation(registry, { operation: getSigninPanelOperation, handle: handlers.getSigninPanel }); registerOperation(registry, { operation: claimSigninOperation, handle: handlers.claimSignin }); + registerOperation(registry, { operation: beginAccountLoginOperation, handle: handlers.beginAccountLogin }); + registerOperation(registry, { operation: getAccountLoginStatusOperation, handle: handlers.getAccountLoginStatus }); + registerOperation(registry, { operation: cancelAccountLoginOperation, handle: handlers.cancelAccountLogin }); registerOperation(registry, { operation: getAccountStatusOperation, handle: handlers.getAccountStatus }); registerOperation(registry, { operation: listUserModelProvidersOperation, handle: handlers.listUserModelProviders }); registerOperation(registry, { operation: createUserModelProviderOperation, handle: handlers.createUserModelProvider }); diff --git a/packages/webui/src/server/operation/provider.ts b/packages/webui/src/server/operation/provider.ts index ccdba154d..6a3f521f4 100644 --- a/packages/webui/src/server/operation/provider.ts +++ b/packages/webui/src/server/operation/provider.ts @@ -2,7 +2,7 @@ import { WebuiErrorCode } from "../envelope.js"; import { requireRecord } from "./operation-contract.js"; import type { WebuiOperation, WebuiOperationValidation } from "./operation-contract.js"; import { validateSessionIdBody } from "./common.js"; -import { ARCHIVE_SESSION_OPERATION_NAME, DELETE_SESSION_OPERATION_NAME, UPDATE_SESSION_OPERATION_NAME, GET_SESSION_FORK_OPTIONS_OPERATION_NAME, FORK_SESSION_OPERATION_NAME, LIST_USER_MODEL_PROVIDERS_OPERATION_NAME, CREATE_USER_MODEL_PROVIDER_OPERATION_NAME, UPDATE_USER_MODEL_PROVIDER_OPERATION_NAME, DELETE_USER_MODEL_PROVIDER_OPERATION_NAME, TEST_USER_MODEL_PROVIDER_OPERATION_NAME, TEST_USER_MODEL_OPERATION_NAME, DISCOVER_USER_MODELS_CANDIDATE_OPERATION_NAME, SAVE_USER_MODEL_PROVIDER_CANDIDATE_OPERATION_NAME, LIST_PROVIDER_PRESETS_OPERATION_NAME, GET_MINIMAX_API_KEY_STATUS_OPERATION_NAME, UPSERT_MINIMAX_API_KEY_OPERATION_NAME, GET_CODEX_OAUTH_STATUS_OPERATION_NAME, GET_MINIMAX_MODEL_SOURCE_OPERATION_NAME, SET_MINIMAX_MODEL_SOURCE_OPERATION_NAME, TEST_USER_MODEL_CANDIDATE_OPERATION_NAME, REVEAL_MODEL_PROVIDER_API_KEY_OPERATION_NAME, START_CODEX_OAUTH_LOGIN_OPERATION_NAME, CANCEL_CODEX_OAUTH_LOGIN_OPERATION_NAME, REFRESH_MODELS_OPERATION_NAME, RUN_COMMAND_OPERATION_NAME, GET_SIGNIN_PANEL_OPERATION_NAME, CLAIM_SIGNIN_OPERATION_NAME, SIGN_OUT_OPERATION_NAME } from "./names.js"; +import { ARCHIVE_SESSION_OPERATION_NAME, DELETE_SESSION_OPERATION_NAME, UPDATE_SESSION_OPERATION_NAME, GET_SESSION_FORK_OPTIONS_OPERATION_NAME, FORK_SESSION_OPERATION_NAME, LIST_USER_MODEL_PROVIDERS_OPERATION_NAME, CREATE_USER_MODEL_PROVIDER_OPERATION_NAME, UPDATE_USER_MODEL_PROVIDER_OPERATION_NAME, DELETE_USER_MODEL_PROVIDER_OPERATION_NAME, TEST_USER_MODEL_PROVIDER_OPERATION_NAME, TEST_USER_MODEL_OPERATION_NAME, DISCOVER_USER_MODELS_CANDIDATE_OPERATION_NAME, SAVE_USER_MODEL_PROVIDER_CANDIDATE_OPERATION_NAME, LIST_PROVIDER_PRESETS_OPERATION_NAME, GET_MINIMAX_API_KEY_STATUS_OPERATION_NAME, UPSERT_MINIMAX_API_KEY_OPERATION_NAME, GET_CODEX_OAUTH_STATUS_OPERATION_NAME, GET_MINIMAX_MODEL_SOURCE_OPERATION_NAME, SET_MINIMAX_MODEL_SOURCE_OPERATION_NAME, TEST_USER_MODEL_CANDIDATE_OPERATION_NAME, REVEAL_MODEL_PROVIDER_API_KEY_OPERATION_NAME, START_CODEX_OAUTH_LOGIN_OPERATION_NAME, CANCEL_CODEX_OAUTH_LOGIN_OPERATION_NAME, REFRESH_MODELS_OPERATION_NAME, RUN_COMMAND_OPERATION_NAME, GET_SIGNIN_PANEL_OPERATION_NAME, CLAIM_SIGNIN_OPERATION_NAME, SIGN_OUT_OPERATION_NAME, BEGIN_ACCOUNT_LOGIN_OPERATION_NAME, GET_ACCOUNT_LOGIN_STATUS_OPERATION_NAME, CANCEL_ACCOUNT_LOGIN_OPERATION_NAME } from "./names.js"; function validateProviderRecord(name: string, body: unknown): WebuiOperationValidation> { return requireRecord(name, body); } @@ -139,6 +139,41 @@ export const claimSigninOperation: WebuiOperation< }, }; +const emptyBody = (name: string) => (body: unknown) => { + if (body === null || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 0) + return { ok: false, code: WebuiErrorCode.invalidBody, message: `${name} body must be an empty object` } as const; + return { ok: true, body: {} } as const; +}; + +// Account login over the device-authorization flow: `begin` starts (or +// re-attaches to) an attempt and usually answers with the prompt on the +// first reply; `status` is what the dialog polls; `cancel` aborts without +// touching the credential. Bodies are empty on purpose — the login session +// is server-owned, one per service, exactly like the check-in panel. +export const beginAccountLoginOperation: WebuiOperation< + Record, + import("../port.js").WebuiAccountLoginView +> = { + name: BEGIN_ACCOUNT_LOGIN_OPERATION_NAME, + validate: emptyBody("beginAccountLogin"), +}; + +export const getAccountLoginStatusOperation: WebuiOperation< + Record, + import("../port.js").WebuiAccountLoginView +> = { + name: GET_ACCOUNT_LOGIN_STATUS_OPERATION_NAME, + validate: emptyBody("getAccountLoginStatus"), +}; + +export const cancelAccountLoginOperation: WebuiOperation< + Record, + { readonly ok: true } +> = { + name: CANCEL_ACCOUNT_LOGIN_OPERATION_NAME, + validate: emptyBody("cancelAccountLogin"), +}; + export const signOutOperation: WebuiOperation, { readonly success: true }> = { name: SIGN_OUT_OPERATION_NAME, validate: (body) => { diff --git a/packages/webui/src/server/port.ts b/packages/webui/src/server/port.ts index 8b1f9aa8d..c4f890c69 100644 --- a/packages/webui/src/server/port.ts +++ b/packages/webui/src/server/port.ts @@ -1247,6 +1247,15 @@ export interface WebuiHarnessPort extends WebuiScheduledTaskPort { /** Daily check-in panel status (cloud check-in API; see `check-in.ts`); supplied by the assembly alongside the host. */ getSigninPanel(): Promise; claimSignin(): Promise; + /** + * Account login over the device-authorization flow, and the sign-out that + * actually removes the credential. See `account-login.ts` for why the + * session lives server-side; supplied by the assembly alongside the host. + */ + beginAccountLogin(): Promise; + getAccountLoginStatus(): Promise; + cancelAccountLogin(): Promise<{ readonly ok: true }>; + signOutAccount(): Promise<{ readonly status: string; readonly generation: number }>; close(): Promise; } @@ -1295,3 +1304,26 @@ export type WebuiUsageQuotaResult = // panel renders them directly, so there is no second shape to drift). export type WebuiSigninPanelView = SigninPanel; export type WebuiClaimSigninView = ClaimSigninData; + +/** The device-authorization prompt the browser shows: a code to enter and + * the page to enter it on. Same fields the terminal client prints. */ +export interface WebuiAccountLoginPromptView { + readonly userCode: string; + readonly verificationUri: string; + readonly verificationUriComplete?: string; + /** Unix ms, derived from the prompt's `expiresInSec`; the dialog counts + * down against it and offers a fresh code when it passes. */ + readonly expiresAtMs: number; +} + +/** + * One answer for both account questions the browser asks: is a login + * attempt running (with its prompt), and is the account signed in — + * `authenticated` covers a login this process started AND one made in the + * terminal client, because the credential store is shared. + */ +export type WebuiAccountLoginView = + | { readonly state: "idle" } + | { readonly state: "pending"; readonly prompt?: WebuiAccountLoginPromptView } + | { readonly state: "authenticated" } + | { readonly state: "error"; readonly error: string }; diff --git a/packages/webui/src/server/service.ts b/packages/webui/src/server/service.ts index 6af74028b..f2c05863c 100644 --- a/packages/webui/src/server/service.ts +++ b/packages/webui/src/server/service.ts @@ -306,6 +306,10 @@ export class WebuiService { getSessionUsage: (request) => this.port.getSessionUsage(request), getUsageQuota: (request) => this.port.getUsageQuota(request), getSigninPanel: () => this.port.getSigninPanel(), + beginAccountLogin: () => this.port.beginAccountLogin(), + getAccountLoginStatus: () => this.port.getAccountLoginStatus(), + cancelAccountLogin: () => this.port.cancelAccountLogin(), + signOutAccount: () => this.port.signOutAccount(), claimSignin: () => this.port.claimSignin(), getAccountStatus: (request) => this.port.getAccountStatus(request), listUserModelProviders: () => this.port.listUserModelProviders(), diff --git a/packages/webui/test-results/.last-run.json b/packages/webui/test-results/.last-run.json new file mode 100644 index 000000000..5fca3f84b --- /dev/null +++ b/packages/webui/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/packages/webui/test/unit/account-login.test.ts b/packages/webui/test/unit/account-login.test.ts new file mode 100644 index 000000000..e03646ab5 --- /dev/null +++ b/packages/webui/test/unit/account-login.test.ts @@ -0,0 +1,192 @@ +// Unit tests for the server-owned account login session and the dialog's +// phase derivation. +// +// Roadmap N (登录与账号): the WebUI had no login at all, and its sign-out +// only cleared projections — the credential survived and the next lease +// refresh signed straight back in. `account-login.ts` is the server half of +// the fix: one session over the shared `MCodeOAuthCore`, answering the +// browser's begin/status/cancel calls. The fake core below stands in for it, +// so these tests pin the session's observable contract: +// +// * `begin` resolves with the prompt as soon as the core emits it, without +// waiting for the human to authorize; +// * a second `begin` while one attempt runs re-attaches (the core dedupes +// `login()` on its own promise; the session must not start a second); +// * `status` reports the settled outcome first, the in-flight prompt +// second, and — with no attempt this process started — the persisted +// store (a terminal-client login counts); +// * `cancel` aborts and, when the core then rejects with the cancellation, +// the session does not report it as a failure. + +import { describe, expect, it, vi } from "vitest"; + +import { createWebuiAccountLoginSession } from "../../src/server/account-login.js"; +import { deriveWebuiAccountLoginPhase } from "../../src/client/components/AccountLoginDialog.js"; + +/** A controllable core: the test decides when the prompt lands and when the + * attempt settles, including the cancellation rejection. */ +function fakeCore() { + let loginCalls = 0; + let authorize: ((authorization: { + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresInSec: number; + }) => void) | undefined; + const settleLogin = () => {}; + const deferred: { + resolve: (value: { status: "authenticated"; generation: number }) => void; + reject: (error: Error) => void; + } = { resolve: () => {}, reject: () => {} }; + const loginPromise = new Promise<{ status: "authenticated"; generation: number }>( + (resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }, + ); + const core = { + login: vi.fn( + ( + options: { + onDeviceAuthorization?: (authorization: { + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresInSec: number; + }) => void; + } = {}, + ) => { + loginCalls += 1; + authorize = options.onDeviceAuthorization; + return loginPromise; + }, + ), + cancelLogin: vi.fn(async () => { + deferred.reject(new Error("MCode OAuth device authorization was cancelled.")); + }), + getStatus: vi.fn(async () => ({ status: "anonymous", generation: 0, scopes: [] })), + emitPrompt() { + authorize?.({ + userCode: "ABCD-1234", + verificationUri: "https://auth.example.invalid/device", + verificationUriComplete: "https://auth.example.invalid/device?code=ABCD-1234", + expiresInSec: 600, + }); + }, + complete() { + deferred.resolve({ status: "authenticated", generation: 7 }); + }, + loginCalls: () => loginCalls, + }; + return core; +} + +describe("the account login session", () => { + it("begins with the prompt as soon as the core emits it", async () => { + const core = fakeCore(); + const session = createWebuiAccountLoginSession(core, () => 1_000); + const beginning = session.begin(); + core.emitPrompt(); + await expect(beginning).resolves.toEqual({ + state: "pending", + prompt: { + userCode: "ABCD-1234", + verificationUri: "https://auth.example.invalid/device", + verificationUriComplete: "https://auth.example.invalid/device?code=ABCD-1234", + // now()=1000 + 600s + expiresAtMs: 601_000, + }, + }); + }); + + it("re-attaches to a running attempt instead of starting a second", async () => { + const core = fakeCore(); + const session = createWebuiAccountLoginSession(core, () => 0); + const beginning = session.begin(); + core.emitPrompt(); + await beginning; + await expect(session.begin()).resolves.toMatchObject({ + state: "pending", + prompt: { userCode: "ABCD-1234" }, + }); + expect(core.loginCalls()).toBe(1); + }); + + it("reports the settled outcome, then the persisted store after cancellation", async () => { + const core = fakeCore(); + const session = createWebuiAccountLoginSession(core, () => 0); + const beginning = session.begin(); + core.emitPrompt(); + await beginning; + + core.complete(); + // Let the session's then/catch run. + await vi.waitFor(() => + expect(session.status()).resolves.toEqual({ state: "authenticated" }), + ); + }); + + it("treats a cancelled attempt as the user's own action, not a failure", async () => { + const core = fakeCore(); + const session = createWebuiAccountLoginSession(core, () => 0); + const beginning = session.begin(); + core.emitPrompt(); + await beginning; + + await session.cancel(); + // The core's login promise now rejects with the cancellation; the + // session must not surface it as an error. + await loginPromiseSettles(); + await expect(session.status()).resolves.toEqual({ state: "idle" }); + }); + + it("reads the persisted store when this process started nothing", async () => { + const core = fakeCore(); + core.getStatus.mockResolvedValue({ status: "authenticated", generation: 3, scopes: [] }); + const session = createWebuiAccountLoginSession(core, () => 0); + await expect(session.status()).resolves.toEqual({ state: "authenticated" }); + }); +}); + +/** Waits until the fake core's login promise has settled (microtask drain). */ +async function loginPromiseSettles(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("deriveWebuiAccountLoginPhase", () => { + it("maps every server answer to the view the panel shows", () => { + expect(deriveWebuiAccountLoginPhase(undefined)).toEqual({ kind: "loading" }); + expect(deriveWebuiAccountLoginPhase({ state: "idle" })).toEqual({ kind: "loading" }); + expect(deriveWebuiAccountLoginPhase({ state: "authenticated" })).toEqual({ + kind: "authenticated", + }); + expect(deriveWebuiAccountLoginPhase({ state: "error", error: "expired" })).toEqual({ + kind: "error", + error: "expired", + }); + expect( + deriveWebuiAccountLoginPhase({ + state: "pending", + prompt: { + userCode: "X", + verificationUri: "https://x.invalid", + expiresAtMs: 1, + }, + }), + ).toEqual({ + kind: "pending", + userCode: "X", + verificationUri: "https://x.invalid", + verificationUriComplete: undefined, + }); + }); + + it("keeps a prompt-less pending answer honest: waiting, not guessing", () => { + expect(deriveWebuiAccountLoginPhase({ state: "pending" })).toEqual({ + kind: "pending", + userCode: undefined, + verificationUri: undefined, + verificationUriComplete: undefined, + }); + }); +}); diff --git a/packages/webui/test/unit/webui-host-shape-invariant.test.ts b/packages/webui/test/unit/webui-host-shape-invariant.test.ts index 1853728d6..71433155d 100644 --- a/packages/webui/test/unit/webui-host-shape-invariant.test.ts +++ b/packages/webui/test/unit/webui-host-shape-invariant.test.ts @@ -353,6 +353,19 @@ class FullPort implements WebuiHarnessPort { panel: { scene: 0, days: [] }, }; } + // Added with the N-zone account login contract. + async beginAccountLogin() { + return { state: "idle" as const }; + } + async getAccountLoginStatus() { + return { state: "idle" as const }; + } + async cancelAccountLogin() { + return { ok: true as const }; + } + async signOutAccount() { + return { status: "anonymous", generation: 0 }; + } async getAccountStatus() { return { available: true }; } diff --git a/packages/webui/test/unit/webui-service.test.ts b/packages/webui/test/unit/webui-service.test.ts index e619899f2..cbca558e8 100644 --- a/packages/webui/test/unit/webui-service.test.ts +++ b/packages/webui/test/unit/webui-service.test.ts @@ -476,6 +476,23 @@ class ScriptedHarnessPort implements WebuiHarnessPort { }; } + // Added with the N-zone account login contract. + async beginAccountLogin() { + return { state: "idle" as const }; + } + + async getAccountLoginStatus() { + return { state: "idle" as const }; + } + + async cancelAccountLogin() { + return { ok: true as const }; + } + + async signOutAccount() { + return { status: "anonymous", generation: 0 }; + } + async getAccountStatus() { return { available: true }; } @@ -3812,6 +3829,18 @@ describe("WebUI shutdown order (criterion 7)", () => { panel: { scene: 0, days: [] }, }; }, + async beginAccountLogin() { + return { state: "idle" as const }; + }, + async getAccountLoginStatus() { + return { state: "idle" as const }; + }, + async cancelAccountLogin() { + return { ok: true as const }; + }, + async signOutAccount() { + return { status: "anonymous", generation: 0 }; + }, async listScheduledTasks() { return { tasks: [], total: 0 }; }, @@ -4212,6 +4241,19 @@ describe("WebUI shutdown order (criterion 7)", () => { panel: { scene: 0, days: [] }, }; }, + + async beginAccountLogin() { + return { state: "idle" as const }; + }, + async getAccountLoginStatus() { + return { state: "idle" as const }; + }, + async cancelAccountLogin() { + return { ok: true as const }; + }, + async signOutAccount() { + return { status: "anonymous", generation: 0 }; + }, async listScheduledTasks() { return { tasks: [], total: 0 }; }, diff --git a/release/public-source.json b/release/public-source.json index ca639eb65..afac3397a 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3514,6 +3514,7 @@ "packages/webui/src/client/assets/img/wechat.png", "packages/webui/src/client/assets/lottie/streaming-rose-loader.json", "packages/webui/src/client/client.js", + "packages/webui/src/client/components/AccountLoginDialog.tsx", "packages/webui/src/client/components/ActivityIndicator.tsx", "packages/webui/src/client/components/ArchonPage.tsx", "packages/webui/src/client/components/ArchonShell.tsx", @@ -3626,6 +3627,7 @@ "packages/webui/src/client/team-mode.ts", "packages/webui/src/client/transport.ts", "packages/webui/src/client/value-readers.ts", + "packages/webui/src/server/account-login.ts", "packages/webui/src/server/assembly.ts", "packages/webui/src/server/auth-context.ts", "packages/webui/src/server/check-in.ts", @@ -3675,6 +3677,8 @@ "packages/webui/src/shared/plugin-management.ts", "packages/webui/src/shared/session-transfer-format.ts", "packages/webui/tailwind.config.cjs", + "packages/webui/test-results/.last-run.json", + "packages/webui/test/unit/account-login.test.ts", "packages/webui/test/unit/activity-indicator.test.tsx", "packages/webui/test/unit/commands.test.ts", "packages/webui/test/unit/composer-history.test.ts", @@ -3833,6 +3837,7 @@ "test/sqlite-message-contention.test.ts", "test/vitest-suites.json", "test/webui-browser/README.md", + "test/webui-browser/account-login.spec.mjs", "test/webui-browser/composer.spec.mjs", "test/webui-browser/connection-status.spec.mjs", "test/webui-browser/fixture.mjs", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 86bba7e63..04374f21a 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -308,6 +308,7 @@ "packages/webui/test/unit/webui-workspace-canvas.test.tsx", "packages/webui/test/unit/webui-workspace-archive.test.ts", "packages/webui/test/unit/webui-error-boundary.test.tsx", + "packages/webui/test/unit/account-login.test.ts", "packages/webui/test/unit/connection-health.test.ts", "packages/webui/test/unit/connection-status.test.tsx" ] diff --git a/test/webui-browser/account-login.spec.mjs b/test/webui-browser/account-login.spec.mjs new file mode 100644 index 000000000..850c64168 --- /dev/null +++ b/test/webui-browser/account-login.spec.mjs @@ -0,0 +1,106 @@ +// The account login dialog, end to end against the built client. +// +// Roadmap N (登录与账号): the WebUI had no login at all — it served whatever +// credential the terminal client had written, its usage panel said 登录后查看用量 +// with no way to log in, and 退出登录 never removed the credential, so +// neither signing in nor switching accounts was possible without leaving the +// browser. This spec walks both entries and the full device-authorization +// round trip: +// +// 1. the user menu's 账号与登录 item opens the dialog, which shows the +// device code and the authorization link, then flips to authenticated +// when the server observes the credential (the fixture's +// setAccountLoginState stands in for the human authorizing); +// 2. from the authenticated state, 切换账号 signs out and starts a fresh +// device flow in the same surface — the whole of account switching +// under a single-credential store; +// 3. the usage panel's signed-out block carries a 登录 button that opens +// the same dialog. +// +// Boundary, stated once: the fixture's login operations are synthetic — the +// real credential round trip needs a logged-out live server and a human at +// the authorization page. What is assertable here is the browser half: every +// entry opens the surface, every phase renders its contract, and the switch +// path issues the sign-out before the new device flow. + +import { expect } from "@playwright/test"; + +import { openApp, test } from "./harness.mjs"; + +test.beforeEach(async ({ page }) => { + page.on("pageerror", (error) => console.error("BROWSER_PAGE_ERROR", error.stack ?? error.message)); + page.on("console", (message) => { if (message.type() === "error") console.error("BROWSER_CONSOLE_ERROR", message.text()); }); +}); + +const dialog = (page) => page.locator('[data-testid="account-login-dialog"]'); + +async function openFromUserMenu(page) { + await page.locator('[data-testid="sidebar-user-menu-trigger"]').click(); + await page.locator('[data-testid="user-menu-account-login"]').click(); + await expect(dialog(page)).toBeVisible(); +} + +test("the user menu opens the login, and authorization completes the flow", async ({ page }) => { + await openApp(page, "#session=A"); + await openFromUserMenu(page); + + // The device prompt: the code the user enters and the page to enter it on. + await expect(dialog(page)).toHaveAttribute("data-webui-account-login-phase", "pending"); + await expect(page.locator('[data-testid="account-login-code"]')).toHaveText("TEST-CODE"); + await expect(page.locator('[data-testid="account-login-uri"]')).toHaveAttribute( + "href", + "https://example.invalid/device?code=TEST-CODE", + ); + + // The human authorizes on the other page; the server (here: the fixture) + // observes the credential and the poll flips the dialog. + await page.evaluate(() => + window.__fixture.setAccountLoginState({ state: "authenticated" }), + ); + await expect(dialog(page)).toHaveAttribute("data-webui-account-login-phase", "authenticated", { timeout: 6_000 }); + await expect(page.locator('[data-testid="account-login-status"]')).toContainText("已登录"); +}); + +test("切换账号 signs out first, then starts a fresh device flow", async ({ page }) => { + await openApp(page, "#session=A"); + await openFromUserMenu(page); + await page.evaluate(() => + window.__fixture.setAccountLoginState({ state: "authenticated" }), + ); + await expect(dialog(page)).toHaveAttribute("data-webui-account-login-phase", "authenticated", { timeout: 6_000 }); + + const signOutCount = () => + page.evaluate(() => window.__fixture.requests.filter((r) => r.operation === "signOut").length); + const before = await signOutCount(); + await page.locator('[data-testid="account-login-switch"]').click(); + + // The switch is a real sign-out (the operation that now removes the + // credential) followed by a new device prompt in the same dialog. + await expect.poll(signOutCount, { timeout: 6_000 }).toBe(before + 1); + await expect(dialog(page)).toHaveAttribute("data-webui-account-login-phase", "pending", { timeout: 6_000 }); + await expect(page.locator('[data-testid="account-login-code"]')).toHaveText("TEST-CODE"); +}); + +test("the signed-out usage block carries a 登录 button into the same dialog", async ({ page }) => { + await openApp(page, "#session=A"); + await page.evaluate(() => window.__fixture.setUsageQuotaResult({ signedIn: false })); + await page.locator('[data-testid="sidebar-user-menu-trigger"]').click(); + await page.locator('[data-testid="user-menu-usage"]').click(); + const signIn = page.locator('[data-testid="usage-sign-in-button"]'); + await expect(signIn).toBeVisible(); + await signIn.click(); + await expect(dialog(page)).toBeVisible(); + await expect(page.locator('[data-testid="account-login-code"]')).toHaveText("TEST-CODE"); +}); + +test("取消 closes the dialog and cancels the in-flight attempt", async ({ page }) => { + await openApp(page, "#session=A"); + await openFromUserMenu(page); + await expect(page.locator('[data-testid="account-login-code"]')).toHaveText("TEST-CODE"); + + await page.locator('[data-testid="account-login-cancel"]').click(); + await expect(dialog(page)).toHaveCount(0); + await expect.poll(() => + page.evaluate(() => window.__fixture.requests.some((r) => r.operation === "cancelAccountLogin")), + ).toBe(true); +}); diff --git a/test/webui-browser/fixture.mjs b/test/webui-browser/fixture.mjs index daef22630..b9616f509 100644 --- a/test/webui-browser/fixture.mjs +++ b/test/webui-browser/fixture.mjs @@ -72,6 +72,17 @@ export function installFixtureTransport() { // listing it wants (an MCP server list with connection states, say) and // every other action keeps the empty default. const pluginManagementResults = {}; + // Account login state machine for the three login operations. `begin` + // starts "pending" with a deterministic prompt; a test flips the state + // (authenticated / error) with setAccountLoginState to drive the dialog's + // poll — the real server observes the credential; the fixture observes + // the test. + let accountLoginState = { state: "idle" }; + let accountLoginSequence = 0; + // The usage quota answer, overridable so a test can stage the signed-out + // block (`{}` — the default — has no signedIn field and reads as signed + // in but quota-less, not as signed out). + let usageQuotaResult = {}; let activeTurn; const requests = []; const sockets = new Set(); @@ -91,6 +102,23 @@ export function installFixtureTransport() { if (operation === "listPendingPermissions") return { requests: [] }; if (operation === "getActiveTurn") return activeTurn; if (operation === "getPendingQuestionnaire") return questionnaire ? { request: questionnaire } : {}; + if (operation === "beginAccountLogin") { + accountLoginState = { + state: "pending", + prompt: { + userCode: "TEST-CODE", + verificationUri: "https://example.invalid/device", + verificationUriComplete: "https://example.invalid/device?code=TEST-CODE", + expiresAtMs: Date.now() + 600_000, + }, + }; + return clone(accountLoginState); + } + if (operation === "getAccountLoginStatus") return clone(accountLoginState); + if (operation === "cancelAccountLogin") { + accountLoginState = { state: "idle" }; + return { ok: true }; + } if (operation === "pluginManagement") { const action = typeof body?.action === "string" ? body.action : ""; return pluginManagementResults[action] ?? {}; @@ -99,7 +127,7 @@ export function installFixtureTransport() { if (operation === "replyQuestionnaire") return { ok: true }; if (operation === "listQueueMessages") return { items: [] }; if (operation === "listModels" || operation === "loadProjects") return []; - if (operation === "getUsageQuota") return {}; + if (operation === "getUsageQuota") return clone(usageQuotaResult); if (operation === "isGoalEnabled") return false; if (operation === "getPermissionMode") return { mode: "default" }; if (operation === "getSessionUsage") return {}; @@ -237,6 +265,8 @@ export function installFixtureTransport() { // is inert on purpose, so a staged questionnaire stays pending until the // test answers, dismisses, or replaces it. setQuestionnaire(request) { questionnaire = request ? clone(request) : undefined; }, + setAccountLoginState(state) { accountLoginState = clone(state); }, + setUsageQuotaResult(result) { usageQuotaResult = clone(result); }, setPluginManagementResult(action, result) { pluginManagementResults[action] = result === undefined ? undefined : clone(result); },