@oxyhq/core 7.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,682 @@
1
+ /**
2
+ * Headless controller for the unified Oxy account dialog.
3
+ *
4
+ * A framework-agnostic state machine + subscribe/getSnapshot store (the same
5
+ * pattern {@link SessionClient} uses — no React, no RN) that both
6
+ * `@oxyhq/services` (RN `OxyProvider`) and `@oxyhq/auth` (web `WebOxyProvider`)
7
+ * bind to via `useSyncExternalStore`, so the account chooser is ONE
8
+ * implementation across the ecosystem instead of the five drifting copies it
9
+ * replaces.
10
+ *
11
+ * The controller owns:
12
+ * - the unified account list (via {@link projectSwitchableAccounts}), fetched
13
+ * from `SessionClient` state ∪ `oxyServices.listAccounts()` and hydrated
14
+ * with `oxyServices.getUsersByIds()`;
15
+ * - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add`);
16
+ * - `switchTo` (the uniform switch: `SessionClient.switchAccount` for an
17
+ * account already on the device, `oxyServices.switchToAccount` to mint on
18
+ * first entry into a graph account — reusing the existing SDK primitives, no
19
+ * new switch path);
20
+ * - the "Sign in with Oxy" device flow (same-device shared-keychain via
21
+ * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
22
+ * via `startCommonsSignIn` → poll → `claimSessionByToken`).
23
+ *
24
+ * It deliberately owns NO password/2FA logic — those live at the IdP
25
+ * (auth.oxy.so). {@link AccountDialogController.openPasswordAtOxyAuth} only
26
+ * builds the hand-off URL; device-first convergence syncs the session back.
27
+ */
28
+
29
+ import type { OxyServices } from '../OxyServices';
30
+ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
31
+ import type { User } from '../models/interfaces';
32
+ import { logger } from '../utils/loggerUtils';
33
+ import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
34
+ import { SessionClient } from './SessionClient';
35
+ import {
36
+ projectSwitchableAccounts,
37
+ switchableAccountIds,
38
+ type SwitchableAccount,
39
+ } from './accountProjection';
40
+ import type { AccountNode } from '../mixins/OxyServices.accounts';
41
+
42
+ /** The dialog's top-level view. */
43
+ export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add';
44
+
45
+ /** Lifecycle phase of the "Sign in with Oxy" device flow. */
46
+ export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
47
+
48
+ /** State of the "Sign in with Oxy" (shared-key / QR) device flow. */
49
+ export interface SignInFlowState {
50
+ phase: SignInFlowPhase;
51
+ /**
52
+ * The PUBLIC, single-use authorize code (safe to display), or `null`. NOT the
53
+ * secret `sessionToken` — the approver resolves the app identity from this.
54
+ */
55
+ authorizeCode: string | null;
56
+ /**
57
+ * The structured deep-link / QR payload (`oxycommons://approve?...`) to render
58
+ * as a QR (cross-device) and open as a deep link (same-device), or `null`.
59
+ */
60
+ qrPayload: string | null;
61
+ /** Server-authoritative expiry (epoch ms), or `null`. */
62
+ expiresAt: number | null;
63
+ /** Human-readable error for the retry UI, or `null`. */
64
+ error: string | null;
65
+ }
66
+
67
+ /** Immutable snapshot consumed by `useSyncExternalStore`. */
68
+ export interface AccountDialogSnapshot {
69
+ /** The current view. */
70
+ view: AccountDialogView;
71
+ /** The unified, deduped account list (device sign-ins ∪ graph accounts). */
72
+ accounts: SwitchableAccount[];
73
+ /** The currently-active account id, or `null` when signed out. */
74
+ activeAccountId: string | null;
75
+ /** `true` while the initial account-list fetch is in flight with no data yet. */
76
+ loading: boolean;
77
+ /** A human-readable account-list error, or `null`. */
78
+ error: string | null;
79
+ /** The `accountId` of an in-flight switch, or `null`. */
80
+ switchingAccountId: string | null;
81
+ /** The "Sign in with Oxy" device-flow state. */
82
+ signIn: SignInFlowState;
83
+ }
84
+
85
+ /** Construction options for {@link AccountDialogController}. */
86
+ export interface AccountDialogControllerOptions {
87
+ /** The API client. Source of graph accounts, profiles, and the sign-in methods. */
88
+ oxyServices: OxyServices;
89
+ /** The device-first session authority. Source of device rows + the switch path. */
90
+ sessionClient: SessionClient;
91
+ /**
92
+ * The RP's registered OAuth client id (ApplicationCredential publicKey).
93
+ * Required for the QR handoff (`startCommonsSignIn`); when absent, `showQr`
94
+ * fails with a clear configuration error instead of creating a session the
95
+ * server would reject.
96
+ */
97
+ clientId?: string | null;
98
+ /** Locale for display-name resolution. */
99
+ locale?: string;
100
+ /**
101
+ * Commit a freshly-authorized session (device flow / shared identity / minted
102
+ * graph switch) into the host's session set — device-first registration +
103
+ * durable persist + profile hydration. The consumer supplies its provider's
104
+ * commit path (`useOxy().handleWebSession` / the auth-sdk equivalent). Called
105
+ * AFTER the SDK has planted the access token. When omitted the controller
106
+ * falls back to `SessionClient.registerAndActivate` (registration + activation
107
+ * only — no provider-side durable persist/hydration).
108
+ */
109
+ commitSession?: (session: SessionLoginResponse & { refreshToken?: string }) => Promise<void>;
110
+ /** Notified after a completed sign-in (bearer planted + session committed). */
111
+ onSignedIn?: (user: MinimalUserData) => void;
112
+ /** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
113
+ idpApex?: string;
114
+ /** QR device-flow poll interval in ms (default 3000). */
115
+ pollIntervalMs?: number;
116
+ /**
117
+ * Optional URL opener. When provided, `openPasswordAtOxyAuth` invokes it with
118
+ * the built URL in addition to returning it (web: `location.assign`; native:
119
+ * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
120
+ */
121
+ openUrl?: (url: string) => void;
122
+ }
123
+
124
+ const DEFAULT_POLL_INTERVAL_MS = 3000;
125
+
126
+ const IDLE_SIGN_IN: SignInFlowState = {
127
+ phase: 'idle',
128
+ authorizeCode: null,
129
+ qrPayload: null,
130
+ expiresAt: null,
131
+ error: null,
132
+ };
133
+
134
+ function errorMessage(error: unknown): string {
135
+ return error instanceof Error ? error.message : String(error);
136
+ }
137
+
138
+ type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
139
+
140
+ export class AccountDialogController {
141
+ private readonly oxyServices: OxyServices;
142
+ private readonly sessionClient: SessionClient;
143
+ private readonly clientId: string | null;
144
+ private readonly locale?: string;
145
+ private readonly commitSession?: (session: SessionLoginResponse & { refreshToken?: string }) => Promise<void>;
146
+ private readonly onSignedIn?: (user: MinimalUserData) => void;
147
+ private readonly idpApex: string;
148
+ private readonly pollIntervalMs: number;
149
+ private readonly openUrl?: (url: string) => void;
150
+
151
+ private readonly listeners = new Set<SnapshotListener>();
152
+
153
+ // --- Internal (unprojected) state ---
154
+ private view: AccountDialogView = 'accounts';
155
+ private graph: AccountNode[] = [];
156
+ private profilesById = new Map<string, User>();
157
+ private loading = false;
158
+ private error: string | null = null;
159
+ private switchingAccountId: string | null = null;
160
+ private signIn: SignInFlowState = IDLE_SIGN_IN;
161
+
162
+ // --- Sign-in device-flow bookkeeping ---
163
+ /** The secret device-flow token of the active QR flow (never surfaced). */
164
+ private signInToken: string | null = null;
165
+ private pollTimer: ReturnType<typeof setTimeout> | null = null;
166
+
167
+ // --- Store plumbing ---
168
+ private unsubscribeSession: (() => void) | null = null;
169
+ private started = false;
170
+ private refreshSeq = 0;
171
+ private snapshot: AccountDialogSnapshot;
172
+
173
+ constructor(options: AccountDialogControllerOptions) {
174
+ this.oxyServices = options.oxyServices;
175
+ this.sessionClient = options.sessionClient;
176
+ this.clientId = options.clientId ?? null;
177
+ this.locale = options.locale;
178
+ this.commitSession = options.commitSession;
179
+ this.onSignedIn = options.onSignedIn;
180
+ this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
181
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
182
+ this.openUrl = options.openUrl;
183
+ this.snapshot = this.computeSnapshot();
184
+ }
185
+
186
+ // =========================================================================
187
+ // Store surface (useSyncExternalStore)
188
+ // =========================================================================
189
+
190
+ /** Returns the current immutable snapshot (stable reference between changes). */
191
+ getSnapshot(): AccountDialogSnapshot {
192
+ return this.snapshot;
193
+ }
194
+
195
+ /** Subscribe to snapshot changes. Returns an unsubscribe function. */
196
+ subscribe(listener: SnapshotListener): () => void {
197
+ this.listeners.add(listener);
198
+ return () => {
199
+ this.listeners.delete(listener);
200
+ };
201
+ }
202
+
203
+ // =========================================================================
204
+ // Lifecycle
205
+ // =========================================================================
206
+
207
+ /**
208
+ * Begin driving the dialog: subscribe to `SessionClient` state and load the
209
+ * account list. Idempotent — a second `start()` is a no-op. Pair with
210
+ * {@link destroy}.
211
+ */
212
+ start(): void {
213
+ if (this.started) return;
214
+ this.started = true;
215
+ this.unsubscribeSession = this.sessionClient.subscribe(() => {
216
+ // A device-state change (switch / sign-out / sibling sign-in) can add or
217
+ // remove accounts — re-project immediately, and refetch profiles when new
218
+ // account ids appeared.
219
+ this.emit();
220
+ void this.ensureProfiles();
221
+ });
222
+ void this.refresh();
223
+ }
224
+
225
+ /**
226
+ * Stop driving the dialog: unsubscribe from `SessionClient` and tear down the
227
+ * active sign-in flow (timers). Idempotent.
228
+ */
229
+ destroy(): void {
230
+ this.started = false;
231
+ if (this.unsubscribeSession) {
232
+ this.unsubscribeSession();
233
+ this.unsubscribeSession = null;
234
+ }
235
+ this.clearPollTimer();
236
+ this.listeners.clear();
237
+ }
238
+
239
+ // =========================================================================
240
+ // View actions
241
+ // =========================================================================
242
+
243
+ /** Set the dialog view directly. */
244
+ setView(view: AccountDialogView): void {
245
+ if (this.view === view) return;
246
+ this.view = view;
247
+ this.emit();
248
+ }
249
+
250
+ /** Return to the account list and cancel any in-flight sign-in flow. */
251
+ close(): void {
252
+ this.cancelSignIn();
253
+ this.setView('accounts');
254
+ }
255
+
256
+ /** Switch to the "add account" view (the sign-in entry chooser). */
257
+ add(): void {
258
+ this.setView('add');
259
+ }
260
+
261
+ // =========================================================================
262
+ // Account list
263
+ // =========================================================================
264
+
265
+ /**
266
+ * Reload the account graph and per-account profiles, then re-project. Safe to
267
+ * call repeatedly; concurrent calls are reconciled by a sequence guard so a
268
+ * slow earlier fetch never overwrites a newer result.
269
+ */
270
+ async refresh(): Promise<void> {
271
+ const seq = ++this.refreshSeq;
272
+ const hadAccounts = this.snapshot.accounts.length > 0;
273
+ this.loading = !hadAccounts;
274
+ this.error = null;
275
+ this.emit();
276
+
277
+ let graph: AccountNode[] = this.graph;
278
+ try {
279
+ graph = await this.oxyServices.listAccounts();
280
+ } catch (error) {
281
+ // A graph-load failure is non-fatal: device rows still render. Surface the
282
+ // message but keep going with whatever graph we already had.
283
+ this.error = errorMessage(error);
284
+ logger.warn('[AccountDialogController] listAccounts failed', { component: 'AccountDialogController' }, error);
285
+ }
286
+ if (seq !== this.refreshSeq) return; // superseded by a newer refresh
287
+
288
+ this.graph = graph;
289
+ await this.loadProfiles(seq);
290
+ if (seq !== this.refreshSeq) return;
291
+
292
+ this.loading = false;
293
+ this.emit();
294
+ }
295
+
296
+ /**
297
+ * Fetch profiles for any account id (device set ∪ graph) not yet resolved.
298
+ * Cheap no-op when everything is already hydrated — used from the session
299
+ * subscription so a newly-added device account gets a name/avatar.
300
+ */
301
+ private async ensureProfiles(): Promise<void> {
302
+ const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
303
+ if (ids.every((id) => this.profilesById.has(id))) return;
304
+ await this.loadProfiles(this.refreshSeq);
305
+ this.emit();
306
+ }
307
+
308
+ private async loadProfiles(seq: number): Promise<void> {
309
+ const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
310
+ if (ids.length === 0) return;
311
+ let profiles: User[] = [];
312
+ try {
313
+ profiles = await this.oxyServices.getUsersByIds(ids);
314
+ } catch (error) {
315
+ // `getUsersByIds` already swallows per-chunk failures and returns `[]`;
316
+ // this guards the unexpected total failure. Non-fatal — keep prior map.
317
+ logger.warn('[AccountDialogController] getUsersByIds failed', { component: 'AccountDialogController' }, error);
318
+ return;
319
+ }
320
+ if (seq !== this.refreshSeq) return; // superseded
321
+ const next = new Map(this.profilesById);
322
+ for (const profile of profiles) {
323
+ next.set(profile.id, profile);
324
+ }
325
+ this.profilesById = next;
326
+ }
327
+
328
+ // =========================================================================
329
+ // Switching (uniform switch model — reuses the existing SDK primitives)
330
+ // =========================================================================
331
+
332
+ /**
333
+ * Switch the active account to `accountId`.
334
+ *
335
+ * Uniform switch model, mirroring the SDK's existing path — NOT a new switch
336
+ * mechanism:
337
+ * - already on this device → `SessionClient.switchAccount` (device-first
338
+ * switch of `/session/device/switch`);
339
+ * - a graph account not yet on the device (first entry) →
340
+ * `oxyServices.switchToAccount` mints + plants a real session and the
341
+ * server registers it into the device set, then it is committed
342
+ * (`commitSession` when supplied, else `SessionClient.registerAndActivate`).
343
+ *
344
+ * The resulting device-state change flows back through the `SessionClient`
345
+ * subscription, which re-projects the active row. Concurrent switches are
346
+ * ignored while one is in flight.
347
+ */
348
+ async switchTo(accountId: string): Promise<void> {
349
+ if (this.switchingAccountId) return;
350
+ this.switchingAccountId = accountId;
351
+ this.error = null;
352
+ this.emit();
353
+ try {
354
+ const state = this.sessionClient.getState();
355
+ const onDevice = state?.accounts.some((account) => account.accountId === accountId) ?? false;
356
+ if (onDevice) {
357
+ await this.sessionClient.switchAccount(accountId);
358
+ } else {
359
+ const result = await this.oxyServices.switchToAccount(accountId);
360
+ if (!result?.user || !result?.sessionId) {
361
+ throw new Error('Account switch did not return a valid session');
362
+ }
363
+ await this.commitAuthorizedSession(
364
+ {
365
+ sessionId: result.sessionId,
366
+ deviceId: result.deviceId,
367
+ expiresAt: result.expiresAt,
368
+ user: result.user,
369
+ accessToken: result.accessToken,
370
+ ...(readRefreshToken(result) ? { refreshToken: readRefreshToken(result) } : {}),
371
+ },
372
+ result.user,
373
+ );
374
+ }
375
+ // Re-project + refetch immediately; the subscription also fires.
376
+ await this.refresh();
377
+ } catch (error) {
378
+ this.error = errorMessage(error);
379
+ } finally {
380
+ this.switchingAccountId = null;
381
+ this.emit();
382
+ }
383
+ }
384
+
385
+ // =========================================================================
386
+ // Sign in with Oxy (device flow — shared keychain, else cross-device QR)
387
+ // =========================================================================
388
+
389
+ /**
390
+ * Start "Sign in with Oxy". Native devices with a shared identity mint a
391
+ * session silently (`signInWithSharedIdentity`); everything else (web, or a
392
+ * native device without a shared identity) falls through to the cross-device
393
+ * QR handoff.
394
+ */
395
+ async signInWithOxy(): Promise<void> {
396
+ this.setView('qr');
397
+ this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
398
+ try {
399
+ const session = await this.oxyServices.signInWithSharedIdentity();
400
+ if (session) {
401
+ await this.completeSignIn(session, session.user);
402
+ return;
403
+ }
404
+ } catch (error) {
405
+ // Shared-key mint failed — log and fall through to the QR handoff rather
406
+ // than dead-ending the sign-in.
407
+ logger.warn('[AccountDialogController] signInWithSharedIdentity failed', { component: 'AccountDialogController' }, error);
408
+ }
409
+ await this.showQr();
410
+ }
411
+
412
+ /**
413
+ * Begin (or restart) the cross-device QR handoff: create a device-flow
414
+ * session, surface its `authorizeCode` + `qrPayload`, and poll for approval.
415
+ * On approval the secret token is exchanged (`claimSessionByToken`) and the
416
+ * session committed. Requires `clientId`.
417
+ */
418
+ async showQr(): Promise<void> {
419
+ this.cancelSignIn();
420
+ this.setView('qr');
421
+ if (!this.clientId) {
422
+ this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
423
+ return;
424
+ }
425
+ this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
426
+ try {
427
+ const handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
428
+ this.signInToken = handle.sessionToken;
429
+ this.setSignIn({
430
+ phase: 'waiting',
431
+ authorizeCode: handle.authorizeCode,
432
+ qrPayload: handle.qrPayload,
433
+ expiresAt: handle.expiresAt,
434
+ error: null,
435
+ });
436
+ this.scheduleNextPoll(handle.sessionToken);
437
+ } catch (error) {
438
+ this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
439
+ }
440
+ }
441
+
442
+ /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
443
+ cancelSignIn(): void {
444
+ this.clearPollTimer();
445
+ this.signInToken = null;
446
+ if (this.signIn !== IDLE_SIGN_IN) {
447
+ this.setSignIn(IDLE_SIGN_IN);
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
453
+ * password sign-in URL. Password + 2FA are NOT in the SDK — they live at the
454
+ * IdP; this only hands off. Device-first: after login at the IdP the device
455
+ * session converges and the caller is woken via the device socket /
456
+ * `BroadcastChannel`, so the URL only needs to point at the IdP sign-in with
457
+ * the right return.
458
+ *
459
+ * @param params.returnUrl - Where the IdP returns after login. Defaults to the
460
+ * current document URL on web (`globalThis.location.href`); pass explicitly
461
+ * on native (no `location`).
462
+ * @param params.state - Optional opaque state echoed back on return.
463
+ * @returns The absolute auth.oxy.so sign-in URL.
464
+ */
465
+ openPasswordAtOxyAuth(params: { returnUrl?: string; state?: string } = {}): string {
466
+ const base = `https://auth.${this.idpApex}`;
467
+ const url = new URL('/login', base);
468
+ const returnUrl = params.returnUrl ?? currentLocationHref();
469
+ if (returnUrl) {
470
+ url.searchParams.set('redirect_uri', returnUrl);
471
+ }
472
+ if (this.clientId) {
473
+ url.searchParams.set('client_id', this.clientId);
474
+ }
475
+ if (params.state) {
476
+ url.searchParams.set('state', params.state);
477
+ }
478
+ const href = url.toString();
479
+ this.openUrl?.(href);
480
+ return href;
481
+ }
482
+
483
+ // =========================================================================
484
+ // Internal sign-in helpers
485
+ // =========================================================================
486
+
487
+ private scheduleNextPoll(sessionToken: string): void {
488
+ this.clearPollTimer();
489
+ this.pollTimer = setTimeout(() => {
490
+ void this.pollOnce(sessionToken);
491
+ }, this.pollIntervalMs);
492
+ }
493
+
494
+ private async pollOnce(sessionToken: string): Promise<void> {
495
+ // A superseded / cancelled flow must not act.
496
+ if (this.signInToken !== sessionToken) return;
497
+ const expiresAt = this.signIn.expiresAt;
498
+ if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
499
+ this.failSignIn('Session expired. Please try again.');
500
+ return;
501
+ }
502
+ try {
503
+ const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
504
+ if (this.signInToken !== sessionToken) return; // cancelled mid-request
505
+ if (status.authorized && status.sessionId) {
506
+ this.clearPollTimer();
507
+ await this.claimAndComplete(status.sessionId, sessionToken);
508
+ return;
509
+ }
510
+ if (status.status === 'cancelled') {
511
+ this.failSignIn('Authorization was denied.');
512
+ return;
513
+ }
514
+ if (status.status === 'expired') {
515
+ this.failSignIn('Session expired. Please try again.');
516
+ return;
517
+ }
518
+ } catch (error) {
519
+ // Transient poll error — the next tick retries. Logged, never thrown.
520
+ logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
521
+ }
522
+ if (this.signInToken === sessionToken) {
523
+ this.scheduleNextPoll(sessionToken);
524
+ }
525
+ }
526
+
527
+ private async claimAndComplete(sessionId: string, sessionToken: string): Promise<void> {
528
+ this.setSignIn({ ...this.signIn, phase: 'authorized' });
529
+ let claimed: { accessToken: string; sessionId: string; deviceId: string; expiresAt: string; user: User };
530
+ try {
531
+ claimed = await this.oxyServices.claimSessionByToken(sessionToken);
532
+ } catch (error) {
533
+ this.failSignIn(errorMessage(error));
534
+ return;
535
+ }
536
+ if (!claimed?.accessToken || !claimed.user) {
537
+ this.failSignIn('Authorization succeeded but the session could not be claimed. Please try again.');
538
+ return;
539
+ }
540
+ // `SessionLoginResponse.user` is the minimal session-carried shape; the claim
541
+ // returns the full `User` (avatar is `string | null | undefined`). Normalize
542
+ // rather than widening the minimal shape to accept `null`.
543
+ const minimalUser: MinimalUserData = {
544
+ id: claimed.user.id,
545
+ username: claimed.user.username,
546
+ name: claimed.user.name,
547
+ avatar: claimed.user.avatar ?? undefined,
548
+ };
549
+ const refreshToken = readRefreshToken(claimed);
550
+ try {
551
+ await this.completeSignIn(
552
+ {
553
+ sessionId: claimed.sessionId || sessionId,
554
+ deviceId: claimed.deviceId ?? '',
555
+ expiresAt: claimed.expiresAt ?? '',
556
+ user: minimalUser,
557
+ accessToken: claimed.accessToken,
558
+ ...(refreshToken ? { refreshToken } : {}),
559
+ },
560
+ minimalUser,
561
+ );
562
+ } catch (error) {
563
+ this.failSignIn(errorMessage(error));
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Commit an authorized session, notify, and return to the account list. Shared
569
+ * by the shared-key, QR, and mint-switch paths so they cannot drift.
570
+ */
571
+ private async completeSignIn(
572
+ session: SessionLoginResponse & { refreshToken?: string },
573
+ user: MinimalUserData,
574
+ ): Promise<void> {
575
+ await this.commitAuthorizedSession(session, user);
576
+ this.signInToken = null;
577
+ this.clearPollTimer();
578
+ this.signIn = IDLE_SIGN_IN;
579
+ this.view = 'accounts';
580
+ this.emit();
581
+ this.onSignedIn?.(user);
582
+ await this.refresh();
583
+ }
584
+
585
+ /**
586
+ * Register a token-planted session into the device set. Prefers the
587
+ * consumer's `commitSession` (durable persist + hydration); falls back to
588
+ * `SessionClient.registerAndActivate` (registration + activation only).
589
+ */
590
+ private async commitAuthorizedSession(
591
+ session: SessionLoginResponse & { refreshToken?: string },
592
+ user: MinimalUserData,
593
+ ): Promise<void> {
594
+ if (this.commitSession) {
595
+ await this.commitSession(session);
596
+ } else {
597
+ await this.sessionClient.registerAndActivate(user.id);
598
+ }
599
+ }
600
+
601
+ private failSignIn(message: string): void {
602
+ this.clearPollTimer();
603
+ this.signInToken = null;
604
+ this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: message });
605
+ }
606
+
607
+ private clearPollTimer(): void {
608
+ if (this.pollTimer !== null) {
609
+ clearTimeout(this.pollTimer);
610
+ this.pollTimer = null;
611
+ }
612
+ }
613
+
614
+ // =========================================================================
615
+ // Snapshot plumbing
616
+ // =========================================================================
617
+
618
+ private setSignIn(next: SignInFlowState): void {
619
+ this.signIn = next;
620
+ this.emit();
621
+ }
622
+
623
+ private computeSnapshot(): AccountDialogSnapshot {
624
+ const state = this.sessionClient.getState();
625
+ return {
626
+ view: this.view,
627
+ accounts: projectSwitchableAccounts({
628
+ state,
629
+ graph: this.graph,
630
+ profilesById: this.profilesById,
631
+ locale: this.locale,
632
+ resolveAvatarUrl: (avatar) =>
633
+ (avatar ? this.oxyServices.getFileDownloadUrl(avatar, 'thumb') : undefined),
634
+ }),
635
+ activeAccountId: state?.activeAccountId ?? null,
636
+ loading: this.loading,
637
+ error: this.error,
638
+ switchingAccountId: this.switchingAccountId,
639
+ signIn: this.signIn,
640
+ };
641
+ }
642
+
643
+ /** Recompute the snapshot and notify subscribers. */
644
+ private emit(): void {
645
+ this.snapshot = this.computeSnapshot();
646
+ for (const listener of this.listeners) {
647
+ try {
648
+ listener(this.snapshot);
649
+ } catch (error) {
650
+ logger.error('[AccountDialogController] subscriber threw', error);
651
+ }
652
+ }
653
+ }
654
+ }
655
+
656
+ /** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
657
+ export function createAccountDialogController(
658
+ options: AccountDialogControllerOptions,
659
+ ): AccountDialogController {
660
+ return new AccountDialogController(options);
661
+ }
662
+
663
+ // ---------------------------------------------------------------------------
664
+ // Local helpers
665
+ // ---------------------------------------------------------------------------
666
+
667
+ /**
668
+ * The rotating refresh-token family head is threaded on the runtime object by
669
+ * the trusted device-flow / switch lanes even though it is NOT on the typed
670
+ * return of `claimSessionByToken` / `switchToAccount`. Read it defensively so
671
+ * the commit funnel can persist a durable session.
672
+ */
673
+ function readRefreshToken(value: unknown): string | undefined {
674
+ const token = (value as { refreshToken?: unknown }).refreshToken;
675
+ return typeof token === 'string' ? token : undefined;
676
+ }
677
+
678
+ /** Current document URL on web; empty string where `location` is absent (native/SSR). */
679
+ function currentLocationHref(): string {
680
+ const location = (globalThis as { location?: { href?: string } }).location;
681
+ return typeof location?.href === 'string' ? location.href : '';
682
+ }