@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +17 -2
- package/dist/cjs/session/SessionClient.js +181 -10
- package/dist/cjs/session/accountDialogController.js +541 -0
- package/dist/cjs/session/accountProjection.js +131 -0
- package/dist/cjs/session/createSessionClient.js +9 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +11 -0
- package/dist/esm/session/SessionClient.js +181 -10
- package/dist/esm/session/accountDialogController.js +536 -0
- package/dist/esm/session/accountProjection.js +127 -0
- package/dist/esm/session/createSessionClient.js +9 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +4 -0
- package/dist/types/session/SessionClient.d.ts +51 -0
- package/dist/types/session/accountDialogController.d.ts +246 -0
- package/dist/types/session/accountProjection.d.ts +142 -0
- package/dist/types/session/createSessionClient.d.ts +9 -2
- package/package.json +1 -1
- package/src/index.ts +31 -0
- package/src/session/SessionClient.ts +201 -11
- package/src/session/__tests__/SessionClient.signedOut.test.ts +224 -0
- package/src/session/__tests__/accountDialogController.test.ts +469 -0
- package/src/session/__tests__/accountProjection.test.ts +181 -0
- package/src/session/accountDialogController.ts +682 -0
- package/src/session/accountProjection.ts +263 -0
- package/src/session/createSessionClient.ts +9 -2
|
@@ -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
|
+
}
|