@oxy.so/core 1.11.0 → 1.12.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/session/accountDialogController.js +41 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/session/accountDialogController.js +41 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/session/accountDialogController.d.ts +45 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/session/__tests__/accountDialogController.test.ts +173 -0
- package/src/session/__tests__/accountDialogShape.test.ts +1 -0
- package/src/session/accountDialogController.ts +65 -0
package/dist/types/index.d.ts
CHANGED
|
@@ -130,7 +130,7 @@ export { isSwitchTargetAccount, canSwitchIntoAccount, } from './session/accountS
|
|
|
130
130
|
export { resolveAccountDelegationAccess } from './session/accountDelegationAccess';
|
|
131
131
|
export type { AccountDelegationAccess, AccountDelegationNode, } from './session/accountDelegationAccess';
|
|
132
132
|
export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController';
|
|
133
|
-
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, PopupWindowHandle, SignInFailureReason, SignInFlowPhase, SignInFlowState, SignInProgress, } from './session/accountDialogController';
|
|
133
|
+
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, ContextChoiceOutcome, PopupWindowHandle, SignInFailureReason, SignInFlowPhase, SignInFlowState, SignInProgress, } from './session/accountDialogController';
|
|
134
134
|
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore';
|
|
135
135
|
export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
|
|
136
136
|
export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './session/sharedDeviceCredential';
|
|
@@ -233,6 +233,16 @@ export interface AccountDialogSnapshot {
|
|
|
233
233
|
* table of their own.
|
|
234
234
|
*/
|
|
235
235
|
backView: AccountDialogView | null;
|
|
236
|
+
/**
|
|
237
|
+
* Whether THIS client is signed in: a bearer is planted.
|
|
238
|
+
*
|
|
239
|
+
* Not the same question as "does the directory list anyone". The directory is
|
|
240
|
+
* the DEVICE's, and a device can go on listing a shared identity (Commons, or a
|
|
241
|
+
* sibling app) after this app signed out. A host names its sign-in entry, and
|
|
242
|
+
* whether a listed account reads as the current one, from this — never from
|
|
243
|
+
* the directory's size.
|
|
244
|
+
*/
|
|
245
|
+
hasSession: boolean;
|
|
236
246
|
/**
|
|
237
247
|
* The server-authoritative device directory — principals and the contexts
|
|
238
248
|
* each may act as (ADR 0002) — or `null` before the first read.
|
|
@@ -259,6 +269,20 @@ export interface AccountDialogSnapshot {
|
|
|
259
269
|
/** Whether Commons is installed on this device. See {@link CommonsAvailability}. */
|
|
260
270
|
commonsAvailability: CommonsAvailability;
|
|
261
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* What {@link AccountDialogController.chooseContext} did with a chosen row.
|
|
274
|
+
*
|
|
275
|
+
* - `'current'` — the row is already the active account; nothing to do.
|
|
276
|
+
* - `'switched'` — the switch to the row happened (signed in; or signed out,
|
|
277
|
+
* after the silent sign-in landed on a different pair and the chosen one was
|
|
278
|
+
* then activated).
|
|
279
|
+
* - `'signing-in'` — signed out: the row started "Continue with Oxy", and the
|
|
280
|
+
* sign-in flow (its own view, its own completion) owns what happens next —
|
|
281
|
+
* including closing the dialog, through `onSignedIn`.
|
|
282
|
+
* - `'failed'` — the switch was attempted and failed (the reason is `error`).
|
|
283
|
+
* - `'busy'` — another device mutation is in flight; the press was dropped.
|
|
284
|
+
*/
|
|
285
|
+
export type ContextChoiceOutcome = 'current' | 'switched' | 'signing-in' | 'failed' | 'busy';
|
|
262
286
|
/** Construction options for {@link AccountDialogController}. */
|
|
263
287
|
export interface AccountDialogControllerOptions {
|
|
264
288
|
/** The API client. Source of graph accounts, profiles, and the sign-in methods. */
|
|
@@ -525,6 +549,27 @@ export declare class AccountDialogController {
|
|
|
525
549
|
* device mutation is in flight ({@link isDeviceMutationInFlight}).
|
|
526
550
|
*/
|
|
527
551
|
activateContext(contextId: string): Promise<boolean>;
|
|
552
|
+
/**
|
|
553
|
+
* A device account row was chosen — the one entry point for "use this
|
|
554
|
+
* account", whether or not anybody is signed in here.
|
|
555
|
+
*
|
|
556
|
+
* Signed in, it is a switch: the active row is already the answer
|
|
557
|
+
* (`'current'`), any other row is {@link activateContext}.
|
|
558
|
+
*
|
|
559
|
+
* Signed out, it is NOT a switch. The row is on the list because the DEVICE
|
|
560
|
+
* still holds that identity (Commons' shared identity, or a sibling app's
|
|
561
|
+
* session), not because this app does, so there is no bearer to activate a
|
|
562
|
+
* context with — and the active-row short-circuit used to read that stale
|
|
563
|
+
* "active" as "already signed in" and close the sheet on a signed-out app
|
|
564
|
+
* (OxyHQ/oxy#1375 item 20). Choosing the row is "Continue as @handle": the
|
|
565
|
+
* same {@link signInWithOxy} path as the "Continue with Oxy" button, which
|
|
566
|
+
* mints silently from the shared identity and otherwise falls back to the
|
|
567
|
+
* request. When that silent mint lands on a different pair from the one
|
|
568
|
+
* chosen (a device holding more than one person, or an organization row), the
|
|
569
|
+
* chosen row is then activated under the new bearer, so the press ends where
|
|
570
|
+
* it pointed.
|
|
571
|
+
*/
|
|
572
|
+
chooseContext(contextId: string): Promise<ContextChoiceOutcome>;
|
|
528
573
|
/**
|
|
529
574
|
* Remove ONE `principal → account` pair, and only that pair.
|
|
530
575
|
*
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -2469,3 +2469,176 @@ it('createAccountDialogController returns an AccountDialogController instance',
|
|
|
2469
2469
|
// Ensure the exported type surface is reachable at compile time for binders.
|
|
2470
2470
|
const _typecheck: MinimalUserData | null = null;
|
|
2471
2471
|
void _typecheck;
|
|
2472
|
+
|
|
2473
|
+
describe('AccountDialogController — choosing a device account row (OxyHQ/oxy#1375 items 20 and 21)', () => {
|
|
2474
|
+
/**
|
|
2475
|
+
* The device's directory as Commons leaves it: `qa` on the device and active,
|
|
2476
|
+
* plus (when `withOrg`) an organization `qa` also operates.
|
|
2477
|
+
*/
|
|
2478
|
+
const deviceDirectory = (activeContextId: string | null, withOrg = false) => ({
|
|
2479
|
+
deviceId: 'device-1',
|
|
2480
|
+
revision: 4,
|
|
2481
|
+
activeContextId,
|
|
2482
|
+
updatedAt: 1_720_000_000_000,
|
|
2483
|
+
principals: [
|
|
2484
|
+
{
|
|
2485
|
+
id: 'p-qa',
|
|
2486
|
+
userId: 'qa',
|
|
2487
|
+
authuser: 0,
|
|
2488
|
+
user: { id: 'qa', username: 'qatest0925' },
|
|
2489
|
+
contexts: [
|
|
2490
|
+
{
|
|
2491
|
+
id: 'ctx-qa',
|
|
2492
|
+
accountId: 'qa',
|
|
2493
|
+
kind: 'personal' as const,
|
|
2494
|
+
relationship: 'self' as const,
|
|
2495
|
+
account: { id: 'qa', username: 'qatest0925' },
|
|
2496
|
+
onDevice: true,
|
|
2497
|
+
available: true,
|
|
2498
|
+
active: activeContextId === 'ctx-qa',
|
|
2499
|
+
lastUsedAt: null,
|
|
2500
|
+
},
|
|
2501
|
+
...(withOrg
|
|
2502
|
+
? [
|
|
2503
|
+
{
|
|
2504
|
+
id: 'ctx-qa-org',
|
|
2505
|
+
accountId: 'org',
|
|
2506
|
+
kind: 'organization' as const,
|
|
2507
|
+
relationship: 'owner' as const,
|
|
2508
|
+
account: { id: 'org', username: 'oxy' },
|
|
2509
|
+
onDevice: true,
|
|
2510
|
+
available: true,
|
|
2511
|
+
active: activeContextId === 'ctx-qa-org',
|
|
2512
|
+
lastUsedAt: null,
|
|
2513
|
+
},
|
|
2514
|
+
]
|
|
2515
|
+
: []),
|
|
2516
|
+
],
|
|
2517
|
+
},
|
|
2518
|
+
],
|
|
2519
|
+
});
|
|
2520
|
+
|
|
2521
|
+
const sharedSession: SessionLoginResponse = {
|
|
2522
|
+
sessionId: 'sess-shared',
|
|
2523
|
+
deviceId: 'device-1',
|
|
2524
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
2525
|
+
accessToken: 'access-shared',
|
|
2526
|
+
user: { id: 'qa', username: 'qatest0925', name: { displayName: 'QA' } },
|
|
2527
|
+
};
|
|
2528
|
+
|
|
2529
|
+
/**
|
|
2530
|
+
* A device that still lists `qa` (Commons holds the shared identity) while
|
|
2531
|
+
* THIS app holds no bearer — Mention after "Sign out".
|
|
2532
|
+
*/
|
|
2533
|
+
async function signedOutDevice(options: { withOrg?: boolean } = {}) {
|
|
2534
|
+
const oxy = makeOxy();
|
|
2535
|
+
const urls: string[] = [];
|
|
2536
|
+
const sc = new TestSessionClient({
|
|
2537
|
+
makeRequest: jest.fn(async (_method: string, url: string) => {
|
|
2538
|
+
urls.push(url);
|
|
2539
|
+
if (url === '/session/device/directory') return deviceDirectory('ctx-qa', options.withOrg);
|
|
2540
|
+
if (url === '/session/device/activate') {
|
|
2541
|
+
return { directory: { ...deviceDirectory('ctx-qa-org', true), revision: 5 }, activeToken: null };
|
|
2542
|
+
}
|
|
2543
|
+
return undefined;
|
|
2544
|
+
}),
|
|
2545
|
+
getBaseURL: () => 'http://test.invalid',
|
|
2546
|
+
getAccessToken: () => oxy.getAccessToken(),
|
|
2547
|
+
getDeviceCredential: () => null,
|
|
2548
|
+
onTokensChanged: () => () => undefined,
|
|
2549
|
+
setTokens: jest.fn(),
|
|
2550
|
+
getCurrentAccountId: () => null,
|
|
2551
|
+
});
|
|
2552
|
+
// The directory this client read while it was still signed in.
|
|
2553
|
+
await sc.refreshDirectory();
|
|
2554
|
+
oxy.emitTokenChange(null);
|
|
2555
|
+
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
2556
|
+
const onSignedIn = jest.fn();
|
|
2557
|
+
const controller = createAccountDialogController({
|
|
2558
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
2559
|
+
sessionClient: sc,
|
|
2560
|
+
clientId: 'oxy_dk_test',
|
|
2561
|
+
commitSession,
|
|
2562
|
+
onSignedIn,
|
|
2563
|
+
});
|
|
2564
|
+
controller.start();
|
|
2565
|
+
await flush();
|
|
2566
|
+
urls.length = 0;
|
|
2567
|
+
return { controller, oxy, sc, urls, commitSession, onSignedIn };
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
it('publishes that this client is signed out while the device still lists an active account', async () => {
|
|
2571
|
+
const { controller } = await signedOutDevice();
|
|
2572
|
+
const snap = controller.getSnapshot();
|
|
2573
|
+
expect(snap.hasSession).toBe(false);
|
|
2574
|
+
// The precondition of the bug: the row the sheet renders reads as "active".
|
|
2575
|
+
expect(snap.activeContext?.contextId).toBe('ctx-qa');
|
|
2576
|
+
controller.destroy();
|
|
2577
|
+
});
|
|
2578
|
+
|
|
2579
|
+
it('signed out, choosing the listed account signs in through the shared identity, like "Continue with Oxy"', async () => {
|
|
2580
|
+
const { controller, oxy, urls, commitSession, onSignedIn } = await signedOutDevice();
|
|
2581
|
+
oxy.signInWithSharedIdentity.mockResolvedValue(sharedSession);
|
|
2582
|
+
|
|
2583
|
+
// It used to short-circuit on "already the active row" and report success,
|
|
2584
|
+
// closing the sheet on an app that was still signed out.
|
|
2585
|
+
expect(await controller.chooseContext('ctx-qa')).toBe('signing-in');
|
|
2586
|
+
|
|
2587
|
+
expect(oxy.signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
|
|
2588
|
+
expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
|
|
2589
|
+
expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'qa' }));
|
|
2590
|
+
expect(controller.getSnapshot().hasSession).toBe(true);
|
|
2591
|
+
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
2592
|
+
// The silent mint already landed on the chosen pair: no switch on top.
|
|
2593
|
+
expect(urls).not.toContain('/session/device/activate');
|
|
2594
|
+
controller.destroy();
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
it('signed out with no shared identity, choosing the account starts the same request "Continue with Oxy" does', async () => {
|
|
2598
|
+
const { controller, oxy, onSignedIn } = await signedOutDevice();
|
|
2599
|
+
oxy.signInWithSharedIdentity.mockResolvedValue(null);
|
|
2600
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
2601
|
+
sessionToken: 'secret-tok',
|
|
2602
|
+
authorizeCode: 'AUTH-CODE',
|
|
2603
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
2604
|
+
expiresAt: Date.now() + 300_000,
|
|
2605
|
+
status: 'pending',
|
|
2606
|
+
});
|
|
2607
|
+
|
|
2608
|
+
expect(await controller.chooseContext('ctx-qa')).toBe('signing-in');
|
|
2609
|
+
|
|
2610
|
+
expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
|
|
2611
|
+
expect(controller.getSnapshot().view).toBe('qr');
|
|
2612
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
2613
|
+
expect(onSignedIn).not.toHaveBeenCalled();
|
|
2614
|
+
controller.destroy();
|
|
2615
|
+
});
|
|
2616
|
+
|
|
2617
|
+
it('signed out, choosing an organization row signs in and then activates that row', async () => {
|
|
2618
|
+
const { controller, oxy, urls } = await signedOutDevice({ withOrg: true });
|
|
2619
|
+
oxy.signInWithSharedIdentity.mockResolvedValue(sharedSession);
|
|
2620
|
+
|
|
2621
|
+
expect(await controller.chooseContext('ctx-qa-org')).toBe('switched');
|
|
2622
|
+
|
|
2623
|
+
expect(oxy.signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
2624
|
+
expect(urls).toContain('/session/device/activate');
|
|
2625
|
+
expect(controller.getSnapshot().activeContext?.contextId).toBe('ctx-qa-org');
|
|
2626
|
+
controller.destroy();
|
|
2627
|
+
});
|
|
2628
|
+
|
|
2629
|
+
it('signed in, the active row is "current" and any other row is a switch', async () => {
|
|
2630
|
+
const { controller, oxy, urls } = await signedOutDevice({ withOrg: true });
|
|
2631
|
+
oxy.emitTokenChange('access-token');
|
|
2632
|
+
await flush();
|
|
2633
|
+
urls.length = 0;
|
|
2634
|
+
expect(controller.getSnapshot().hasSession).toBe(true);
|
|
2635
|
+
|
|
2636
|
+
expect(await controller.chooseContext('ctx-qa')).toBe('current');
|
|
2637
|
+
expect(urls).not.toContain('/session/device/activate');
|
|
2638
|
+
|
|
2639
|
+
expect(await controller.chooseContext('ctx-qa-org')).toBe('switched');
|
|
2640
|
+
expect(urls).toContain('/session/device/activate');
|
|
2641
|
+
expect(oxy.signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
2642
|
+
controller.destroy();
|
|
2643
|
+
});
|
|
2644
|
+
});
|
|
@@ -310,6 +310,16 @@ export interface AccountDialogSnapshot {
|
|
|
310
310
|
* table of their own.
|
|
311
311
|
*/
|
|
312
312
|
backView: AccountDialogView | null;
|
|
313
|
+
/**
|
|
314
|
+
* Whether THIS client is signed in: a bearer is planted.
|
|
315
|
+
*
|
|
316
|
+
* Not the same question as "does the directory list anyone". The directory is
|
|
317
|
+
* the DEVICE's, and a device can go on listing a shared identity (Commons, or a
|
|
318
|
+
* sibling app) after this app signed out. A host names its sign-in entry, and
|
|
319
|
+
* whether a listed account reads as the current one, from this — never from
|
|
320
|
+
* the directory's size.
|
|
321
|
+
*/
|
|
322
|
+
hasSession: boolean;
|
|
313
323
|
/**
|
|
314
324
|
* The server-authoritative device directory — principals and the contexts
|
|
315
325
|
* each may act as (ADR 0002) — or `null` before the first read.
|
|
@@ -337,6 +347,21 @@ export interface AccountDialogSnapshot {
|
|
|
337
347
|
commonsAvailability: CommonsAvailability;
|
|
338
348
|
}
|
|
339
349
|
|
|
350
|
+
/**
|
|
351
|
+
* What {@link AccountDialogController.chooseContext} did with a chosen row.
|
|
352
|
+
*
|
|
353
|
+
* - `'current'` — the row is already the active account; nothing to do.
|
|
354
|
+
* - `'switched'` — the switch to the row happened (signed in; or signed out,
|
|
355
|
+
* after the silent sign-in landed on a different pair and the chosen one was
|
|
356
|
+
* then activated).
|
|
357
|
+
* - `'signing-in'` — signed out: the row started "Continue with Oxy", and the
|
|
358
|
+
* sign-in flow (its own view, its own completion) owns what happens next —
|
|
359
|
+
* including closing the dialog, through `onSignedIn`.
|
|
360
|
+
* - `'failed'` — the switch was attempted and failed (the reason is `error`).
|
|
361
|
+
* - `'busy'` — another device mutation is in flight; the press was dropped.
|
|
362
|
+
*/
|
|
363
|
+
export type ContextChoiceOutcome = 'current' | 'switched' | 'signing-in' | 'failed' | 'busy';
|
|
364
|
+
|
|
340
365
|
/** Construction options for {@link AccountDialogController}. */
|
|
341
366
|
export interface AccountDialogControllerOptions {
|
|
342
367
|
/** The API client. Source of graph accounts, profiles, and the sign-in methods. */
|
|
@@ -880,6 +905,45 @@ export class AccountDialogController {
|
|
|
880
905
|
);
|
|
881
906
|
}
|
|
882
907
|
|
|
908
|
+
/**
|
|
909
|
+
* A device account row was chosen — the one entry point for "use this
|
|
910
|
+
* account", whether or not anybody is signed in here.
|
|
911
|
+
*
|
|
912
|
+
* Signed in, it is a switch: the active row is already the answer
|
|
913
|
+
* (`'current'`), any other row is {@link activateContext}.
|
|
914
|
+
*
|
|
915
|
+
* Signed out, it is NOT a switch. The row is on the list because the DEVICE
|
|
916
|
+
* still holds that identity (Commons' shared identity, or a sibling app's
|
|
917
|
+
* session), not because this app does, so there is no bearer to activate a
|
|
918
|
+
* context with — and the active-row short-circuit used to read that stale
|
|
919
|
+
* "active" as "already signed in" and close the sheet on a signed-out app
|
|
920
|
+
* (OxyHQ/oxy#1375 item 20). Choosing the row is "Continue as @handle": the
|
|
921
|
+
* same {@link signInWithOxy} path as the "Continue with Oxy" button, which
|
|
922
|
+
* mints silently from the shared identity and otherwise falls back to the
|
|
923
|
+
* request. When that silent mint lands on a different pair from the one
|
|
924
|
+
* chosen (a device holding more than one person, or an organization row), the
|
|
925
|
+
* chosen row is then activated under the new bearer, so the press ends where
|
|
926
|
+
* it pointed.
|
|
927
|
+
*/
|
|
928
|
+
async chooseContext(contextId: string): Promise<ContextChoiceOutcome> {
|
|
929
|
+
if (this.isDeviceMutationInFlight()) return 'busy';
|
|
930
|
+
if (this.hasSession()) {
|
|
931
|
+
if (contextId === this.snapshot.activeContext?.contextId) return 'current';
|
|
932
|
+
return (await this.activateContext(contextId)) ? 'switched' : 'failed';
|
|
933
|
+
}
|
|
934
|
+
await this.signInWithOxy();
|
|
935
|
+
// Only a sign-in that finished here (the silent shared-identity mint) can be
|
|
936
|
+
// steered to the chosen row; a request still waiting on approval belongs to
|
|
937
|
+
// whoever approves it.
|
|
938
|
+
if (this.signIn.phase !== 'completed' || !this.hasSession()) return 'signing-in';
|
|
939
|
+
const directory = this.sessionClient.getDirectory();
|
|
940
|
+
const offered = directory?.principals.some((principal) =>
|
|
941
|
+
principal.contexts.some((context) => context.id === contextId),
|
|
942
|
+
);
|
|
943
|
+
if (!offered || resolveActiveContext(directory)?.contextId === contextId) return 'signing-in';
|
|
944
|
+
return (await this.activateContext(contextId)) ? 'switched' : 'signing-in';
|
|
945
|
+
}
|
|
946
|
+
|
|
883
947
|
/**
|
|
884
948
|
* Remove ONE `principal → account` pair, and only that pair.
|
|
885
949
|
*
|
|
@@ -1780,6 +1844,7 @@ export class AccountDialogController {
|
|
|
1780
1844
|
return {
|
|
1781
1845
|
view,
|
|
1782
1846
|
backView: backViewOf(view, hasSession),
|
|
1847
|
+
hasSession,
|
|
1783
1848
|
directory,
|
|
1784
1849
|
activeContext: resolveActiveContext(directory),
|
|
1785
1850
|
loading: this.loading,
|