@oxy.so/services 11.4.0 → 11.5.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.
Files changed (104) hide show
  1. package/lib/commonjs/index.js +0 -28
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/boot/runProviderColdBoot.js +47 -3
  4. package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
  5. package/lib/commonjs/ui/components/signIn/OxyEmailAccountChoice.js +113 -0
  6. package/lib/commonjs/ui/components/signIn/OxyEmailAccountChoice.js.map +1 -0
  7. package/lib/commonjs/ui/components/signIn/OxySignInPanel.js +70 -3
  8. package/lib/commonjs/ui/components/signIn/OxySignInPanel.js.map +1 -1
  9. package/lib/commonjs/ui/components/signIn/accountFlowParts.js +2 -0
  10. package/lib/commonjs/ui/components/signIn/accountFlowParts.js.map +1 -1
  11. package/lib/commonjs/ui/screens/AccountMembersScreen.js +20 -63
  12. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
  13. package/lib/commonjs/ui/screens/PreferencesScreen.js +3 -4
  14. package/lib/commonjs/ui/screens/PreferencesScreen.js.map +1 -1
  15. package/lib/module/index.js +0 -4
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/boot/runProviderColdBoot.js +47 -3
  18. package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
  19. package/lib/module/ui/components/signIn/OxyEmailAccountChoice.js +110 -0
  20. package/lib/module/ui/components/signIn/OxyEmailAccountChoice.js.map +1 -0
  21. package/lib/module/ui/components/signIn/OxySignInPanel.js +71 -4
  22. package/lib/module/ui/components/signIn/OxySignInPanel.js.map +1 -1
  23. package/lib/module/ui/components/signIn/accountFlowParts.js +2 -0
  24. package/lib/module/ui/components/signIn/accountFlowParts.js.map +1 -1
  25. package/lib/module/ui/screens/AccountMembersScreen.js +20 -63
  26. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
  27. package/lib/module/ui/screens/PreferencesScreen.js +3 -4
  28. package/lib/module/ui/screens/PreferencesScreen.js.map +1 -1
  29. package/lib/typescript/commonjs/index.d.ts +0 -4
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/ui/components/signIn/OxyEmailAccountChoice.d.ts +20 -0
  33. package/lib/typescript/commonjs/ui/components/signIn/OxyEmailAccountChoice.d.ts.map +1 -0
  34. package/lib/typescript/commonjs/ui/components/signIn/OxySignInPanel.d.ts +2 -0
  35. package/lib/typescript/commonjs/ui/components/signIn/OxySignInPanel.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +1 -7
  38. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +1 -2
  40. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +1 -1
  42. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/screens/PreferencesScreen.d.ts.map +1 -1
  44. package/lib/typescript/module/index.d.ts +0 -4
  45. package/lib/typescript/module/index.d.ts.map +1 -1
  46. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  47. package/lib/typescript/module/ui/components/signIn/OxyEmailAccountChoice.d.ts +20 -0
  48. package/lib/typescript/module/ui/components/signIn/OxyEmailAccountChoice.d.ts.map +1 -0
  49. package/lib/typescript/module/ui/components/signIn/OxySignInPanel.d.ts +2 -0
  50. package/lib/typescript/module/ui/components/signIn/OxySignInPanel.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts.map +1 -1
  52. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +1 -7
  53. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  54. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +1 -2
  55. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  56. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +1 -1
  57. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  58. package/lib/typescript/module/ui/screens/PreferencesScreen.d.ts.map +1 -1
  59. package/package.json +5 -6
  60. package/src/index.ts +0 -5
  61. package/src/ui/boot/__tests__/coldBootNoIdpRedirect.test.ts +166 -3
  62. package/src/ui/boot/runProviderColdBoot.ts +49 -4
  63. package/src/ui/components/signIn/OxyEmailAccountChoice.tsx +116 -0
  64. package/src/ui/components/signIn/OxySignInPanel.tsx +89 -4
  65. package/src/ui/components/signIn/accountFlowParts.tsx +2 -0
  66. package/src/ui/screens/AccountMembersScreen.tsx +27 -71
  67. package/src/ui/screens/PreferencesScreen.tsx +0 -2
  68. package/lib/commonjs/ui/components/AccountInvitationAcceptance.js +0 -97
  69. package/lib/commonjs/ui/components/AccountInvitationAcceptance.js.map +0 -1
  70. package/lib/commonjs/ui/components/DisplayCurrencySelector.js +0 -49
  71. package/lib/commonjs/ui/components/DisplayCurrencySelector.js.map +0 -1
  72. package/lib/commonjs/ui/hooks/useDisplayCurrency.js +0 -77
  73. package/lib/commonjs/ui/hooks/useDisplayCurrency.js.map +0 -1
  74. package/lib/commonjs/ui/hooks/useDisplayPrice.js +0 -59
  75. package/lib/commonjs/ui/hooks/useDisplayPrice.js.map +0 -1
  76. package/lib/module/ui/components/AccountInvitationAcceptance.js +0 -94
  77. package/lib/module/ui/components/AccountInvitationAcceptance.js.map +0 -1
  78. package/lib/module/ui/components/DisplayCurrencySelector.js +0 -44
  79. package/lib/module/ui/components/DisplayCurrencySelector.js.map +0 -1
  80. package/lib/module/ui/hooks/useDisplayCurrency.js +0 -72
  81. package/lib/module/ui/hooks/useDisplayCurrency.js.map +0 -1
  82. package/lib/module/ui/hooks/useDisplayPrice.js +0 -56
  83. package/lib/module/ui/hooks/useDisplayPrice.js.map +0 -1
  84. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts +0 -5
  85. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts.map +0 -1
  86. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts +0 -3
  87. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts.map +0 -1
  88. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts +0 -11
  89. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts.map +0 -1
  90. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts +0 -6
  91. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts.map +0 -1
  92. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts +0 -5
  93. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts.map +0 -1
  94. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts +0 -3
  95. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts.map +0 -1
  96. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts +0 -11
  97. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts.map +0 -1
  98. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts +0 -6
  99. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts.map +0 -1
  100. package/src/ui/components/AccountInvitationAcceptance.tsx +0 -90
  101. package/src/ui/components/DisplayCurrencySelector.tsx +0 -38
  102. package/src/ui/components/__tests__/AccountInvitationAcceptance.test.tsx +0 -61
  103. package/src/ui/hooks/useDisplayCurrency.ts +0 -64
  104. package/src/ui/hooks/useDisplayPrice.ts +0 -40
package/src/index.ts CHANGED
@@ -453,8 +453,3 @@ export {
453
453
  usePersonalPlans,
454
454
  usePersonalPlanSubscriptions,
455
455
  } from './ui/hooks/queries/usePersonalPlans';
456
- export { AccountInvitationAcceptance } from './ui/components/AccountInvitationAcceptance';
457
-
458
- export { DisplayCurrencySelector } from './ui/components/DisplayCurrencySelector';
459
- export { useDisplayCurrency } from './ui/hooks/useDisplayCurrency';
460
- export { useDisplayPrice } from './ui/hooks/useDisplayPrice';
@@ -63,6 +63,22 @@ function makeEmptyAuthStore(): AuthStateStore {
63
63
  } as unknown as AuthStateStore;
64
64
  }
65
65
 
66
+ /** An auth store holding this origin's device credential. */
67
+ function makeCredentialAuthStore(): AuthStateStore {
68
+ return {
69
+ load: jest.fn(
70
+ async (): Promise<PersistedAuthState | null> => ({ deviceId: 'd1', deviceSecret: 's1' }),
71
+ ),
72
+ save: jest.fn(async () => undefined),
73
+ clear: jest.fn(async () => undefined),
74
+ } as unknown as AuthStateStore;
75
+ }
76
+
77
+ /** Put an OAuth return (`?code=`) on the address bar, as the IdP's redirect does. */
78
+ function landWithAuthorizationCode(): void {
79
+ window.history.replaceState(null, '', '/?code=auth-code&state=auth-state');
80
+ }
81
+
66
82
  interface Harness {
67
83
  options: RunProviderColdBootOptions;
68
84
  commitSession: jest.Mock;
@@ -120,6 +136,7 @@ describe('runProviderColdBoot — no SDK-initiated IdP navigation (#691 phase 7b
120
136
  mockTryCompleteOAuthReturn.mockClear();
121
137
  mockTryCompleteOAuthReturn.mockImplementation(async () => false);
122
138
  globalThis.sessionStorage?.clear();
139
+ window.history.replaceState(null, '', '/');
123
140
  });
124
141
 
