@oxy.so/services 11.2.0 → 11.3.1

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 (85) hide show
  1. package/lib/commonjs/ui/boot/runProviderColdBoot.js +47 -3
  2. package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
  3. package/lib/commonjs/ui/components/organizations/OrganizationClaimReview.js +208 -0
  4. package/lib/commonjs/ui/components/organizations/OrganizationClaimReview.js.map +1 -0
  5. package/lib/commonjs/ui/components/organizations/OrganizationClaims.js +77 -0
  6. package/lib/commonjs/ui/components/organizations/OrganizationClaims.js.map +1 -0
  7. package/lib/commonjs/ui/components/organizations/OrganizationDirectorySearch.js +218 -0
  8. package/lib/commonjs/ui/components/organizations/OrganizationDirectorySearch.js.map +1 -0
  9. package/lib/commonjs/ui/components/organizations/OrganizationSettings.js +147 -0
  10. package/lib/commonjs/ui/components/organizations/OrganizationSettings.js.map +1 -0
  11. package/lib/commonjs/ui/components/organizations/OrganizationTypePicker.js +34 -0
  12. package/lib/commonjs/ui/components/organizations/OrganizationTypePicker.js.map +1 -0
  13. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +14 -5
  14. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
  15. package/lib/commonjs/ui/screens/CreateAccountScreen.js +148 -115
  16. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  17. package/lib/commonjs/ui/screens/ManageAccountScreen.js +13 -1
  18. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  19. package/lib/module/ui/boot/runProviderColdBoot.js +47 -3
  20. package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
  21. package/lib/module/ui/components/organizations/OrganizationClaimReview.js +205 -0
  22. package/lib/module/ui/components/organizations/OrganizationClaimReview.js.map +1 -0
  23. package/lib/module/ui/components/organizations/OrganizationClaims.js +73 -0
  24. package/lib/module/ui/components/organizations/OrganizationClaims.js.map +1 -0
  25. package/lib/module/ui/components/organizations/OrganizationDirectorySearch.js +215 -0
  26. package/lib/module/ui/components/organizations/OrganizationDirectorySearch.js.map +1 -0
  27. package/lib/module/ui/components/organizations/OrganizationSettings.js +143 -0
  28. package/lib/module/ui/components/organizations/OrganizationSettings.js.map +1 -0
  29. package/lib/module/ui/components/organizations/OrganizationTypePicker.js +30 -0
  30. package/lib/module/ui/components/organizations/OrganizationTypePicker.js.map +1 -0
  31. package/lib/module/ui/screens/AccountSettingsScreen.js +14 -5
  32. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
  33. package/lib/module/ui/screens/CreateAccountScreen.js +149 -115
  34. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  35. package/lib/module/ui/screens/ManageAccountScreen.js +13 -1
  36. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  37. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaimReview.d.ts +3 -0
  39. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaimReview.d.ts.map +1 -0
  40. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaims.d.ts +2 -0
  41. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaims.d.ts.map +1 -0
  42. package/lib/typescript/commonjs/ui/components/organizations/OrganizationDirectorySearch.d.ts +6 -0
  43. package/lib/typescript/commonjs/ui/components/organizations/OrganizationDirectorySearch.d.ts.map +1 -0
  44. package/lib/typescript/commonjs/ui/components/organizations/OrganizationSettings.d.ts +5 -0
  45. package/lib/typescript/commonjs/ui/components/organizations/OrganizationSettings.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/ui/components/organizations/OrganizationTypePicker.d.ts +7 -0
  47. package/lib/typescript/commonjs/ui/components/organizations/OrganizationTypePicker.d.ts.map +1 -0
  48. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
  49. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +2 -2
  51. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +1 -1
  52. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  53. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  56. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  57. package/lib/typescript/module/ui/components/organizations/OrganizationClaimReview.d.ts +3 -0
  58. package/lib/typescript/module/ui/components/organizations/OrganizationClaimReview.d.ts.map +1 -0
  59. package/lib/typescript/module/ui/components/organizations/OrganizationClaims.d.ts +2 -0
  60. package/lib/typescript/module/ui/components/organizations/OrganizationClaims.d.ts.map +1 -0
  61. package/lib/typescript/module/ui/components/organizations/OrganizationDirectorySearch.d.ts +6 -0
  62. package/lib/typescript/module/ui/components/organizations/OrganizationDirectorySearch.d.ts.map +1 -0
  63. package/lib/typescript/module/ui/components/organizations/OrganizationSettings.d.ts +5 -0
  64. package/lib/typescript/module/ui/components/organizations/OrganizationSettings.d.ts.map +1 -0
  65. package/lib/typescript/module/ui/components/organizations/OrganizationTypePicker.d.ts +7 -0
  66. package/lib/typescript/module/ui/components/organizations/OrganizationTypePicker.d.ts.map +1 -0
  67. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
  68. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  69. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +2 -2
  70. package/lib/typescript/module/ui/icons/Ionicons.d.ts +1 -1
  71. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  72. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  73. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  74. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  75. package/package.json +8 -8
  76. package/src/ui/boot/__tests__/coldBootNoIdpRedirect.test.ts +166 -3
  77. package/src/ui/boot/runProviderColdBoot.ts +49 -4
  78. package/src/ui/components/organizations/OrganizationClaimReview.tsx +218 -0
  79. package/src/ui/components/organizations/OrganizationClaims.tsx +67 -0
  80. package/src/ui/components/organizations/OrganizationDirectorySearch.tsx +228 -0
  81. package/src/ui/components/organizations/OrganizationSettings.tsx +131 -0
  82. package/src/ui/components/organizations/OrganizationTypePicker.tsx +31 -0
  83. package/src/ui/screens/AccountSettingsScreen.tsx +22 -4
  84. package/src/ui/screens/CreateAccountScreen.tsx +177 -124
  85. package/src/ui/screens/ManageAccountScreen.tsx +18 -0
@@ -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,218 @@
1
+ import { useState } from 'react';
2
+ import { ActivityIndicator, View } from 'react-native';
3
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { isHttpRequestError } from '@oxy.so/core';
5
+ import {
6
+ organizationClaimReviewSchema,
7
+ type OrganizationClaimReview as OrganizationClaimReviewInput,
8
+ } from '@oxy.so/contracts';
9
+ import { Button } from '@oxy.so/bloom/button';
10
+ import { SettingsListGroup, SettingsListItem } from '@oxy.so/bloom/settings-list';
11
+ import { TextField, TextFieldInput } from '@oxy.so/bloom/text-field';
12
+ import { Text } from '@oxy.so/bloom/typography';
13
+ import { useOxy } from '../../context/OxyContext';
14
+ import { useI18n } from '../../hooks/useI18n';
15
+
16
+ /** The queue is requested only after an explicit action; the server rechecks staff authority. */
17
+ export function OrganizationClaimReview() {
18
+ const { t } = useI18n();
19
+ const { user, canUsePrivateApi } = useOxy();
20
+ const [open, setOpen] = useState(false);
21
+ return (
22
+ <SettingsListGroup>
23
+ <SettingsListItem
24
+ title={t('organizationDirectory.review.title')}
25
+ disabled={!canUsePrivateApi}
26
+ onPress={() => setOpen((value) => !value)}
27
+ />
28
+ {open && user?.kind === 'personal' && canUsePrivateApi ? <ReviewQueue key={user.id} /> : null}
29
+ </SettingsListGroup>
30
+ );
31
+ }
32
+ function ReviewQueue() {
33
+ const { t } = useI18n();
34
+ const { oxyServices, user, canUsePrivateApi } = useOxy();
35
+ const [selected, setSelected] = useState<string | null>(null);
36
+ const requests = useInfiniteQuery({
37
+ queryKey: ['oxy', 'organizations', 'review', user?.id, canUsePrivateApi],
38
+ queryFn: ({ pageParam }) =>
39
+ oxyServices.organizations.reviewClaims({
40
+ status: 'pending',
41
+ limit: 20,
42
+ ...(pageParam ? { cursor: pageParam } : {}),
43
+ }),
44
+ initialPageParam: undefined as string | undefined,
45
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
46
+ enabled: canUsePrivateApi,
47
+ retry: false,
48
+ gcTime: 0,
49
+ });
50
+ const claims = requests.data?.pages.flatMap((page) => page.items) ?? [];
51
+ const ids = [...new Set(claims.map((claim) => claim.accountId))].sort();
52
+ const organizations = useQuery({
53
+ queryKey: ['oxy', 'organizations', 'review-organizations', ids],
54
+ queryFn: async () => {
55
+ const batches = [];
56
+ for (let offset = 0; offset < ids.length; offset += 100)
57
+ batches.push(oxyServices.organizations.batch({ ids: ids.slice(offset, offset + 100) }));
58
+ return (await Promise.all(batches)).flatMap((batch) => batch.items);
59
+ },
60
+ enabled: canUsePrivateApi && requests.isSuccess && ids.length > 0,
61
+ retry: false,
62
+ });
63
+ if (requests.isPending) return <ActivityIndicator />;
64
+ if (requests.isError)
65
+ return (
66
+ <View className="gap-space-16 p-space-16">
67
+ <Text accessibilityRole="alert">
68
+ {t(
69
+ isHttpRequestError(requests.error) && requests.error.status === 403
70
+ ? 'organizationDirectory.review.forbidden'
71
+ : 'organizationDirectory.requestError',
72
+ )}
73
+ </Text>
74
+ <Button onPress={() => requests.refetch()}>{t('organizationDirectory.retry')}</Button>
75
+ </View>
76
+ );
77
+ if (selected)
78
+ return <ReviewDetail claimId={selected} key={selected} onClose={() => setSelected(null)} />;
79
+ return (
80
+ <View className="gap-space-16 p-space-16">
81
+ {claims.length === 0 ? <Text>{t('organizationDirectory.review.empty')}</Text> : null}
82
+ {claims.map((claim) => (
83
+ <SettingsListItem
84
+ key={claim.id}
85
+ title={
86
+ organizations.data?.find((item) => item.requestedAccountId === claim.accountId)
87
+ ?.organization.name ?? t('organizationDirectory.view')
88
+ }
89
+ description={t('organizationDirectory.pending')}
90
+ onPress={() => setSelected(claim.id)}
91
+ />
92
+ ))}
93
+ {requests.hasNextPage ? (
94
+ <Button onPress={() => requests.fetchNextPage()} disabled={requests.isFetchingNextPage}>
95
+ {t('organizationDirectory.loadMore')}
96
+ </Button>
97
+ ) : null}
98
+ </View>
99
+ );
100
+ }
101
+ function ReviewDetail({ claimId, onClose }: { claimId: string; onClose: () => void }) {
102
+ const { t } = useI18n();
103
+ const { oxyServices, user, canUsePrivateApi } = useOxy();
104
+ const queryClient = useQueryClient();
105
+ const [evidence, setEvidence] = useState('');
106
+ const [authorizationRevoked, setAuthorizationRevoked] = useState(false);
107
+ const detail = useQuery({
108
+ queryKey: ['oxy', 'organizations', 'review-detail', user?.id, canUsePrivateApi, claimId],
109
+ queryFn: () => oxyServices.organizations.reviewClaimDetail(claimId),
110
+ enabled: canUsePrivateApi,
111
+ retry: false,
112
+ gcTime: 0,
113
+ });
114
+ const review = useMutation({
115
+ mutationFn: (decision: OrganizationClaimReviewInput['decision']) => {
116
+ if (
117
+ !canUsePrivateApi ||
118
+ authorizationRevoked ||
119
+ !detail.isSuccess ||
120
+ !detail.data ||
121
+ detail.data.claimantUserId === user?.id
122
+ )
123
+ throw new Error('Independent staff review is required');
124
+ return oxyServices.organizations.reviewClaim(
125
+ claimId,
126
+ organizationClaimReviewSchema.parse({ decision, evidenceReference: evidence }),
127
+ );
128
+ },
129
+ onError: (error) => {
130
+ if (isHttpRequestError(error) && error.status === 403) setAuthorizationRevoked(true);
131
+ },
132
+ onSuccess: async () => {
133
+ await queryClient.invalidateQueries({ queryKey: ['oxy', 'organizations'] });
134
+ await queryClient.invalidateQueries({ queryKey: ['accounts'] });
135
+ onClose();
136
+ },
137
+ });
138
+ const conflict = isHttpRequestError(review.error) && review.error.status === 409;
139
+ const forbidden = isHttpRequestError(review.error) && review.error.status === 403;
140
+ const selfReview = detail.data?.claimantUserId === user?.id;
141
+ return (
142
+ <View className="gap-space-16 p-space-16">
143
+ {detail.isPending ? <ActivityIndicator /> : null}
144
+ {detail.isError ? (
145
+ <>
146
+ <Text accessibilityRole="alert">
147
+ {t(
148
+ isHttpRequestError(detail.error) && detail.error.status === 403
149
+ ? 'organizationDirectory.review.forbidden'
150
+ : 'organizationDirectory.requestError',
151
+ )}
152
+ </Text>
153
+ <Button onPress={() => detail.refetch()}>{t('organizationDirectory.retry')}</Button>
154
+ </>
155
+ ) : null}
156
+ {authorizationRevoked ? (
157
+ <Text accessibilityRole="alert">{t('organizationDirectory.review.forbidden')}</Text>
158
+ ) : null}
159
+ {detail.isSuccess && canUsePrivateApi && !authorizationRevoked ? (
160
+ <>
161
+ <Text>{t('organizationDirectory.evidence')}</Text>
162
+ <Text selectable>{detail.data.evidenceReference}</Text>
163
+ <Text>{t('organizationDirectory.review.hint')}</Text>
164
+ {selfReview ? <Text>{t('organizationDirectory.review.independent')}</Text> : null}
165
+ <TextField>
166
+ <TextFieldInput
167
+ label={t('organizationDirectory.review.evidence')}
168
+ value={evidence}
169
+ onChangeText={setEvidence}
170
+ maxLength={1000}
171
+ />
172
+ </TextField>
173
+ {review.isError ? (
174
+ <Text accessibilityRole="alert">
175
+ {t(
176
+ conflict
177
+ ? 'organizationDirectory.review.conflict'
178
+ : forbidden
179
+ ? 'organizationDirectory.review.forbidden'
180
+ : 'organizationDirectory.review.failed',
181
+ )}
182
+ </Text>
183
+ ) : null}
184
+ {(['approved', 'rejected'] as const).map((decision) => (
185
+ <Button
186
+ key={decision}
187
+ disabled={
188
+ !canUsePrivateApi ||
189
+ selfReview ||
190
+ detail.data.status !== 'pending' ||
191
+ !evidence.trim() ||
192
+ review.isPending ||
193
+ conflict ||
194
+ forbidden
195
+ }
196
+ onPress={() => review.mutate(decision)}
197
+ >
198
+ {t(`organizationDirectory.review.${decision}`)}
199
+ </Button>
200
+ ))}
201
+ {conflict ? (
202
+ <Button
203
+ onPress={async () => {
204
+ await queryClient.invalidateQueries({ queryKey: ['oxy', 'organizations'] });
205
+ onClose();
206
+ }}
207
+ >
208
+ {t('organizationDirectory.retry')}
209
+ </Button>
210
+ ) : null}
211
+ </>
212
+ ) : null}
213
+ <Button appearance="outline" onPress={onClose} disabled={review.isPending}>
214
+ {t('organizationDirectory.back')}
215
+ </Button>
216
+ </View>
217
+ );
218
+ }
@@ -0,0 +1,67 @@
1
+ import { View } from 'react-native';
2
+ import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
3
+ import { Button } from '@oxy.so/bloom/button';
4
+ import { SettingsListGroup, SettingsListItem } from '@oxy.so/bloom/settings-list';
5
+ import { Text } from '@oxy.so/bloom/typography';
6
+ import { useOxy } from '../../context/OxyContext';
7
+ import { useI18n } from '../../hooks/useI18n';
8
+
9
+ export function OrganizationClaims() {
10
+ const { oxyServices, user, canUsePrivateApi } = useOxy();
11
+ const { t } = useI18n();
12
+ const enabled = canUsePrivateApi && user?.kind === 'personal';
13
+ const requests = useInfiniteQuery({
14
+ queryKey: ['oxy', 'organizations', 'claims', user?.id, canUsePrivateApi],
15
+ queryFn: ({ pageParam }) =>
16
+ oxyServices.organizations.myClaims({
17
+ limit: 20,
18
+ ...(pageParam ? { cursor: pageParam } : {}),
19
+ }),
20
+ initialPageParam: undefined as string | undefined,
21
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
22
+ enabled,
23
+ retry: false,
24
+ gcTime: 0,
25
+ });
26
+ const claims = requests.data?.pages.flatMap((page) => page.items) ?? [];
27
+ const ids = [...new Set(claims.map((claim) => claim.accountId))].sort();
28
+ const organizations = useQuery({
29
+ queryKey: ['oxy', 'organizations', 'claims-employers', ids],
30
+ queryFn: async () => {
31
+ const batches = [];
32
+ for (let offset = 0; offset < ids.length; offset += 100)
33
+ batches.push(oxyServices.organizations.batch({ ids: ids.slice(offset, offset + 100) }));
34
+ return (await Promise.all(batches)).flatMap((batch) => batch.items);
35
+ },
36
+ enabled: enabled && ids.length > 0,
37
+ retry: false,
38
+ });
39
+ if (!enabled || (!requests.isError && claims.length === 0)) return null;
40
+ return (
41
+ <SettingsListGroup title={t('organizationDirectory.requests')}>
42
+ {requests.isError ? (
43
+ <View>
44
+ <Text accessibilityRole="alert">{t('organizationDirectory.requestError')}</Text>
45
+ <Button onPress={() => requests.refetch()}>{t('organizationDirectory.retry')}</Button>
46
+ </View>
47
+ ) : null}
48
+ {!requests.isError &&
49
+ claims.map((claim) => (
50
+ <SettingsListItem
51
+ key={claim.id}
52
+ title={
53
+ organizations.data?.find((item) => item.requestedAccountId === claim.accountId)
54
+ ?.organization.name ?? t('organizationDirectory.view')
55
+ }
56
+ description={t(`organizationDirectory.${claim.status}`)}
57
+ showChevron={false}
58
+ />
59
+ ))}
60
+ {!requests.isError && requests.hasNextPage ? (
61
+ <Button onPress={() => requests.fetchNextPage()} disabled={requests.isFetchingNextPage}>
62
+ {t('organizationDirectory.loadMore')}
63
+ </Button>
64
+ ) : null}
65
+ </SettingsListGroup>
66
+ );
67
+ }