@oxyhq/core 6.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/HttpService.js +1 -1
- package/dist/cjs/index.js +32 -11
- package/dist/cjs/mixins/OxyServices.accounts.js +7 -13
- package/dist/cjs/mixins/index.js +0 -5
- package/dist/cjs/server/index.js +2 -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/cjs/shared/utils/debugUtils.js +3 -3
- package/dist/cjs/utils/authWebUrl.js +10 -30
- package/dist/cjs/utils/coldBoot.js +10 -8
- package/dist/cjs/utils/{fapiAutoDetect.js → registrableApex.js} +8 -10
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +1 -1
- package/dist/esm/index.js +26 -8
- package/dist/esm/mixins/OxyServices.accounts.js +7 -13
- package/dist/esm/mixins/index.js +0 -5
- package/dist/esm/server/index.js +1 -1
- 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/esm/shared/utils/debugUtils.js +3 -3
- package/dist/esm/utils/authWebUrl.js +9 -29
- package/dist/esm/utils/coldBoot.js +10 -8
- package/dist/esm/utils/{fapiAutoDetect.js → registrableApex.js} +8 -10
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +1 -1
- package/dist/types/index.d.ts +6 -3
- package/dist/types/mixins/OxyServices.accounts.d.ts +24 -27
- package/dist/types/mixins/index.d.ts +1 -2
- package/dist/types/models/session.d.ts +4 -5
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/session/SessionClient.d.ts +52 -1
- 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/dist/types/shared/utils/debugUtils.d.ts +3 -3
- package/dist/types/utils/accountUtils.d.ts +2 -3
- package/dist/types/utils/authWebUrl.d.ts +9 -29
- package/dist/types/utils/coldBoot.d.ts +17 -14
- package/dist/types/utils/{fapiAutoDetect.d.ts → registrableApex.d.ts} +8 -10
- package/package.json +2 -2
- package/src/HttpService.ts +1 -1
- package/src/index.ts +43 -11
- package/src/mixins/OxyServices.accounts.ts +21 -26
- package/src/mixins/index.ts +0 -7
- package/src/models/session.ts +4 -5
- package/src/server/index.ts +1 -1
- package/src/session/SessionClient.ts +202 -12
- 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
- package/src/shared/utils/debugUtils.ts +3 -3
- package/src/utils/__tests__/authWebUrl.test.ts +5 -16
- package/src/utils/__tests__/{fapiAutoDetect.test.ts → registrableApex.test.ts} +1 -1
- package/src/utils/accountUtils.ts +2 -3
- package/src/utils/authWebUrl.ts +9 -30
- package/src/utils/coldBoot.ts +17 -14
- package/src/utils/{fapiAutoDetect.ts → registrableApex.ts} +8 -10
- package/dist/cjs/mixins/OxyServices.authorizedApps.js +0 -38
- package/dist/esm/mixins/OxyServices.authorizedApps.js +0 -35
- package/dist/types/mixins/OxyServices.authorizedApps.d.ts +0 -94
- package/src/mixins/OxyServices.authorizedApps.ts +0 -75
- package/src/mixins/__tests__/authorizedApps.test.ts +0 -63
|
@@ -0,0 +1,536 @@
|
|
|
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
|
+
import { logger } from '../utils/loggerUtils.js';
|
|
29
|
+
import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
|
|
30
|
+
import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
|
|
31
|
+
const DEFAULT_POLL_INTERVAL_MS = 3000;
|
|
32
|
+
const IDLE_SIGN_IN = {
|
|
33
|
+
phase: 'idle',
|
|
34
|
+
authorizeCode: null,
|
|
35
|
+
qrPayload: null,
|
|
36
|
+
expiresAt: null,
|
|
37
|
+
error: null,
|
|
38
|
+
};
|
|
39
|
+
function errorMessage(error) {
|
|
40
|
+
return error instanceof Error ? error.message : String(error);
|
|
41
|
+
}
|
|
42
|
+
export class AccountDialogController {
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.listeners = new Set();
|
|
45
|
+
// --- Internal (unprojected) state ---
|
|
46
|
+
this.view = 'accounts';
|
|
47
|
+
this.graph = [];
|
|
48
|
+
this.profilesById = new Map();
|
|
49
|
+
this.loading = false;
|
|
50
|
+
this.error = null;
|
|
51
|
+
this.switchingAccountId = null;
|
|
52
|
+
this.signIn = IDLE_SIGN_IN;
|
|
53
|
+
// --- Sign-in device-flow bookkeeping ---
|
|
54
|
+
/** The secret device-flow token of the active QR flow (never surfaced). */
|
|
55
|
+
this.signInToken = null;
|
|
56
|
+
this.pollTimer = null;
|
|
57
|
+
// --- Store plumbing ---
|
|
58
|
+
this.unsubscribeSession = null;
|
|
59
|
+
this.started = false;
|
|
60
|
+
this.refreshSeq = 0;
|
|
61
|
+
this.oxyServices = options.oxyServices;
|
|
62
|
+
this.sessionClient = options.sessionClient;
|
|
63
|
+
this.clientId = options.clientId ?? null;
|
|
64
|
+
this.locale = options.locale;
|
|
65
|
+
this.commitSession = options.commitSession;
|
|
66
|
+
this.onSignedIn = options.onSignedIn;
|
|
67
|
+
this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
|
|
68
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
69
|
+
this.openUrl = options.openUrl;
|
|
70
|
+
this.snapshot = this.computeSnapshot();
|
|
71
|
+
}
|
|
72
|
+
// =========================================================================
|
|
73
|
+
// Store surface (useSyncExternalStore)
|
|
74
|
+
// =========================================================================
|
|
75
|
+
/** Returns the current immutable snapshot (stable reference between changes). */
|
|
76
|
+
getSnapshot() {
|
|
77
|
+
return this.snapshot;
|
|
78
|
+
}
|
|
79
|
+
/** Subscribe to snapshot changes. Returns an unsubscribe function. */
|
|
80
|
+
subscribe(listener) {
|
|
81
|
+
this.listeners.add(listener);
|
|
82
|
+
return () => {
|
|
83
|
+
this.listeners.delete(listener);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// =========================================================================
|
|
87
|
+
// Lifecycle
|
|
88
|
+
// =========================================================================
|
|
89
|
+
/**
|
|
90
|
+
* Begin driving the dialog: subscribe to `SessionClient` state and load the
|
|
91
|
+
* account list. Idempotent — a second `start()` is a no-op. Pair with
|
|
92
|
+
* {@link destroy}.
|
|
93
|
+
*/
|
|
94
|
+
start() {
|
|
95
|
+
if (this.started)
|
|
96
|
+
return;
|
|
97
|
+
this.started = true;
|
|
98
|
+
this.unsubscribeSession = this.sessionClient.subscribe(() => {
|
|
99
|
+
// A device-state change (switch / sign-out / sibling sign-in) can add or
|
|
100
|
+
// remove accounts — re-project immediately, and refetch profiles when new
|
|
101
|
+
// account ids appeared.
|
|
102
|
+
this.emit();
|
|
103
|
+
void this.ensureProfiles();
|
|
104
|
+
});
|
|
105
|
+
void this.refresh();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Stop driving the dialog: unsubscribe from `SessionClient` and tear down the
|
|
109
|
+
* active sign-in flow (timers). Idempotent.
|
|
110
|
+
*/
|
|
111
|
+
destroy() {
|
|
112
|
+
this.started = false;
|
|
113
|
+
if (this.unsubscribeSession) {
|
|
114
|
+
this.unsubscribeSession();
|
|
115
|
+
this.unsubscribeSession = null;
|
|
116
|
+
}
|
|
117
|
+
this.clearPollTimer();
|
|
118
|
+
this.listeners.clear();
|
|
119
|
+
}
|
|
120
|
+
// =========================================================================
|
|
121
|
+
// View actions
|
|
122
|
+
// =========================================================================
|
|
123
|
+
/** Set the dialog view directly. */
|
|
124
|
+
setView(view) {
|
|
125
|
+
if (this.view === view)
|
|
126
|
+
return;
|
|
127
|
+
this.view = view;
|
|
128
|
+
this.emit();
|
|
129
|
+
}
|
|
130
|
+
/** Return to the account list and cancel any in-flight sign-in flow. */
|
|
131
|
+
close() {
|
|
132
|
+
this.cancelSignIn();
|
|
133
|
+
this.setView('accounts');
|
|
134
|
+
}
|
|
135
|
+
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
136
|
+
add() {
|
|
137
|
+
this.setView('add');
|
|
138
|
+
}
|
|
139
|
+
// =========================================================================
|
|
140
|
+
// Account list
|
|
141
|
+
// =========================================================================
|
|
142
|
+
/**
|
|
143
|
+
* Reload the account graph and per-account profiles, then re-project. Safe to
|
|
144
|
+
* call repeatedly; concurrent calls are reconciled by a sequence guard so a
|
|
145
|
+
* slow earlier fetch never overwrites a newer result.
|
|
146
|
+
*/
|
|
147
|
+
async refresh() {
|
|
148
|
+
const seq = ++this.refreshSeq;
|
|
149
|
+
const hadAccounts = this.snapshot.accounts.length > 0;
|
|
150
|
+
this.loading = !hadAccounts;
|
|
151
|
+
this.error = null;
|
|
152
|
+
this.emit();
|
|
153
|
+
let graph = this.graph;
|
|
154
|
+
try {
|
|
155
|
+
graph = await this.oxyServices.listAccounts();
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
// A graph-load failure is non-fatal: device rows still render. Surface the
|
|
159
|
+
// message but keep going with whatever graph we already had.
|
|
160
|
+
this.error = errorMessage(error);
|
|
161
|
+
logger.warn('[AccountDialogController] listAccounts failed', { component: 'AccountDialogController' }, error);
|
|
162
|
+
}
|
|
163
|
+
if (seq !== this.refreshSeq)
|
|
164
|
+
return; // superseded by a newer refresh
|
|
165
|
+
this.graph = graph;
|
|
166
|
+
await this.loadProfiles(seq);
|
|
167
|
+
if (seq !== this.refreshSeq)
|
|
168
|
+
return;
|
|
169
|
+
this.loading = false;
|
|
170
|
+
this.emit();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Fetch profiles for any account id (device set ∪ graph) not yet resolved.
|
|
174
|
+
* Cheap no-op when everything is already hydrated — used from the session
|
|
175
|
+
* subscription so a newly-added device account gets a name/avatar.
|
|
176
|
+
*/
|
|
177
|
+
async ensureProfiles() {
|
|
178
|
+
const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
|
|
179
|
+
if (ids.every((id) => this.profilesById.has(id)))
|
|
180
|
+
return;
|
|
181
|
+
await this.loadProfiles(this.refreshSeq);
|
|
182
|
+
this.emit();
|
|
183
|
+
}
|
|
184
|
+
async loadProfiles(seq) {
|
|
185
|
+
const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
|
|
186
|
+
if (ids.length === 0)
|
|
187
|
+
return;
|
|
188
|
+
let profiles = [];
|
|
189
|
+
try {
|
|
190
|
+
profiles = await this.oxyServices.getUsersByIds(ids);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
// `getUsersByIds` already swallows per-chunk failures and returns `[]`;
|
|
194
|
+
// this guards the unexpected total failure. Non-fatal — keep prior map.
|
|
195
|
+
logger.warn('[AccountDialogController] getUsersByIds failed', { component: 'AccountDialogController' }, error);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (seq !== this.refreshSeq)
|
|
199
|
+
return; // superseded
|
|
200
|
+
const next = new Map(this.profilesById);
|
|
201
|
+
for (const profile of profiles) {
|
|
202
|
+
next.set(profile.id, profile);
|
|
203
|
+
}
|
|
204
|
+
this.profilesById = next;
|
|
205
|
+
}
|
|
206
|
+
// =========================================================================
|
|
207
|
+
// Switching (uniform switch model — reuses the existing SDK primitives)
|
|
208
|
+
// =========================================================================
|
|
209
|
+
/**
|
|
210
|
+
* Switch the active account to `accountId`.
|
|
211
|
+
*
|
|
212
|
+
* Uniform switch model, mirroring the SDK's existing path — NOT a new switch
|
|
213
|
+
* mechanism:
|
|
214
|
+
* - already on this device → `SessionClient.switchAccount` (device-first
|
|
215
|
+
* switch of `/session/device/switch`);
|
|
216
|
+
* - a graph account not yet on the device (first entry) →
|
|
217
|
+
* `oxyServices.switchToAccount` mints + plants a real session and the
|
|
218
|
+
* server registers it into the device set, then it is committed
|
|
219
|
+
* (`commitSession` when supplied, else `SessionClient.registerAndActivate`).
|
|
220
|
+
*
|
|
221
|
+
* The resulting device-state change flows back through the `SessionClient`
|
|
222
|
+
* subscription, which re-projects the active row. Concurrent switches are
|
|
223
|
+
* ignored while one is in flight.
|
|
224
|
+
*/
|
|
225
|
+
async switchTo(accountId) {
|
|
226
|
+
if (this.switchingAccountId)
|
|
227
|
+
return;
|
|
228
|
+
this.switchingAccountId = accountId;
|
|
229
|
+
this.error = null;
|
|
230
|
+
this.emit();
|
|
231
|
+
try {
|
|
232
|
+
const state = this.sessionClient.getState();
|
|
233
|
+
const onDevice = state?.accounts.some((account) => account.accountId === accountId) ?? false;
|
|
234
|
+
if (onDevice) {
|
|
235
|
+
await this.sessionClient.switchAccount(accountId);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
const result = await this.oxyServices.switchToAccount(accountId);
|
|
239
|
+
if (!result?.user || !result?.sessionId) {
|
|
240
|
+
throw new Error('Account switch did not return a valid session');
|
|
241
|
+
}
|
|
242
|
+
await this.commitAuthorizedSession({
|
|
243
|
+
sessionId: result.sessionId,
|
|
244
|
+
deviceId: result.deviceId,
|
|
245
|
+
expiresAt: result.expiresAt,
|
|
246
|
+
user: result.user,
|
|
247
|
+
accessToken: result.accessToken,
|
|
248
|
+
...(readRefreshToken(result) ? { refreshToken: readRefreshToken(result) } : {}),
|
|
249
|
+
}, result.user);
|
|
250
|
+
}
|
|
251
|
+
// Re-project + refetch immediately; the subscription also fires.
|
|
252
|
+
await this.refresh();
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
this.error = errorMessage(error);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
this.switchingAccountId = null;
|
|
259
|
+
this.emit();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// =========================================================================
|
|
263
|
+
// Sign in with Oxy (device flow — shared keychain, else cross-device QR)
|
|
264
|
+
// =========================================================================
|
|
265
|
+
/**
|
|
266
|
+
* Start "Sign in with Oxy". Native devices with a shared identity mint a
|
|
267
|
+
* session silently (`signInWithSharedIdentity`); everything else (web, or a
|
|
268
|
+
* native device without a shared identity) falls through to the cross-device
|
|
269
|
+
* QR handoff.
|
|
270
|
+
*/
|
|
271
|
+
async signInWithOxy() {
|
|
272
|
+
this.setView('qr');
|
|
273
|
+
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
|
|
274
|
+
try {
|
|
275
|
+
const session = await this.oxyServices.signInWithSharedIdentity();
|
|
276
|
+
if (session) {
|
|
277
|
+
await this.completeSignIn(session, session.user);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
// Shared-key mint failed — log and fall through to the QR handoff rather
|
|
283
|
+
// than dead-ending the sign-in.
|
|
284
|
+
logger.warn('[AccountDialogController] signInWithSharedIdentity failed', { component: 'AccountDialogController' }, error);
|
|
285
|
+
}
|
|
286
|
+
await this.showQr();
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Begin (or restart) the cross-device QR handoff: create a device-flow
|
|
290
|
+
* session, surface its `authorizeCode` + `qrPayload`, and poll for approval.
|
|
291
|
+
* On approval the secret token is exchanged (`claimSessionByToken`) and the
|
|
292
|
+
* session committed. Requires `clientId`.
|
|
293
|
+
*/
|
|
294
|
+
async showQr() {
|
|
295
|
+
this.cancelSignIn();
|
|
296
|
+
this.setView('qr');
|
|
297
|
+
if (!this.clientId) {
|
|
298
|
+
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
|
|
302
|
+
try {
|
|
303
|
+
const handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
|
|
304
|
+
this.signInToken = handle.sessionToken;
|
|
305
|
+
this.setSignIn({
|
|
306
|
+
phase: 'waiting',
|
|
307
|
+
authorizeCode: handle.authorizeCode,
|
|
308
|
+
qrPayload: handle.qrPayload,
|
|
309
|
+
expiresAt: handle.expiresAt,
|
|
310
|
+
error: null,
|
|
311
|
+
});
|
|
312
|
+
this.scheduleNextPoll(handle.sessionToken);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** Tear down the active sign-in device flow (timers + token) and reset to idle. */
|
|
319
|
+
cancelSignIn() {
|
|
320
|
+
this.clearPollTimer();
|
|
321
|
+
this.signInToken = null;
|
|
322
|
+
if (this.signIn !== IDLE_SIGN_IN) {
|
|
323
|
+
this.setSignIn(IDLE_SIGN_IN);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
|
|
328
|
+
* password sign-in URL. Password + 2FA are NOT in the SDK — they live at the
|
|
329
|
+
* IdP; this only hands off. Device-first: after login at the IdP the device
|
|
330
|
+
* session converges and the caller is woken via the device socket /
|
|
331
|
+
* `BroadcastChannel`, so the URL only needs to point at the IdP sign-in with
|
|
332
|
+
* the right return.
|
|
333
|
+
*
|
|
334
|
+
* @param params.returnUrl - Where the IdP returns after login. Defaults to the
|
|
335
|
+
* current document URL on web (`globalThis.location.href`); pass explicitly
|
|
336
|
+
* on native (no `location`).
|
|
337
|
+
* @param params.state - Optional opaque state echoed back on return.
|
|
338
|
+
* @returns The absolute auth.oxy.so sign-in URL.
|
|
339
|
+
*/
|
|
340
|
+
openPasswordAtOxyAuth(params = {}) {
|
|
341
|
+
const base = `https://auth.${this.idpApex}`;
|
|
342
|
+
const url = new URL('/login', base);
|
|
343
|
+
const returnUrl = params.returnUrl ?? currentLocationHref();
|
|
344
|
+
if (returnUrl) {
|
|
345
|
+
url.searchParams.set('redirect_uri', returnUrl);
|
|
346
|
+
}
|
|
347
|
+
if (this.clientId) {
|
|
348
|
+
url.searchParams.set('client_id', this.clientId);
|
|
349
|
+
}
|
|
350
|
+
if (params.state) {
|
|
351
|
+
url.searchParams.set('state', params.state);
|
|
352
|
+
}
|
|
353
|
+
const href = url.toString();
|
|
354
|
+
this.openUrl?.(href);
|
|
355
|
+
return href;
|
|
356
|
+
}
|
|
357
|
+
// =========================================================================
|
|
358
|
+
// Internal sign-in helpers
|
|
359
|
+
// =========================================================================
|
|
360
|
+
scheduleNextPoll(sessionToken) {
|
|
361
|
+
this.clearPollTimer();
|
|
362
|
+
this.pollTimer = setTimeout(() => {
|
|
363
|
+
void this.pollOnce(sessionToken);
|
|
364
|
+
}, this.pollIntervalMs);
|
|
365
|
+
}
|
|
366
|
+
async pollOnce(sessionToken) {
|
|
367
|
+
// A superseded / cancelled flow must not act.
|
|
368
|
+
if (this.signInToken !== sessionToken)
|
|
369
|
+
return;
|
|
370
|
+
const expiresAt = this.signIn.expiresAt;
|
|
371
|
+
if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
|
|
372
|
+
this.failSignIn('Session expired. Please try again.');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
|
|
377
|
+
if (this.signInToken !== sessionToken)
|
|
378
|
+
return; // cancelled mid-request
|
|
379
|
+
if (status.authorized && status.sessionId) {
|
|
380
|
+
this.clearPollTimer();
|
|
381
|
+
await this.claimAndComplete(status.sessionId, sessionToken);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (status.status === 'cancelled') {
|
|
385
|
+
this.failSignIn('Authorization was denied.');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (status.status === 'expired') {
|
|
389
|
+
this.failSignIn('Session expired. Please try again.');
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
// Transient poll error — the next tick retries. Logged, never thrown.
|
|
395
|
+
logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
|
|
396
|
+
}
|
|
397
|
+
if (this.signInToken === sessionToken) {
|
|
398
|
+
this.scheduleNextPoll(sessionToken);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async claimAndComplete(sessionId, sessionToken) {
|
|
402
|
+
this.setSignIn({ ...this.signIn, phase: 'authorized' });
|
|
403
|
+
let claimed;
|
|
404
|
+
try {
|
|
405
|
+
claimed = await this.oxyServices.claimSessionByToken(sessionToken);
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
this.failSignIn(errorMessage(error));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (!claimed?.accessToken || !claimed.user) {
|
|
412
|
+
this.failSignIn('Authorization succeeded but the session could not be claimed. Please try again.');
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
// `SessionLoginResponse.user` is the minimal session-carried shape; the claim
|
|
416
|
+
// returns the full `User` (avatar is `string | null | undefined`). Normalize
|
|
417
|
+
// rather than widening the minimal shape to accept `null`.
|
|
418
|
+
const minimalUser = {
|
|
419
|
+
id: claimed.user.id,
|
|
420
|
+
username: claimed.user.username,
|
|
421
|
+
name: claimed.user.name,
|
|
422
|
+
avatar: claimed.user.avatar ?? undefined,
|
|
423
|
+
};
|
|
424
|
+
const refreshToken = readRefreshToken(claimed);
|
|
425
|
+
try {
|
|
426
|
+
await this.completeSignIn({
|
|
427
|
+
sessionId: claimed.sessionId || sessionId,
|
|
428
|
+
deviceId: claimed.deviceId ?? '',
|
|
429
|
+
expiresAt: claimed.expiresAt ?? '',
|
|
430
|
+
user: minimalUser,
|
|
431
|
+
accessToken: claimed.accessToken,
|
|
432
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
433
|
+
}, minimalUser);
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
this.failSignIn(errorMessage(error));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Commit an authorized session, notify, and return to the account list. Shared
|
|
441
|
+
* by the shared-key, QR, and mint-switch paths so they cannot drift.
|
|
442
|
+
*/
|
|
443
|
+
async completeSignIn(session, user) {
|
|
444
|
+
await this.commitAuthorizedSession(session, user);
|
|
445
|
+
this.signInToken = null;
|
|
446
|
+
this.clearPollTimer();
|
|
447
|
+
this.signIn = IDLE_SIGN_IN;
|
|
448
|
+
this.view = 'accounts';
|
|
449
|
+
this.emit();
|
|
450
|
+
this.onSignedIn?.(user);
|
|
451
|
+
await this.refresh();
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Register a token-planted session into the device set. Prefers the
|
|
455
|
+
* consumer's `commitSession` (durable persist + hydration); falls back to
|
|
456
|
+
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
457
|
+
*/
|
|
458
|
+
async commitAuthorizedSession(session, user) {
|
|
459
|
+
if (this.commitSession) {
|
|
460
|
+
await this.commitSession(session);
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
await this.sessionClient.registerAndActivate(user.id);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
failSignIn(message) {
|
|
467
|
+
this.clearPollTimer();
|
|
468
|
+
this.signInToken = null;
|
|
469
|
+
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: message });
|
|
470
|
+
}
|
|
471
|
+
clearPollTimer() {
|
|
472
|
+
if (this.pollTimer !== null) {
|
|
473
|
+
clearTimeout(this.pollTimer);
|
|
474
|
+
this.pollTimer = null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// =========================================================================
|
|
478
|
+
// Snapshot plumbing
|
|
479
|
+
// =========================================================================
|
|
480
|
+
setSignIn(next) {
|
|
481
|
+
this.signIn = next;
|
|
482
|
+
this.emit();
|
|
483
|
+
}
|
|
484
|
+
computeSnapshot() {
|
|
485
|
+
const state = this.sessionClient.getState();
|
|
486
|
+
return {
|
|
487
|
+
view: this.view,
|
|
488
|
+
accounts: projectSwitchableAccounts({
|
|
489
|
+
state,
|
|
490
|
+
graph: this.graph,
|
|
491
|
+
profilesById: this.profilesById,
|
|
492
|
+
locale: this.locale,
|
|
493
|
+
resolveAvatarUrl: (avatar) => (avatar ? this.oxyServices.getFileDownloadUrl(avatar, 'thumb') : undefined),
|
|
494
|
+
}),
|
|
495
|
+
activeAccountId: state?.activeAccountId ?? null,
|
|
496
|
+
loading: this.loading,
|
|
497
|
+
error: this.error,
|
|
498
|
+
switchingAccountId: this.switchingAccountId,
|
|
499
|
+
signIn: this.signIn,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/** Recompute the snapshot and notify subscribers. */
|
|
503
|
+
emit() {
|
|
504
|
+
this.snapshot = this.computeSnapshot();
|
|
505
|
+
for (const listener of this.listeners) {
|
|
506
|
+
try {
|
|
507
|
+
listener(this.snapshot);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
logger.error('[AccountDialogController] subscriber threw', error);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
|
|
516
|
+
export function createAccountDialogController(options) {
|
|
517
|
+
return new AccountDialogController(options);
|
|
518
|
+
}
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
// Local helpers
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
/**
|
|
523
|
+
* The rotating refresh-token family head is threaded on the runtime object by
|
|
524
|
+
* the trusted device-flow / switch lanes even though it is NOT on the typed
|
|
525
|
+
* return of `claimSessionByToken` / `switchToAccount`. Read it defensively so
|
|
526
|
+
* the commit funnel can persist a durable session.
|
|
527
|
+
*/
|
|
528
|
+
function readRefreshToken(value) {
|
|
529
|
+
const token = value.refreshToken;
|
|
530
|
+
return typeof token === 'string' ? token : undefined;
|
|
531
|
+
}
|
|
532
|
+
/** Current document URL on web; empty string where `location` is absent (native/SSR). */
|
|
533
|
+
function currentLocationHref() {
|
|
534
|
+
const location = globalThis.location;
|
|
535
|
+
return typeof location?.href === 'string' ? location.href : '';
|
|
536
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified account-list projection — THE single source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Produces the flat `SwitchableAccount[]` every account chooser renders, by
|
|
5
|
+
* merging the device's server-authoritative session set (`DeviceSessionState`
|
|
6
|
+
* from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
|
|
7
|
+
* from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
|
|
8
|
+
* `@oxyhq/core` so `@oxyhq/services` (RN) and `@oxyhq/auth` (web) — and
|
|
9
|
+
* `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
|
|
10
|
+
* diverge.
|
|
11
|
+
*
|
|
12
|
+
* Pure and I/O-free: the caller resolves per-account profiles via
|
|
13
|
+
* `oxyServices.getUsersByIds(...)` and passes them in as `profilesById`, and
|
|
14
|
+
* binds `resolveAvatarUrl` to `oxyServices.getFileDownloadUrl`. This is the same
|
|
15
|
+
* split the former `@oxyhq/services` `buildSwitchableAccounts` used — hoisted
|
|
16
|
+
* into core, keyed directly on `DeviceSessionState` (whose `activeAccountId` is
|
|
17
|
+
* atomic, so no cross-call current-row reconciliation is needed).
|
|
18
|
+
*/
|
|
19
|
+
import { getAccountDisplayName, getAccountFallbackHandle } from '../utils/accountUtils.js';
|
|
20
|
+
/**
|
|
21
|
+
* Pure union of device sign-ins and account-graph nodes into the flat
|
|
22
|
+
* {@link SwitchableAccount}[] every switcher renders.
|
|
23
|
+
*
|
|
24
|
+
* Order: device rows first (in `state.accounts` order, active flagged), then
|
|
25
|
+
* graph-only rows (in graph order). An account present as BOTH a device session
|
|
26
|
+
* and a graph node is deduped into ONE device row enriched with the graph
|
|
27
|
+
* metadata (relationship / kind / parent / membership).
|
|
28
|
+
*/
|
|
29
|
+
export function projectSwitchableAccounts(input) {
|
|
30
|
+
const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
|
|
31
|
+
const activeAccountId = state?.activeAccountId ?? null;
|
|
32
|
+
const toRow = (accountUser, opts) => {
|
|
33
|
+
const accountId = accountUser.id?.toString() ?? '';
|
|
34
|
+
const handle = getAccountFallbackHandle(accountUser);
|
|
35
|
+
const secondaryHandle = handle ? `@${handle}` : null;
|
|
36
|
+
return {
|
|
37
|
+
accountId,
|
|
38
|
+
sessionId: opts.sessionId,
|
|
39
|
+
authuser: opts.authuser,
|
|
40
|
+
isCurrent: Boolean(accountId) && accountId === activeAccountId,
|
|
41
|
+
onDevice: Boolean(opts.sessionId),
|
|
42
|
+
relationship: opts.relationship,
|
|
43
|
+
kind: opts.kind,
|
|
44
|
+
parentAccountId: opts.parentAccountId,
|
|
45
|
+
callerMembership: opts.callerMembership,
|
|
46
|
+
displayName: getAccountDisplayName(accountUser, locale),
|
|
47
|
+
// Real email, or the `@handle` fallback (NEVER synthesized).
|
|
48
|
+
email: accountUser.email ?? secondaryHandle,
|
|
49
|
+
avatarUrl: resolveAvatarUrl(accountUser.avatar),
|
|
50
|
+
color: accountUser.color ?? null,
|
|
51
|
+
user: accountUser,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
// --- Device rows (from the server-authoritative session set) ---
|
|
55
|
+
const deviceRows = (state?.accounts ?? []).flatMap((account) => {
|
|
56
|
+
const isActive = account.accountId === activeAccountId;
|
|
57
|
+
// The active row prefers the freshest `activeUser` (when supplied), then the
|
|
58
|
+
// batch-resolved profile; every other row uses the batch-resolved profile.
|
|
59
|
+
const accountUser = isActive && activeUser
|
|
60
|
+
? activeUser
|
|
61
|
+
: profilesById.get(account.accountId);
|
|
62
|
+
if (!accountUser) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return [toRow(accountUser, { sessionId: account.sessionId, authuser: account.authuser })];
|
|
66
|
+
});
|
|
67
|
+
// --- Merge graph nodes, deduping by account id ---
|
|
68
|
+
const byAccountId = new Map();
|
|
69
|
+
const order = [];
|
|
70
|
+
const remember = (row) => {
|
|
71
|
+
if (!row.accountId || byAccountId.has(row.accountId)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
byAccountId.set(row.accountId, row);
|
|
75
|
+
order.push(row.accountId);
|
|
76
|
+
};
|
|
77
|
+
for (const row of deviceRows) {
|
|
78
|
+
remember(row);
|
|
79
|
+
}
|
|
80
|
+
for (const node of graph) {
|
|
81
|
+
const existing = byAccountId.get(node.accountId);
|
|
82
|
+
if (existing) {
|
|
83
|
+
// On-device account that is ALSO in the graph: enrich the device row with
|
|
84
|
+
// graph metadata; keep its (freshest) profile + sessionId + active flag.
|
|
85
|
+
byAccountId.set(node.accountId, {
|
|
86
|
+
...existing,
|
|
87
|
+
relationship: node.relationship,
|
|
88
|
+
kind: node.kind,
|
|
89
|
+
parentAccountId: node.parentAccountId,
|
|
90
|
+
callerMembership: node.callerMembership,
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Graph-only account (owned org / shared, not yet a device session).
|
|
95
|
+
remember(toRow(node.account, {
|
|
96
|
+
relationship: node.relationship,
|
|
97
|
+
kind: node.kind,
|
|
98
|
+
parentAccountId: node.parentAccountId,
|
|
99
|
+
callerMembership: node.callerMembership,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
return order.flatMap((id) => {
|
|
103
|
+
const row = byAccountId.get(id);
|
|
104
|
+
return row ? [row] : [];
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Every distinct account id referenced by a device session set AND an account
|
|
109
|
+
* graph, sorted for a stable profile-fetch key. Feed to
|
|
110
|
+
* `oxyServices.getUsersByIds(...)`; graph nodes already embed their `account`
|
|
111
|
+
* document, but including their ids lets the caller pass one id set and lets the
|
|
112
|
+
* projection prefer freshly-fetched profiles uniformly.
|
|
113
|
+
*/
|
|
114
|
+
export function switchableAccountIds(state, graph) {
|
|
115
|
+
const ids = new Set();
|
|
116
|
+
for (const account of state?.accounts ?? []) {
|
|
117
|
+
if (account.accountId) {
|
|
118
|
+
ids.add(account.accountId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const node of graph) {
|
|
122
|
+
if (node.accountId) {
|
|
123
|
+
ids.add(node.accountId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return Array.from(ids).sort();
|
|
127
|
+
}
|
|
@@ -22,8 +22,15 @@ import { createSessionClientHost } from './sessionClientHost.js';
|
|
|
22
22
|
* specifier (bundler-fragile in Metro/Expo-web and Vite against the published
|
|
23
23
|
* dist). When omitted, the client falls back to the lazy loader.
|
|
24
24
|
*/
|
|
25
|
-
export function createSessionClient(oxyServices, transport, socketFactory
|
|
25
|
+
export function createSessionClient(oxyServices, transport, socketFactory,
|
|
26
|
+
/**
|
|
27
|
+
* Optional signed-out realtime wiring: `signedOutSocketAuth` (open the socket
|
|
28
|
+
* while signed out so an idle tab receives its device pushes) and
|
|
29
|
+
* `onSessionAppeared` (self-acquire when a sibling signs in). See
|
|
30
|
+
* {@link SessionClientOptions}.
|
|
31
|
+
*/
|
|
32
|
+
extra) {
|
|
26
33
|
const host = createSessionClientHost(oxyServices);
|
|
27
|
-
const client = new SessionClient(host, { transport, socketFactory });
|
|
34
|
+
const client = new SessionClient(host, { transport, socketFactory, ...extra });
|
|
28
35
|
return { client, host };
|
|
29
36
|
}
|