@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.
- package/lib/commonjs/ui/boot/runProviderColdBoot.js +47 -3
- package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/commonjs/ui/components/organizations/OrganizationClaimReview.js +208 -0
- package/lib/commonjs/ui/components/organizations/OrganizationClaimReview.js.map +1 -0
- package/lib/commonjs/ui/components/organizations/OrganizationClaims.js +77 -0
- package/lib/commonjs/ui/components/organizations/OrganizationClaims.js.map +1 -0
- package/lib/commonjs/ui/components/organizations/OrganizationDirectorySearch.js +218 -0
- package/lib/commonjs/ui/components/organizations/OrganizationDirectorySearch.js.map +1 -0
- package/lib/commonjs/ui/components/organizations/OrganizationSettings.js +147 -0
- package/lib/commonjs/ui/components/organizations/OrganizationSettings.js.map +1 -0
- package/lib/commonjs/ui/components/organizations/OrganizationTypePicker.js +34 -0
- package/lib/commonjs/ui/components/organizations/OrganizationTypePicker.js.map +1 -0
- package/lib/commonjs/ui/screens/AccountSettingsScreen.js +14 -5
- package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/CreateAccountScreen.js +148 -115
- package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/ManageAccountScreen.js +13 -1
- package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/module/ui/boot/runProviderColdBoot.js +47 -3
- package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/module/ui/components/organizations/OrganizationClaimReview.js +205 -0
- package/lib/module/ui/components/organizations/OrganizationClaimReview.js.map +1 -0
- package/lib/module/ui/components/organizations/OrganizationClaims.js +73 -0
- package/lib/module/ui/components/organizations/OrganizationClaims.js.map +1 -0
- package/lib/module/ui/components/organizations/OrganizationDirectorySearch.js +215 -0
- package/lib/module/ui/components/organizations/OrganizationDirectorySearch.js.map +1 -0
- package/lib/module/ui/components/organizations/OrganizationSettings.js +143 -0
- package/lib/module/ui/components/organizations/OrganizationSettings.js.map +1 -0
- package/lib/module/ui/components/organizations/OrganizationTypePicker.js +30 -0
- package/lib/module/ui/components/organizations/OrganizationTypePicker.js.map +1 -0
- package/lib/module/ui/screens/AccountSettingsScreen.js +14 -5
- package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
- package/lib/module/ui/screens/CreateAccountScreen.js +149 -115
- package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
- package/lib/module/ui/screens/ManageAccountScreen.js +13 -1
- package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaimReview.d.ts +3 -0
- package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaimReview.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaims.d.ts +2 -0
- package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaims.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/organizations/OrganizationDirectorySearch.d.ts +6 -0
- package/lib/typescript/commonjs/ui/components/organizations/OrganizationDirectorySearch.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/organizations/OrganizationSettings.d.ts +5 -0
- package/lib/typescript/commonjs/ui/components/organizations/OrganizationSettings.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/organizations/OrganizationTypePicker.d.ts +7 -0
- package/lib/typescript/commonjs/ui/components/organizations/OrganizationTypePicker.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +2 -2
- package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +1 -1
- package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/organizations/OrganizationClaimReview.d.ts +3 -0
- package/lib/typescript/module/ui/components/organizations/OrganizationClaimReview.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/organizations/OrganizationClaims.d.ts +2 -0
- package/lib/typescript/module/ui/components/organizations/OrganizationClaims.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/organizations/OrganizationDirectorySearch.d.ts +6 -0
- package/lib/typescript/module/ui/components/organizations/OrganizationDirectorySearch.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/organizations/OrganizationSettings.d.ts +5 -0
- package/lib/typescript/module/ui/components/organizations/OrganizationSettings.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/organizations/OrganizationTypePicker.d.ts +7 -0
- package/lib/typescript/module/ui/components/organizations/OrganizationTypePicker.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +2 -2
- package/lib/typescript/module/ui/icons/Ionicons.d.ts +1 -1
- package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
- package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/ui/boot/__tests__/coldBootNoIdpRedirect.test.ts +166 -3
- package/src/ui/boot/runProviderColdBoot.ts +49 -4
- package/src/ui/components/organizations/OrganizationClaimReview.tsx +218 -0
- package/src/ui/components/organizations/OrganizationClaims.tsx +67 -0
- package/src/ui/components/organizations/OrganizationDirectorySearch.tsx +228 -0
- package/src/ui/components/organizations/OrganizationSettings.tsx +131 -0
- package/src/ui/components/organizations/OrganizationTypePicker.tsx +31 -0
- package/src/ui/screens/AccountSettingsScreen.tsx +22 -4
- package/src/ui/screens/CreateAccountScreen.tsx +177 -124
- 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
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
}
|