Skip to content

feat(webui): device-authorization login, a sign-out that signs out, and account switching (N) - #29

Merged
stevenjj33 merged 1 commit into
webuifrom
fix/webui-login-account
Oct 5, 2026
Merged

stevenjj33 merged 1 commit into
webuifrom
fix/webui-login-account

Conversation

@stevenjj33

Copy link
Copy Markdown
Collaborator

Change

Roadmap N(登录与账号): the two 🟡 rows — 登录方式 and 登出·账号切换(身份展示 ✅ untouched). Baseline: webui @ e35940c (verified against the fetched origin/webui HEAD at start of work, matching the number read at 22:33).

What was actually wrong (both reproduced/read before any change):

  1. No login at all. The WebUI served whatever OAuth credential mcode login (or the desktop) had already written into the shared store. The usage panel literally said 「登录后查看用量」 with no way to log in; after a sign-out the only way back was leaving the browser.
  2. 退出登录 didn't sign out. The button ran the host's invalidateAuth, which clears the cli-auth projection and the in-process context — the OAuth credential itself survived, and the next lease refresh (refreshOAuthAuthContext) re-authenticated immediately. Neither row could work while the credential was undeletable from the UI.

The fix — one surface, server-first:

  • Server (account-login.ts + assembly): the assembly already builds one MCodeOAuthCore for quota leases; the account session wraps the same core — one credential store, one watch. beginAccountLogin starts (or re-attaches to — the core dedupes login() on its own promise) a device-authorization login, the same flow the terminal client uses, and resolves with the prompt (user code + verification URL) without waiting for the human. getAccountLoginStatus folds the settled outcome / in-flight prompt / persisted store (a terminal-client login counts). cancelAccountLogin aborts without recording the cancellation as a failure.
  • signOut fixed: the operation now prefers signOutAccount → quotaOauthCore.logout({revoke: true}) (revoke server-side + remove the credential) followed by the existing invalidation; hosts assembled before this change fall back to the historical behaviour instead of failing.
  • 账号切换: from the authenticated state the same dialog offers 切换账号 — sign out (for real), then begin a fresh device flow in the same surface. With a single-credential store this is the honest switch.
  • Client: AccountLoginDialog (portal dialog; the pure AccountLoginPanel is separately testable) — device code, authorization link (complete URI when provided), 2s poll, cancel, expired retry. Two entries: the user menu's 「账号与登录」 item, and a 登录 button in the usage panel's signed-out block.
  • A real-user bug found by the browser spec, fixed here: the usage panel's 登录 button ate first clicks — mousedown gave it focus, focusin re-triggered the anchor's onFocus → loadUsage, whose loading skeleton replaced the pressed node mid-click, and Chromium does not dispatch click when the mousedown node is disconnected (event-trace reproduced). The button now prevents mousedown default; the comment in UserMenu.tsx explains the chain.

Validation

  • pnpm test:webui — 91 files / 1750 tests passed, including new 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).
  • npx playwright test — 93/93 passed, including new account-login.spec.mjs (4, harness-imported test): the full device flow with authorization completing via the poll; 切换账号 issuing signOut before the new device flow; the usage-panel entry (which also covers the first-click fix); cancel closing and cancelling.
  • pnpm typecheck:webui-full, pnpm check:source (inventory regenerated), pnpm verify — passed, 20 gates on darwin.
  • Port stubs in webui-service.test.ts / webui-host-shape-invariant.test.ts grow the four members (the invariant class exists precisely to make a forgotten member a compile error).

NOT RUN / boundaries

  • The credential round trip itself (real OAuth server, human at the authorization page) was not exercised: the fixture's login operations are synthetic. What is verified is the browser half — entries, phases, polling, switch path issuing sign-out first — and the server session against a fake core following the MCodeOAuthCore contract (login/cancelLogin/logout/getStatus, source-read). No live-service acceptance claimed.
  • 身份展示 (✅) untouched.

Publication and contribution checks

  • I have permission to contribute these changes under the existing licenses applicable to the changed files/packages; imported material and its provenance are identified and existing notices are preserved.
  • No credentials, account data, real user content, internal source history or private review material is included.
  • Added/removed source files were reviewed before regenerating release/public-source.json; new tests are declared in test/vitest-suites.json where applicable.
  • Shared English/Chinese documentation and capability/verification records are updated where applicable. Mock/offline results are not described as live-service acceptance.

Maintainer handoff

Publication scope or license changes (if any): none.

Shared-source port: not needed.

…nd account switching

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.
@stevenjj33 stevenjj33 added the enhancement New feature or request label Oct 5, 2026
@stevenjj33 stevenjj33 closed this Oct 5, 2026
@stevenjj33 stevenjj33 reopened this Oct 5, 2026
@stevenjj33 stevenjj33 added the enhancement New feature or request label Oct 5, 2026
@stevenjj33
stevenjj33 merged commit f56b26f into webui Oct 5, 2026
12 of 18 checks passed
@stevenjj33
stevenjj33 deleted the fix/webui-login-account branch October 5, 2026 16:02
antianqi added a commit that referenced this pull request Oct 7, 2026
…nect

Every dev-mode upgrade and API call was rejected with 403 Forbidden Origin,
and the shell sat on "WebUI connection failed". Sessions and account state came
back empty because nothing had ever connected: the websocket handshake never
completed.

`isAllowedOrigin` requires the origin's port to equal the bound port, which is
correct for the packaged server — ADR 0004 has it serve the built client from
the same listener, so same-origin is the right rule there. The Vite dev server
necessarily listens on a different port (5173 against 8787), so every request
the browser made carried an origin the check rejected.

Measured, before this change:

    ws://127.0.0.1:8787/ws  Origin http://127.0.0.1:8787  ->  OPEN
    ws://127.0.0.1:5173/ws  Origin http://127.0.0.1:5173  ->  ERROR

After: both OPEN.

The port check is skipped only in dev, and only after the checks above it have
already held — the origin must still be http or https and its host must still be
`127.0.0.1` or `localhost`. A page on another machine still cannot reach the
service.

This is a port of another contributor's uncommitted work on this file, which I
had to drop while syncing the base: their patch no longer applies, because
PR #29 added 153 lines to `service.ts` and PR #32 moved the other two files in
the same change set. The reasoning and the shape are theirs, carried over
rather than rewritten; if they would rather land it themselves, this commit
should be dropped in favour of theirs.

Validation
- pnpm typecheck:webui-full                  exit 0
- run-vitest-suite.mjs webui    10 failed | 1917 passed | 4 skipped

The 10 failures are the unchanged pre-existing Windows baseline. The browser
suite's own harness does not exercise the dev proxy path, so it neither caught
this nor proves the fix; the handshake measurement above is what does.

Not covered here: `vite.config.ts` and `build-webui-styles.mjs` carried related
edits in the same dropped patch. Those address dev-server packaging rather than
the handshake, and neither was needed to restore the connection.
antianqi added a commit that referenced this pull request Oct 9, 2026
…nect

Every dev-mode upgrade and API call was rejected with 403 Forbidden Origin,
and the shell sat on "WebUI connection failed". Sessions and account state came
back empty because nothing had ever connected: the websocket handshake never
completed.

`isAllowedOrigin` requires the origin's port to equal the bound port, which is
correct for the packaged server — ADR 0004 has it serve the built client from
the same listener, so same-origin is the right rule there. The Vite dev server
necessarily listens on a different port (5173 against 8787), so every request
the browser made carried an origin the check rejected.

Measured, before this change:

    ws://127.0.0.1:8787/ws  Origin http://127.0.0.1:8787  ->  OPEN
    ws://127.0.0.1:5173/ws  Origin http://127.0.0.1:5173  ->  ERROR

After: both OPEN.

The port check is skipped only in dev, and only after the checks above it have
already held — the origin must still be http or https and its host must still be
`127.0.0.1` or `localhost`. A page on another machine still cannot reach the
service.

This is a port of another contributor's uncommitted work on this file, which I
had to drop while syncing the base: their patch no longer applies, because
PR #29 added 153 lines to `service.ts` and PR #32 moved the other two files in
the same change set. The reasoning and the shape are theirs, carried over
rather than rewritten; if they would rather land it themselves, this commit
should be dropped in favour of theirs.

Validation
- pnpm typecheck:webui-full                  exit 0
- run-vitest-suite.mjs webui    10 failed | 1917 passed | 4 skipped

The 10 failures are the unchanged pre-existing Windows baseline. The browser
suite's own harness does not exercise the dev proxy path, so it neither caught
this nor proves the fix; the handshake measurement above is what does.

Not covered here: `vite.config.ts` and `build-webui-styles.mjs` carried related
edits in the same dropped patch. Those address dev-server packaging rather than
the handshake, and neither was needed to restore the connection.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant