Skip to content

Commit b092627

Browse files
feat(webui): add account sign-in and switching
1 parent 97818b2 commit b092627

37 files changed

Lines changed: 1462 additions & 1177 deletions

‎packages/webui/docs/runtime/boot.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ reduced to passing capability flags into the host.
1616
(`runtime-environment.ts`) *before* the host is built, so `getRuntimeRegion()`,
1717
`getRuntimeBuildEnv()` and `isManagedRuntime()` see the same scope the credential reader served.
1818
The WebUI does this for itself because ADR 0003 forbids importing the terminal client's module. A
19-
start with no client-side login falls back to the harness defaults (`en` / `dev`, not managed).
19+
A complete explicit environment pair wins, otherwise it resolves the live OAuth namespace. A
20+
fresh install with no account scope defaults to `cn/prod`, so the login page reaches the
21+
production account service instead of the old `en/dev` placeholder endpoint.
2022
3. **Create the one OAuth core, its lease and its refresh timer** (`auth-session.ts`). It reads the
2123
shared `mcode login` credential, so a browser session never signs in again. A failure here aborts
2224
the boot before any host exists.

‎packages/webui/src/client/features/account/application/account-workflows.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,14 @@ export interface WebuiAccountWorkflows {
399399
/** Claim today's check-in; records the result for the menu's animation. */
400400
readonly claimSignin: () => Promise<void>;
401401

402+
/**
403+
* Read the persisted login state without starting a device flow.
404+
*
405+
* The login page asks this first: an account that is already signed in is
406+
* offered the switch, not a second sign-in. It reads the store and changes
407+
* nothing.
408+
*/
409+
readonly loadLoginStatus: () => Promise<void>;
402410
/** Begin a device-authorization flow and start polling its status. */
403411
readonly beginLogin: () => Promise<void>;
404412
/** Cancel the in-flight attempt, stop polling and reset the view to idle. */
@@ -622,6 +630,18 @@ export function createWebuiAccountWorkflows(deps: {
622630
);
623631
};
624632

633+
const loadLoginStatus = async (): Promise<void> => {
634+
const read = port.getAccountLoginStatus;
635+
if (!read) return;
636+
try {
637+
const view = await read();
638+
applyLoginView(view);
639+
} catch {
640+
// A status that cannot be read leaves the page where it is. The page
641+
// offers a sign-in, and the attempt that follows reports its own error.
642+
}
643+
};
644+
625645
const beginLogin = async (): Promise<void> => {
626646
stopLoginPolling();
627647
const begin = port.beginAccountLogin;
@@ -1183,6 +1203,12 @@ export function createWebuiAccountWorkflows(deps: {
11831203
// identity cannot land as current.
11841204
lastCredentialFingerprint = undefined;
11851205
afterCredentialChange();
1206+
// The sign-in state belonged to the account that just left. A page that
1207+
// still reads as signed in would offer a switch instead of a sign-in.
1208+
set((current) => ({
1209+
...current,
1210+
login: { ...current.login, view: { state: "idle" } },
1211+
}));
11861212
// The account and quota answers belong to the account that just left:
11871213
// re-read both so no surface keeps showing the previous identity.
11881214
await Promise.all([loadAccount(), loadUsage(true)]);
@@ -1257,6 +1283,7 @@ export function createWebuiAccountWorkflows(deps: {
12571283
redeemResetCard,
12581284
loadSigninPanel,
12591285
claimSignin,
1286+
loadLoginStatus,
12601287
beginLogin,
12611288
cancelLogin,
12621289
stopLoginPolling,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Which phase the account login page is in.
2+
//
3+
// The Desktop's login page is a state machine of its own: signed out, opening
4+
// the browser, waiting for the authorization, failed. This module derives
5+
// those states from the server's answer so the page renders a phase rather
6+
// than reading the answer's shape.
7+
//
8+
// It is a separate derivation from `login-phase.ts` on purpose. The compact
9+
// sign-in dialog and the page are two different surfaces over one answer: the
10+
// dialog opens a flow the moment it appears, so "idle" is a state it passes
11+
// through and never rests in, while the page waits for the user to ask for a
12+
// sign-in — so "idle" is the page's own resting state, and it means the
13+
// account is signed out.
14+
15+
import type { WebuiAccountLoginView } from "../../../../shared/contracts/account.js";
16+
17+
/** Everything the login page can show, derived (not fetched). */
18+
export type WebuiAccountLoginPagePhase =
19+
/** Nobody is signed in; the page offers the sign-in. */
20+
| { readonly kind: "signed-out" }
21+
/** A device flow is running and the code has not arrived yet. */
22+
| { readonly kind: "opening" }
23+
/** A device code is waiting; the authorization page can be opened. */
24+
| {
25+
readonly kind: "awaiting";
26+
readonly verificationUri: string;
27+
}
28+
/** The account is signed in. */
29+
| { readonly kind: "authenticated" }
30+
/** The attempt failed. */
31+
| { readonly kind: "error"; readonly error: string };
32+
33+
/**
34+
* Derive the page's phase from the server's answer.
35+
*
36+
* `undefined` means the answer has not arrived yet. It reads as "signed out"
37+
* because that is the state the page rests in, and the page disables its call
38+
* to action until the answer lands — a page that offered a sign-in before it
39+
* knew whether one was needed would offer a second sign-in to somebody who is
40+
* already signed in.
41+
*/
42+
export function deriveWebuiAccountLoginPagePhase(
43+
view: WebuiAccountLoginView | undefined,
44+
): WebuiAccountLoginPagePhase {
45+
if (!view) return { kind: "signed-out" };
46+
switch (view.state) {
47+
case "authenticated":
48+
return { kind: "authenticated" };
49+
case "error":
50+
return { kind: "error", error: view.error };
51+
case "pending": {
52+
// The complete URL carries the code, so it is the one to open; the bare
53+
// verification URL asks for the code to be typed in.
54+
const uri = view.prompt?.verificationUriComplete ?? view.prompt?.verificationUri;
55+
return uri ? { kind: "awaiting", verificationUri: uri } : { kind: "opening" };
56+
}
57+
default:
58+
return { kind: "signed-out" };
59+
}
60+
}

‎packages/webui/src/client/features/account/application/login-phase.ts‎

Lines changed: 0 additions & 50 deletions
This file was deleted.

‎packages/webui/src/client/features/account/bindings/use-account-workflows.public.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// transport method for the account operations.
99
//
1010
// Three peers read through these hooks: the account business's `UserMenu` and
11-
// `AccountLoginDialog` need the workflow handle and the live snapshot to drive
11+
// `AccountLoginPage` need the workflow handle and the live snapshot to drive
1212
// their panels, the settings dialog mounts both the workflows and the state
1313
// to drive its account tab and the sign-out button, and the composer's draft
1414
// loop watches the workflows state so its banner tracks the signed-in user.

‎packages/webui/src/client/features/account/ui/AccountLoginDialog.tsx‎

Lines changed: 0 additions & 195 deletions
This file was deleted.

0 commit comments

Comments
 (0)