125
142
  it('resolves a domain with no local credential SIGNED OUT instead of bouncing to the IdP', async () => {
@@ -160,7 +177,7 @@ describe('runProviderColdBoot — no SDK-initiated IdP navigation (#691 phase 7b
160
177
  return { kind: 'session', via: session.via, session } as const;
161
178
  },
162
179
  );
163
- const { options, commitSession } = makeHarness();
180
+ const { options, commitSession } = makeHarness({ authStore: makeCredentialAuthStore() });
164
181
 
165
182
  await runProviderColdBoot(options);
166
183
 
@@ -176,6 +193,7 @@ describe('runProviderColdBoot — no SDK-initiated IdP navigation (#691 phase 7b
176
193
  // A popup-mode app lands here whenever the browser blocked the popup and
177
194
  // `startWebOAuthSignIn` fell back to a full-page redirect.
178
195
  mockTryCompleteOAuthReturn.mockImplementation(async () => true);
196
+ landWithAuthorizationCode();
179
197
  const { options, markAuthResolved, setTokenReady } = makeHarness();
180
198
 
181
199
  await runProviderColdBoot(options);
@@ -190,6 +208,7 @@ describe('runProviderColdBoot — no SDK-initiated IdP navigation (#691 phase 7b
190
208
 
191
209
  it('runs the return leg even on a boot that finds no session', async () => {
192
210
  stubSignedOutBoot();
211
+ landWithAuthorizationCode();
193
212
  const { options } = makeHarness();
194
213
 
195
214
  await runProviderColdBoot(options);
@@ -199,6 +218,7 @@ describe('runProviderColdBoot — no SDK-initiated IdP navigation (#691 phase 7b
199
218
 
200
219
  it('commits the OAuth return as a deliberate sign-in', async () => {
201
220
  mockTryCompleteOAuthReturn.mockImplementation(async () => true);
221
+ landWithAuthorizationCode();
202
222
  const { options } = makeHarness();
203
223
 
204
224
  await runProviderColdBoot(options);
@@ -225,14 +245,22 @@ describe('runProviderColdBoot — no SDK-initiated IdP navigation (#691 phase 7b
225
245
  });
226
246
  });
227
247
 
248
+ // Each case lands with an OAuth return on the URL — the boot that must classify
249
+ // before it does anything — so "nothing ran first" is a real claim. The web
250
+ // origin with nothing to restore, which resolves before classification, is
251
+ // covered on its own below.
228
252
  describe('registered cold-boot classification', () => {
229
253
  beforeEach(() => {
230
254
  mockRunSessionColdBoot.mockReset();
231
255
  mockTryCompleteOAuthReturn.mockReset();
232
256
  mockTryCompleteOAuthReturn.mockResolvedValue(false);
257
+ landWithAuthorizationCode();
258
+ });
259
+ afterEach(() => {
260
+ window.history.replaceState(null, '', '/');
233
261
  });
234
262
  it('external classification bypasses prior secrets, shared device and identity pin work', async () => {
235
- const { options } = makeHarness();
263
+ const { options } = makeHarness({ authStore: makeCredentialAuthStore() });
236
264
  (options.oxyServices.apps.getPublic as jest.Mock).mockResolvedValue({
237
265
  id: 'external',
238
266
  name: 'External',
@@ -298,8 +326,9 @@ describe('registered cold-boot classification', () => {
298
326
 
299
327
  it('an unresponsive registry resolves signed-out without a late privileged continuation', async () => {
300
328
  jest.useFakeTimers();
329
+ landWithAuthorizationCode();
301
330
  try {
302
- const { options } = makeHarness();
331
+ const { options } = makeHarness({ authStore: makeCredentialAuthStore() });
303
332
  let release!: (app: unknown) => void;
304
333
  (options.oxyServices.apps.getPublic as jest.Mock).mockImplementation(
305
334
  () =>
@@ -327,5 +356,139 @@ it('an unresponsive registry resolves signed-out without a late privileged conti
327
356
  expect(mockTryCompleteOAuthReturn).not.toHaveBeenCalled();
328
357
  } finally {
329
358
  jest.useRealTimers();
359
+ window.history.replaceState(null, '', '/');
330
360
  }
331
361
  });
362
+
363
+ /**
364
+ * A web origin with nothing to restore — no warm token, no device secret, no
365
+ * OAuth return on the URL — ends signed out on either lane, so its routing does
366
+ * not wait on the registry round trip. Everything that would USE a credential
367
+ * still waits for the verdict.
368
+ */
369
+ describe('web origin with nothing to restore', () => {
370
+ const official = {
371
+ id: 'registered-app',
372
+ name: 'Registered App',
373
+ type: 'first_party',
374
+ isOfficial: true,
375
+ isInternal: false,
376
+ scopes: [],
377
+ };
378
+
379
+ beforeEach(() => {
380
+ mockRunSessionColdBoot.mockReset();
381
+ mockTryCompleteOAuthReturn.mockReset();
382
+ mockTryCompleteOAuthReturn.mockResolvedValue(false);
383
+ window.history.replaceState(null, '', '/');
384
+ });
385
+
386
+ function pendingRegistry(options: RunProviderColdBootOptions): (app: unknown) => void {
387
+ let release!: (app: unknown) => void;
388
+ (options.oxyServices.apps.getPublic as jest.Mock).mockImplementation(
389
+ () =>
390
+ new Promise((resolve) => {
391
+ release = resolve;
392
+ }),
393
+ );
394
+ return (app) => release(app);
395
+ }
396
+
397
+ it('resolves auth before the registry answers, and still classifies with no cache', async () => {
398
+ const { options, markAuthResolved } = makeHarness();
399
+ const onClassified = jest.fn();
400
+ const release = pendingRegistry(options);
401
+
402
+ const boot = runProviderColdBoot({ ...options, onClassified });
403
+ await new Promise((resolve) => setTimeout(resolve, 0));
404
+
405
+ expect(markAuthResolved).toHaveBeenCalled();
406
+ expect(options.oxyServices.apps.getPublic).toHaveBeenCalledWith(CLIENT_ID, { cache: false });
407
+ expect(onClassified).not.toHaveBeenCalled();
408
+
409
+ release(official);
410
+ await boot;
411
+
412
+ expect(onClassified).toHaveBeenCalledWith('device');
413
+ expect(mockRunSessionColdBoot).not.toHaveBeenCalled();
414
+ expect(mockTryCompleteOAuthReturn).not.toHaveBeenCalled();
415
+ expect(options.syncDeviceCredentialToHost).not.toHaveBeenCalled();
416
+ expect(options.commitSession).not.toHaveBeenCalled();
417
+ });
418
+
419
+ it('a failed classification after the early resolution restores nothing', async () => {
420
+ const { options, markAuthResolved } = makeHarness();
421
+ (options.oxyServices.apps.getPublic as jest.Mock).mockRejectedValue(new Error('offline'));
422
+
423
+ await runProviderColdBoot(options);
424
+
425
+ expect(markAuthResolved).toHaveBeenCalled();
426
+ expect(mockRunSessionColdBoot).not.toHaveBeenCalled();
427
+ expect(options.syncDeviceCredentialToHost).not.toHaveBeenCalled();
428
+ });
429
+
430
+ it('an origin holding a device secret still waits for the verdict before using it', async () => {
431
+ stubSignedOutBoot();
432
+ const { options, markAuthResolved } = makeHarness({ authStore: makeCredentialAuthStore() });
433
+ const release = pendingRegistry(options);
434
+
435
+ const boot = runProviderColdBoot(options);
436
+ await new Promise((resolve) => setTimeout(resolve, 0));
437
+
438
+ expect(markAuthResolved).not.toHaveBeenCalled();
439
+ expect(mockRunSessionColdBoot).not.toHaveBeenCalled();
440
+ expect(options.syncDeviceCredentialToHost).not.toHaveBeenCalled();
441
+
442
+ release(official);
443
+ await boot;
444
+
445
+ expect(mockRunSessionColdBoot).toHaveBeenCalledTimes(1);
446
+ expect(markAuthResolved).toHaveBeenCalled();
447
+ });
448
+
449
+ it('an origin holding a device secret classified external never uses it', async () => {
450
+ const { options } = makeHarness({ authStore: makeCredentialAuthStore() });
451
+ (options.oxyServices.apps.getPublic as jest.Mock).mockResolvedValue({
452
+ ...official,
453
+ type: 'third_party',
454
+ isOfficial: false,
455
+ });
456
+
457
+ await runProviderColdBoot(options);
458
+
459
+ expect(mockRunSessionColdBoot).not.toHaveBeenCalled();
460
+ expect(options.syncDeviceCredentialToHost).not.toHaveBeenCalled();
461
+ expect(options.authStore.save).not.toHaveBeenCalled();
462
+ expect(options.authStore.clear).not.toHaveBeenCalled();
463
+ });
464
+
465
+ it('an OAuth return on the URL waits for the verdict', async () => {
466
+ landWithAuthorizationCode();
467
+ const { options, markAuthResolved } = makeHarness();
468
+ const release = pendingRegistry(options);
469
+
470
+ const boot = runProviderColdBoot(options);
471
+ await new Promise((resolve) => setTimeout(resolve, 0));
472
+ expect(markAuthResolved).not.toHaveBeenCalled();
473
+ expect(options.authStore.load).not.toHaveBeenCalled();
474
+
475
+ release(official);
476
+ await boot;
477
+ expect(mockTryCompleteOAuthReturn).toHaveBeenCalledTimes(1);
478
+ window.history.replaceState(null, '', '/');
479
+ });
480
+
481
+ it('identity mode always waits for the verdict', async () => {
482
+ stubSignedOutBoot();
483
+ const { options, markAuthResolved } = makeHarness({ sessionMode: 'identity' });
484
+ const release = pendingRegistry(options);
485
+
486
+ const boot = runProviderColdBoot(options);
487
+ await new Promise((resolve) => setTimeout(resolve, 0));
488
+ expect(markAuthResolved).not.toHaveBeenCalled();
489
+
490
+ release(official);
491
+ await boot;
492
+ expect(markAuthResolved).toHaveBeenCalled();
493
+ });
494
+ });
@@ -14,6 +14,28 @@ import { isWebBrowser } from '../utils/isWebBrowser';
14
14
  import { isNetConnectivityExplicitlyOffline } from '../utils/netConnectivity';
15
15
  import type { CommitInput } from '../context/oxyContextTypes';
16
16
 
17
+ /**
18
+ * Whether the URL carries an OAuth return (`?code=` or `?error=`) for
19
+ * `tryCompleteOAuthReturn` to consume. Reads the address bar only.
20
+ */
21
+ function hasOAuthReturnOnUrl(): boolean {
22
+ const search = (globalThis as { location?: { search?: string } }).location?.search;
23
+ if (!search) return false;
24
+ const params = new URLSearchParams(search);
25
+ return params.has('code') || params.has('error');
26
+ }
27
+
28
+ /**
29
+ * Whether this origin's own store holds anything a web boot could restore from:
30
+ * a warm access token to plant or a device secret to mint with. A presence
31
+ * check only — nothing read here is used, sent or synced before the
32
+ * classification allows it.
33
+ */
34
+ async function hasRestorableState(store: AuthStateStore): Promise<boolean> {
35
+ const persisted = await store.load();
36
+ return Boolean(persisted?.accessToken || persisted?.deviceSecret);
37
+ }
38
+
17
39
  /** How long the cold boot waits for the post-boot SessionClient handoff (ms). */
18
40
  export const SESSION_HANDOFF_DEADLINE_MS = 6000;
19
41
 
@@ -139,10 +161,33 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
139
161
  setTokenReady(false);
140
162
 
141
163
  try {
142
- // Classification precedes every local-secret, shared-credential and identity
143
- // probe. Unavailable registry metadata fails closed, including offline boot.
144
- const lane = await (opts.resolveSessionLane?.() ??
145
- resolveApplicationSessionLane(oxyServices, clientId));
164
+ // Classification precedes every USE of a local secret, the shared credential
165
+ // and the identity key. Unavailable registry metadata fails closed, including
166
+ // offline boot. It starts now, before anything else, so its round trip
167
+ // overlaps the local work below.
168
+ const classification = (async () =>
169
+ opts.resolveSessionLane?.() ?? resolveApplicationSessionLane(oxyServices, clientId))();
170
+ // Observed so a rejection that lands before it is awaited is not reported as
171
+ // unhandled; every path below still awaits it and sees the rejection.
172
+ classification.catch(() => undefined);
173
+
174
+ // The verdict decides HOW a session is restored, never WHETHER there is one.
175
+ // A web origin with nothing to restore and no OAuth return on the URL ends
176
+ // signed out on either lane, so routing need not wait a network round trip
177
+ // for an answer that cannot change the outcome. The classification still
178
+ // completes below and still gates every later sign-in
179
+ // (`ensureDeviceSessionLane` shares this same in-flight lookup).
180
+ if (!identityBound && isWebBrowser() && !hasOAuthReturnOnUrl()) {
181
+ if (!(await hasRestorableState(authStore))) {
182
+ markAuthResolved();
183
+ const lane = await classification;
184
+ await opts.onClassified?.(lane);
185
+ if (lane === 'oauth') setTokenReady(true);
186
+ return;
187
+ }
188
+ }
189
+
190
+ const lane = await classification;
146
191
  await opts.onClassified?.(lane);
147
192
  if (identityBound && lane !== 'device') return;
148
193
  // The redirect transport's RETURN leg. An app reaches it whenever the browser
@@ -0,0 +1,116 @@
1
+ /**
2
+ * "Choose an account" after an email was proven — the email holds several
3
+ * accounts, and the code or the link just proved the mailbox they share.
4
+ *
5
+ * Presentational, and the same rounded list as `OxyAccountPicker`: each row is
6
+ * the account's name and `@handle` on the left, its avatar and a chevron on the
7
+ * right. The host signs the chosen account in (`oxy.auth.email.choose`).
8
+ */
9
+
10
+ import type React from 'react';
11
+ import { Fragment, useState } from 'react';
12
+ import { ScrollView } from 'react-native';
13
+ import { Pressable, View } from 'react-native-css/components';
14
+ import { Avatar } from '@oxy.so/bloom/avatar';
15
+ import { RiArrowRightSLine } from '@oxy.so/bloom/icons/RiArrowRightSLine';
16
+ import { SpinnerIcon } from '@oxy.so/bloom/loading';
17
+ import { useTheme } from '@oxy.so/bloom/theme';
18
+ import { Text } from '@oxy.so/bloom/typography';
19
+ import type { EmailSignInAccountOption } from '@oxy.so/contracts';
20
+ import { getNormalizedUserHandle } from '@oxy.so/core';
21
+ import { useOxy } from '../../context/OxyContext';
22
+
23
+ /** The list scrolls past this height (`max-h-96`). */
24
+ const LIST_MAX_HEIGHT = 384;
25
+ const AVATAR_SIZE = 40;
26
+
27
+ export interface OxyEmailAccountChoiceProps {
28
+ accounts: EmailSignInAccountOption[];
29
+ onChoose: (account: EmailSignInAccountOption) => void;
30
+ /** The account being signed in, whose row shows it is busy. */
31
+ pendingId?: string | null;
32
+ /** Disables every row while a choice is in flight. */
33
+ disabled?: boolean;
34
+ }
35
+
36
+ export const OxyEmailAccountChoice: React.FC<OxyEmailAccountChoiceProps> = ({
37
+ accounts,
38
+ onChoose,
39
+ pendingId = null,
40
+ disabled = false,
41
+ }) => {
42
+ const theme = useTheme();
43
+ const { oxyServices } = useOxy();
44
+ const [hovered, setHovered] = useState<number | null>(null);
45
+
46
+ /** The hairline under row `index`, hidden while either neighbour is hovered. */
47
+ const divider = (index: number) =>
48
+ index === accounts.length - 1 ? null : hovered === index || hovered === index + 1 ? (
49
+ <View className="h-px mx-[8px]" />
50
+ ) : (
51
+ <View className="h-px mx-[8px] bg-border opacity-50" />
52
+ );
53
+
54
+ return (
55
+ <View
56
+ className="bg-fill-secondary rounded-[22px] overflow-hidden"
57
+ testID="email-account-choice"
58
+ >
59
+ <ScrollView style={{ maxHeight: LIST_MAX_HEIGHT }} contentContainerStyle={{ padding: 8 }}>
60
+ {accounts.map((account, index) => {
61
+ const handle = getNormalizedUserHandle({ username: account.username });
62
+ const name = account.displayName ?? (handle ? `@${handle}` : account.id);
63
+ const busy = pendingId === account.id;
64
+ return (
65
+ <Fragment key={account.id}>
66
+ <Pressable
67
+ className={`flex-row items-center gap-[12px] rounded-[14px] p-[8px] ${hovered === index && !disabled ? 'bg-fill' : ''} ${disabled && !busy ? 'opacity-50' : ''}`}
68
+ onPress={() => onChoose(account)}
69
+ onHoverIn={() => setHovered(index)}
70
+ onHoverOut={() => setHovered((value) => (value === index ? null : value))}
71
+ disabled={disabled}
72
+ accessibilityRole="button"
73
+ accessibilityLabel={name}
74
+ accessibilityState={{ disabled, busy }}
75
+ testID={`email-account-${account.id}`}
76
+ >
77
+ <View className="flex-1 min-w-0">
78
+ <Text
79
+ className="text-body text-text"
80
+ style={{ fontWeight: '500' }}
81
+ numberOfLines={1}
82
+ >
83
+ {name}
84
+ </Text>
85
+ {account.displayName && handle ? (
86
+ <Text className="text-bodySmall text-text-secondary" numberOfLines={1}>
87
+ @{handle}
88
+ </Text>
89
+ ) : null}
90
+ </View>
91
+ <View className="flex-row items-center gap-[8px] shrink-0">
92
+ <Avatar
93
+ source={
94
+ account.avatar
95
+ ? oxyServices.assets.publicUrl(account.avatar, 'thumb')
96
+ : undefined
97
+ }
98
+ variant="thumb"
99
+ name={name}
100
+ size={AVATAR_SIZE}
101
+ />
102
+ {busy ? (
103
+ <SpinnerIcon size={20} color={theme.colors.primary} />
104
+ ) : (
105
+ <RiArrowRightSLine size="sm" fill={theme.colors.textSecondary} />
106
+ )}
107
+ </View>
108
+ </Pressable>
109
+ {divider(index)}
110
+ </Fragment>
111
+ );
112
+ })}
113
+ </ScrollView>
114
+ </View>
115
+ );
116
+ };
@@ -16,6 +16,8 @@
16
16
  * (the start response does not say which, by design); meanwhile
17
17
  * the screen asks whether the link was opened in this browser,
18
18
  * and signs in when it was.
19
+ * choose the email holds several accounts, and the code or the link just
20
+ * proved it: which one signs in (a username never gets here).
19
21
  * password the alternative for an account that has one.
20
22
  * second-factor the authenticator's code, or a backup code, when the account has one.
21
23
  *
@@ -39,9 +41,11 @@ import {
39
41
  EMAIL_SIGNIN_LONG_CODE_LENGTH,
40
42
  SIGN_IN_ERROR_CODES,
41
43
  TOTP_DIGITS,
44
+ isEmailSignInChooseAccount,
42
45
  isSecondFactorRequired,
46
+ type EmailSignInAccountOption,
47
+ type EmailSignInStepResult,
43
48
  type LoginResult,
44
- type SignInStepResult,
45
49
  } from '@oxy.so/contracts';
46
50
  import { useQueryClient } from '@tanstack/react-query';
47
51
  import { useOxy } from '../../context/OxyContext';
@@ -56,6 +60,7 @@ import { SIGN_IN_SLIDES } from './artwork';
56
60
  import { InlineCommonsQr } from './InlineCommonsQr';
57
61
  import { OxyAccountPicker } from './OxyAccountPicker';
58
62
  import { OxyAuthScreen, OxyAuthScreenHeader, OxyAuthSplit, OxyAuthTerms } from './OxyAuthScreen';
63
+ import { OxyEmailAccountChoice } from './OxyEmailAccountChoice';
59
64
  import {
60
65
  AccountFlowAction,
61
66
  AccountFlowCodeField,
@@ -94,6 +99,13 @@ const SIGN_IN_FLOW_KEY = 'signin';
94
99
  type Step =
95
100
  | { name: 'start' }
96
101
  | { name: 'check-email'; identifier: string; requestId: string; requestSecret: string }
102
+ | {
103
+ name: 'choose';
104
+ identifier: string;
105
+ requestId: string;
106
+ requestSecret: string;
107
+ accounts: EmailSignInAccountOption[];
108
+ }
97
109
  | { name: 'password'; identifier: string }
98
110
  | { name: 'second-factor'; challengeId: string; identifier: string };
99
111
 
@@ -153,6 +165,7 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
153
165
  const [useBackupCode, setUseBackupCode] = useState(saved?.useBackupCode ?? false);
154
166
  const [longCode, setLongCode] = useState(saved?.longCode ?? false);
155
167
  const [pending, setPending] = useState(false);
168
+ const [choosingId, setChoosingId] = useState<string | null>(null);
156
169
  const [error, setError] = useState<string | null>(null);
157
170
  const [notice, setNotice] = useState<string | null>(null);
158
171
  const [rateLimitSeconds, setRateLimitSeconds] = useState(0);
@@ -225,8 +238,25 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
225
238
  // One sign-in ends once: the typed code and the opened link can race.
226
239
  const finishingRef = useRef(false);
227
240
  const finish = useCallback(
228
- async (result: SignInStepResult, from: string): Promise<void> => {
241
+ async (
242
+ result: EmailSignInStepResult,
243
+ from: string,
244
+ request?: { requestId: string; requestSecret: string },
245
+ ): Promise<void> => {
229
246
  if (finishingRef.current) return;
247
+ if (isEmailSignInChooseAccount(result)) {
248
+ if (!request) return;
249
+ setError(null);
250
+ setNotice(null);
251
+ setStep({
252
+ name: 'choose',
253
+ identifier: from,
254
+ requestId: request.requestId,
255
+ requestSecret: request.requestSecret,
256
+ accounts: result.accounts,
257
+ });
258
+ return;
259
+ }
230
260
  if (isSecondFactorRequired(result)) {
231
261
  setError(null);
232
262
  setSecondFactorCode('');
@@ -301,7 +331,7 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
301
331
  requestSecret: request.requestSecret,
302
332
  code: typed.trim(),
303
333
  })
304
- .then((result) => finish(result, request.identifier))
334
+ .then((result) => finish(result, request.identifier, request))
305
335
  .catch((reason: unknown) => {
306
336
  setCode('');
307
337
  fail(reason);
@@ -309,6 +339,27 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
309
339
  .finally(() => setPending(false));
310
340
  };
311
341
 
342
+ const chooseAccount = (account: EmailSignInAccountOption) => {
343
+ if (step.name !== 'choose' || blocked) return;
344
+ const request = step;
345
+ const name = account.username ?? request.identifier;
346
+ setError(null);
347
+ setPending(true);
348
+ setChoosingId(account.id);
349
+ oxyServices.auth.email
350
+ .choose({
351
+ requestId: request.requestId,
352
+ requestSecret: request.requestSecret,
353
+ userId: account.id,
354
+ })
355
+ .then((result) => finish(result, name))
356
+ .catch(fail)
357
+ .finally(() => {
358
+ setPending(false);
359
+ setChoosingId(null);
360
+ });
361
+ };
362
+
312
363
  const submitPassword = () => {
313
364
  if (step.name !== 'password' || blocked) return;
314
365
  if (!password) {
@@ -369,7 +420,7 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
369
420
  try {
370
421
  const result = await oxyServices.auth.email.collect({ requestId, requestSecret });
371
422
  if (!('status' in result)) {
372
- await finishRef.current(result, requestIdentifier);
423
+ await finishRef.current(result, requestIdentifier, { requestId, requestSecret });
373
424
  return;
374
425
  }
375
426
  if (stopped) return;
@@ -550,6 +601,40 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
550
601
  );
551
602
  break;
552
603
  }
604
+ case 'choose': {
605
+ const request = step;
606
+ form = (
607
+ <OxyAuthScreen className={splits ? 'md:max-w-none' : undefined}>
608
+ <OxyAuthScreenHeader
609
+ title={t('signin.emailAccounts.title')}
610
+ description={t('signin.emailAccounts.description', {
611
+ identifier: request.identifier,
612
+ })}
613
+ />
614
+ <OxyEmailAccountChoice
615
+ accounts={request.accounts}
616
+ onChoose={chooseAccount}
617
+ pendingId={choosingId}
618
+ disabled={blocked}
619
+ />
620
+ {error ? (
621
+ <AccountFlowNotice type="error" testID="signin-choose-error">
622
+ {error}
623
+ </AccountFlowNotice>
624
+ ) : null}
625
+ {rateLimitNotice}
626
+ <View style={styles.links}>
627
+ <SubtleLink
628
+ label={t('signin.checkEmail.differentAccount')}
629
+ onPress={backToIdentifier}
630
+ disabled={pending}
631
+ testID="signin-different-account"
632
+ />
633
+ </View>
634
+ </OxyAuthScreen>
635
+ );
636
+ break;
637
+ }
553
638
  case 'password': {
554
639
  const request = step;
555
640
  form = (
@@ -85,6 +85,8 @@ export function describeSignInError(error: unknown, t: Translate): string {
85
85
  return t('signin.errors.originNotAllowed');
86
86
  case SIGN_IN_ERROR_CODES.usernameTaken:
87
87
  return t('signup.username.taken');
88
+ case SIGN_IN_ERROR_CODES.emailAccountsFull:
89
+ return t('signin.errors.emailAccountsFull');
88
90
  case EMAIL_VERIFICATION_ERROR_CODES.codeInvalid:
89
91
  return t('emailCode.errors.codeInvalid');
90
92
  case EMAIL_VERIFICATION_ERROR_CODES.tooManyAttempts: