@stigmer/react 3.15.0 → 3.15.2-dev.20260913223433
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/organization/useOrgGate.d.ts +19 -23
- package/organization/useOrgGate.d.ts.map +1 -1
- package/organization/useOrgGate.js +19 -51
- package/organization/useOrgGate.js.map +1 -1
- package/package.json +4 -4
- package/src/organization/__tests__/useOrgGate.test.tsx +84 -0
- package/src/organization/useOrgGate.ts +20 -69
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Options passed to {@link useOrgGate} by the host application.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* `isBypassed` is computed by the consumer using framework-specific APIs
|
|
5
5
|
* (e.g. `usePathname()` in Next.js, `useLocation()` in react-router) so
|
|
6
6
|
* that the hook itself has zero framework dependencies.
|
|
7
7
|
*/
|
|
8
8
|
export interface UseOrgGateOptions {
|
|
9
9
|
/** True when the current route should bypass the gate (e.g. `/invite/` links). */
|
|
10
10
|
readonly isBypassed: boolean;
|
|
11
|
-
/** True when the auth mode supports server-side personal org provisioning. */
|
|
12
|
-
readonly isOidcMode: boolean;
|
|
13
11
|
}
|
|
14
12
|
/**
|
|
15
13
|
* Discriminated union representing the current state of the org gate.
|
|
@@ -28,9 +26,6 @@ export type OrgGateState = {
|
|
|
28
26
|
} | {
|
|
29
27
|
/** Initial organization list fetch is in progress. */
|
|
30
28
|
readonly status: "loading";
|
|
31
|
-
} | {
|
|
32
|
-
/** Personal organization provisioning is in progress. */
|
|
33
|
-
readonly status: "provisioning";
|
|
34
29
|
} | {
|
|
35
30
|
/** Organization fetch failed and user action is required. */
|
|
36
31
|
readonly status: "error";
|
|
@@ -59,28 +54,31 @@ export interface UseOrgGateReturn {
|
|
|
59
54
|
readonly refresh: (targetSlug?: string) => void;
|
|
60
55
|
}
|
|
61
56
|
/**
|
|
62
|
-
* Headless behavior hook that
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* The hook observes the organization context from {@link useOrg} and
|
|
66
|
-
* drives the following lifecycle:
|
|
57
|
+
* Headless behavior hook that derives the org-gate state from
|
|
58
|
+
* {@link useOrg}.
|
|
67
59
|
*
|
|
68
60
|
* 1. **`bypassed`** — `isBypassed` is true; the gate is inactive.
|
|
69
61
|
* 2. **`loading`** — the initial org list fetch is in flight.
|
|
70
|
-
* 3. **`
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* 5. **`
|
|
74
|
-
*
|
|
75
|
-
*
|
|
62
|
+
* 3. **`error`** — the org fetch failed; `message` carries the reason.
|
|
63
|
+
* 4. **`no-orgs`** — no organizations exist; the consumer should show an
|
|
64
|
+
* onboarding form.
|
|
65
|
+
* 5. **`ready`** — at least one org exists; render the app.
|
|
66
|
+
*
|
|
67
|
+
* A pure derivation, deliberately: the organization list is final by the
|
|
68
|
+
* time this hook runs. Where a server creates a personal organization on
|
|
69
|
+
* first sign-in (Stigmer Cloud), it does so inside `provisionMyAccount`,
|
|
70
|
+
* before the identity gate ahead of this one reports ready — so there is
|
|
71
|
+
* nothing to wait for, and an empty list means the person creates their
|
|
72
|
+
* first organization now. (The hook once polled for that organization for
|
|
73
|
+
* ten seconds, a relic of an earlier asynchronous provisioner; on a server
|
|
74
|
+
* that never provisions one, every first sign-in paid the full wait.)
|
|
76
75
|
*
|
|
77
|
-
* The consumer computes `isBypassed`
|
|
78
|
-
*
|
|
79
|
-
* routing or auth-framework dependencies (DD-004).
|
|
76
|
+
* The consumer computes `isBypassed` using framework-specific APIs and
|
|
77
|
+
* passes it in, keeping this hook free of routing dependencies (DD-004).
|
|
80
78
|
*
|
|
81
79
|
* @example
|
|
82
80
|
* ```tsx
|
|
83
|
-
* const { state, retry, refresh } = useOrgGate({ isBypassed
|
|
81
|
+
* const { state, retry, refresh } = useOrgGate({ isBypassed });
|
|
84
82
|
*
|
|
85
83
|
* switch (state.status) {
|
|
86
84
|
* case "bypassed":
|
|
@@ -88,8 +86,6 @@ export interface UseOrgGateReturn {
|
|
|
88
86
|
* return <>{children}</>;
|
|
89
87
|
* case "loading":
|
|
90
88
|
* return <Spinner />;
|
|
91
|
-
* case "provisioning":
|
|
92
|
-
* return <WelcomeScreen />;
|
|
93
89
|
* case "error":
|
|
94
90
|
* return <ErrorScreen message={state.message} onRetry={retry} />;
|
|
95
91
|
* case "no-orgs":
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useOrgGate.d.ts","sourceRoot":"","sources":["../../src/organization/useOrgGate.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useOrgGate.d.ts","sourceRoot":"","sources":["../../src/organization/useOrgGate.ts"],"names":[],"mappings":"AAQA;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,kFAAkF;IAClF,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC9B;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,YAAY,GACpB;IACE,8CAA8C;IAC9C,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;CAC7B,GACD;IACE,sDAAsD;IACtD,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;CAC5B,GACD;IACE,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,GACD;IACE,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;CAC5B,GACD;IACE,qEAAqE;IACrE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEN;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,yDAAyD;IACzD,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAC;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACjD;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CAkBvE"}
|
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
3
2
|
import { useOrg } from "./OrgProvider.js";
|
|
4
|
-
const PROVISIONING_POLL_MS = 2_000;
|
|
5
|
-
const PROVISIONING_TIMEOUT_MS = 10_000;
|
|
6
3
|
// ---------------------------------------------------------------------------
|
|
7
4
|
// Hook
|
|
8
5
|
// ---------------------------------------------------------------------------
|
|
9
6
|
/**
|
|
10
|
-
* Headless behavior hook that
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* The hook observes the organization context from {@link useOrg} and
|
|
14
|
-
* drives the following lifecycle:
|
|
7
|
+
* Headless behavior hook that derives the org-gate state from
|
|
8
|
+
* {@link useOrg}.
|
|
15
9
|
*
|
|
16
10
|
* 1. **`bypassed`** — `isBypassed` is true; the gate is inactive.
|
|
17
11
|
* 2. **`loading`** — the initial org list fetch is in flight.
|
|
18
|
-
* 3. **`
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* 5. **`
|
|
22
|
-
*
|
|
23
|
-
*
|
|
12
|
+
* 3. **`error`** — the org fetch failed; `message` carries the reason.
|
|
13
|
+
* 4. **`no-orgs`** — no organizations exist; the consumer should show an
|
|
14
|
+
* onboarding form.
|
|
15
|
+
* 5. **`ready`** — at least one org exists; render the app.
|
|
16
|
+
*
|
|
17
|
+
* A pure derivation, deliberately: the organization list is final by the
|
|
18
|
+
* time this hook runs. Where a server creates a personal organization on
|
|
19
|
+
* first sign-in (Stigmer Cloud), it does so inside `provisionMyAccount`,
|
|
20
|
+
* before the identity gate ahead of this one reports ready — so there is
|
|
21
|
+
* nothing to wait for, and an empty list means the person creates their
|
|
22
|
+
* first organization now. (The hook once polled for that organization for
|
|
23
|
+
* ten seconds, a relic of an earlier asynchronous provisioner; on a server
|
|
24
|
+
* that never provisions one, every first sign-in paid the full wait.)
|
|
24
25
|
*
|
|
25
|
-
* The consumer computes `isBypassed`
|
|
26
|
-
*
|
|
27
|
-
* routing or auth-framework dependencies (DD-004).
|
|
26
|
+
* The consumer computes `isBypassed` using framework-specific APIs and
|
|
27
|
+
* passes it in, keeping this hook free of routing dependencies (DD-004).
|
|
28
28
|
*
|
|
29
29
|
* @example
|
|
30
30
|
* ```tsx
|
|
31
|
-
* const { state, retry, refresh } = useOrgGate({ isBypassed
|
|
31
|
+
* const { state, retry, refresh } = useOrgGate({ isBypassed });
|
|
32
32
|
*
|
|
33
33
|
* switch (state.status) {
|
|
34
34
|
* case "bypassed":
|
|
@@ -36,8 +36,6 @@ const PROVISIONING_TIMEOUT_MS = 10_000;
|
|
|
36
36
|
* return <>{children}</>;
|
|
37
37
|
* case "loading":
|
|
38
38
|
* return <Spinner />;
|
|
39
|
-
* case "provisioning":
|
|
40
|
-
* return <WelcomeScreen />;
|
|
41
39
|
* case "error":
|
|
42
40
|
* return <ErrorScreen message={state.message} onRetry={retry} />;
|
|
43
41
|
* case "no-orgs":
|
|
@@ -46,42 +44,12 @@ const PROVISIONING_TIMEOUT_MS = 10_000;
|
|
|
46
44
|
* ```
|
|
47
45
|
*/
|
|
48
46
|
export function useOrgGate(options) {
|
|
49
|
-
const { isBypassed
|
|
47
|
+
const { isBypassed } = options;
|
|
50
48
|
const { orgs, isLoading, error, retry, refresh } = useOrg();
|
|
51
|
-
const [provisioningStarted, setProvisioningStarted] = useState(false);
|
|
52
|
-
const [provisioningTimedOut, setProvisioningTimedOut] = useState(false);
|
|
53
|
-
// React-sanctioned "adjust state during render" pattern: the guard on
|
|
54
|
-
// `!provisioningStarted` prevents infinite re-render loops.
|
|
55
|
-
if (!isBypassed &&
|
|
56
|
-
!provisioningStarted &&
|
|
57
|
-
!isLoading &&
|
|
58
|
-
orgs.length === 0 &&
|
|
59
|
-
!error &&
|
|
60
|
-
isOidcMode) {
|
|
61
|
-
setProvisioningStarted(true);
|
|
62
|
-
}
|
|
63
|
-
const isProvisioning = provisioningStarted && orgs.length === 0 && !provisioningTimedOut;
|
|
64
|
-
// Poll for the personal org while provisioning is in progress.
|
|
65
|
-
// Errors from refresh() are absorbed — transient failures (identity not
|
|
66
|
-
// yet created) are expected during the provisioning window.
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
if (!isProvisioning)
|
|
69
|
-
return;
|
|
70
|
-
const interval = setInterval(() => refresh(), PROVISIONING_POLL_MS);
|
|
71
|
-
const timeout = setTimeout(() => setProvisioningTimedOut(true), PROVISIONING_TIMEOUT_MS);
|
|
72
|
-
return () => {
|
|
73
|
-
clearInterval(interval);
|
|
74
|
-
clearTimeout(timeout);
|
|
75
|
-
};
|
|
76
|
-
}, [isProvisioning, refresh]);
|
|
77
|
-
// Resolve state — order matters: bypass and provisioning take priority.
|
|
78
49
|
let state;
|
|
79
50
|
if (isBypassed) {
|
|
80
51
|
state = { status: "bypassed" };
|
|
81
52
|
}
|
|
82
|
-
else if (isProvisioning) {
|
|
83
|
-
state = { status: "provisioning" };
|
|
84
|
-
}
|
|
85
53
|
else if (isLoading) {
|
|
86
54
|
state = { status: "loading" };
|
|
87
55
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useOrgGate.js","sourceRoot":"","sources":["../../src/organization/useOrgGate.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"useOrgGate.js","sourceRoot":"","sources":["../../src/organization/useOrgGate.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAqE1C,8EAA8E;AAC9E,OAAO;AACP,8EAA8E;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,MAAM,UAAU,UAAU,CAAC,OAA0B;IACnD,MAAM,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IAC/B,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,MAAM,EAAE,CAAC;IAE5D,IAAI,KAAmB,CAAC;IACxB,IAAI,UAAU,EAAE,CAAC;QACf,KAAK,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IACjC,CAAC;SAAM,IAAI,SAAS,EAAE,CAAC;QACrB,KAAK,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAChC,CAAC;SAAM,IAAI,KAAK,EAAE,CAAC;QACjB,KAAK,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IAC9C,CAAC;SAAM,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC7B,KAAK,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAChC,CAAC;SAAM,CAAC;QACN,KAAK,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IAC9B,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;AACnC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/react",
|
|
3
|
-
"version": "3.15.
|
|
3
|
+
"version": "3.15.2-dev.20260913223433",
|
|
4
4
|
"description": "React provider and client hook for the Stigmer platform SDK",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@bufbuild/protovalidate": "^1.1.1",
|
|
39
39
|
"@dagrejs/dagre": "^1.1.4",
|
|
40
|
-
"@stigmer/theme": "3.15.
|
|
40
|
+
"@stigmer/theme": "3.15.2-dev.20260913223433",
|
|
41
41
|
"diff": "^8.0.3",
|
|
42
42
|
"fflate": "^0.8.2",
|
|
43
43
|
"hast-util-to-jsx-runtime": "^2.3.6",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"@codemirror/state": "^6.0.0",
|
|
61
61
|
"@codemirror/view": "^6.0.0",
|
|
62
62
|
"@lezer/highlight": "^1.0.0",
|
|
63
|
-
"@stigmer/protos": "3.15.
|
|
64
|
-
"@stigmer/sdk": "3.15.
|
|
63
|
+
"@stigmer/protos": "3.15.2-dev.20260913223433",
|
|
64
|
+
"@stigmer/sdk": "3.15.2-dev.20260913223433",
|
|
65
65
|
"@tanstack/react-table": "^8.20.0",
|
|
66
66
|
"@xyflow/react": "^12.0.0",
|
|
67
67
|
"elkjs": "^0.9.0",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Pins useOrgGate as a pure derivation over useOrg() (20260913.02,
|
|
2
|
+
// sp.console-login Q-CL-5). The hook once carried a "provisioning" arm that
|
|
3
|
+
// polled for a personal organization the server was "still creating"; the
|
|
4
|
+
// cloud has created that organization synchronously inside
|
|
5
|
+
// provisionMyAccount since 20260911.11, and the step is best-effort, so by
|
|
6
|
+
// the time the identity gate is ready the organization list is final in
|
|
7
|
+
// every edition. Zero organizations is therefore `no-orgs` at once — no
|
|
8
|
+
// timers, no options beyond the route bypass — and the arms below fail if
|
|
9
|
+
// a wait ever comes back.
|
|
10
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
11
|
+
import { renderHook } from "@testing-library/react";
|
|
12
|
+
import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
13
|
+
|
|
14
|
+
import type { OrgContextValue } from "../OrgProvider";
|
|
15
|
+
|
|
16
|
+
const mocks = vi.hoisted(() => ({
|
|
17
|
+
org: null as OrgContextValue | null,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("../OrgProvider.js", () => ({
|
|
21
|
+
useOrg: () => mocks.org,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { useOrgGate } from "../useOrgGate";
|
|
25
|
+
|
|
26
|
+
const acme = {
|
|
27
|
+
metadata: { id: "acme", slug: "acme", name: "Acme" },
|
|
28
|
+
} as Organization;
|
|
29
|
+
|
|
30
|
+
function orgContext(overrides: Partial<OrgContextValue>): OrgContextValue {
|
|
31
|
+
return {
|
|
32
|
+
orgs: [],
|
|
33
|
+
isLoading: false,
|
|
34
|
+
error: null,
|
|
35
|
+
retry: vi.fn(),
|
|
36
|
+
refresh: vi.fn(),
|
|
37
|
+
...overrides,
|
|
38
|
+
} as OrgContextValue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("useOrgGate is a derivation, not a state machine", () => {
|
|
42
|
+
afterEach(() => vi.useRealTimers());
|
|
43
|
+
|
|
44
|
+
it("zero organizations is no-orgs immediately — no provisioning wait in any edition", () => {
|
|
45
|
+
vi.useFakeTimers();
|
|
46
|
+
mocks.org = orgContext({ orgs: [] });
|
|
47
|
+
const { result } = renderHook(() => useOrgGate({ isBypassed: false }));
|
|
48
|
+
expect(result.current.state).toEqual({ status: "no-orgs" });
|
|
49
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("the initial fetch is loading", () => {
|
|
53
|
+
mocks.org = orgContext({ isLoading: true });
|
|
54
|
+
const { result } = renderHook(() => useOrgGate({ isBypassed: false }));
|
|
55
|
+
expect(result.current.state).toEqual({ status: "loading" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("a failed fetch is error with its message", () => {
|
|
59
|
+
mocks.org = orgContext({ error: "boom" });
|
|
60
|
+
const { result } = renderHook(() => useOrgGate({ isBypassed: false }));
|
|
61
|
+
expect(result.current.state).toEqual({ status: "error", message: "boom" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("at least one organization is ready", () => {
|
|
65
|
+
mocks.org = orgContext({ orgs: [acme] });
|
|
66
|
+
const { result } = renderHook(() => useOrgGate({ isBypassed: false }));
|
|
67
|
+
expect(result.current.state).toEqual({ status: "ready" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a bypassed route is bypassed whatever the list says", () => {
|
|
71
|
+
mocks.org = orgContext({ orgs: [], isLoading: true });
|
|
72
|
+
const { result } = renderHook(() => useOrgGate({ isBypassed: true }));
|
|
73
|
+
expect(result.current.state).toEqual({ status: "bypassed" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("hands the consumer useOrg's own retry and refresh", () => {
|
|
77
|
+
const retry = vi.fn();
|
|
78
|
+
const refresh = vi.fn();
|
|
79
|
+
mocks.org = orgContext({ retry, refresh });
|
|
80
|
+
const { result } = renderHook(() => useOrgGate({ isBypassed: false }));
|
|
81
|
+
expect(result.current.retry).toBe(retry);
|
|
82
|
+
expect(result.current.refresh).toBe(refresh);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
4
3
|
import { useOrg } from "./OrgProvider.js";
|
|
5
4
|
|
|
6
|
-
const PROVISIONING_POLL_MS = 2_000;
|
|
7
|
-
const PROVISIONING_TIMEOUT_MS = 10_000;
|
|
8
|
-
|
|
9
5
|
// ---------------------------------------------------------------------------
|
|
10
6
|
// Types
|
|
11
7
|
// ---------------------------------------------------------------------------
|
|
@@ -13,15 +9,13 @@ const PROVISIONING_TIMEOUT_MS = 10_000;
|
|
|
13
9
|
/**
|
|
14
10
|
* Options passed to {@link useOrgGate} by the host application.
|
|
15
11
|
*
|
|
16
|
-
*
|
|
12
|
+
* `isBypassed` is computed by the consumer using framework-specific APIs
|
|
17
13
|
* (e.g. `usePathname()` in Next.js, `useLocation()` in react-router) so
|
|
18
14
|
* that the hook itself has zero framework dependencies.
|
|
19
15
|
*/
|
|
20
16
|
export interface UseOrgGateOptions {
|
|
21
17
|
/** True when the current route should bypass the gate (e.g. `/invite/` links). */
|
|
22
18
|
readonly isBypassed: boolean;
|
|
23
|
-
/** True when the auth mode supports server-side personal org provisioning. */
|
|
24
|
-
readonly isOidcMode: boolean;
|
|
25
19
|
}
|
|
26
20
|
|
|
27
21
|
/**
|
|
@@ -44,10 +38,6 @@ export type OrgGateState =
|
|
|
44
38
|
/** Initial organization list fetch is in progress. */
|
|
45
39
|
readonly status: "loading";
|
|
46
40
|
}
|
|
47
|
-
| {
|
|
48
|
-
/** Personal organization provisioning is in progress. */
|
|
49
|
-
readonly status: "provisioning";
|
|
50
|
-
}
|
|
51
41
|
| {
|
|
52
42
|
/** Organization fetch failed and user action is required. */
|
|
53
43
|
readonly status: "error";
|
|
@@ -84,28 +74,31 @@ export interface UseOrgGateReturn {
|
|
|
84
74
|
// ---------------------------------------------------------------------------
|
|
85
75
|
|
|
86
76
|
/**
|
|
87
|
-
* Headless behavior hook that
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* The hook observes the organization context from {@link useOrg} and
|
|
91
|
-
* drives the following lifecycle:
|
|
77
|
+
* Headless behavior hook that derives the org-gate state from
|
|
78
|
+
* {@link useOrg}.
|
|
92
79
|
*
|
|
93
80
|
* 1. **`bypassed`** — `isBypassed` is true; the gate is inactive.
|
|
94
81
|
* 2. **`loading`** — the initial org list fetch is in flight.
|
|
95
|
-
* 3. **`
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* 5. **`
|
|
99
|
-
* the consumer should show an onboarding form.
|
|
100
|
-
* 6. **`ready`** — at least one org exists; render the app.
|
|
82
|
+
* 3. **`error`** — the org fetch failed; `message` carries the reason.
|
|
83
|
+
* 4. **`no-orgs`** — no organizations exist; the consumer should show an
|
|
84
|
+
* onboarding form.
|
|
85
|
+
* 5. **`ready`** — at least one org exists; render the app.
|
|
101
86
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
87
|
+
* A pure derivation, deliberately: the organization list is final by the
|
|
88
|
+
* time this hook runs. Where a server creates a personal organization on
|
|
89
|
+
* first sign-in (Stigmer Cloud), it does so inside `provisionMyAccount`,
|
|
90
|
+
* before the identity gate ahead of this one reports ready — so there is
|
|
91
|
+
* nothing to wait for, and an empty list means the person creates their
|
|
92
|
+
* first organization now. (The hook once polled for that organization for
|
|
93
|
+
* ten seconds, a relic of an earlier asynchronous provisioner; on a server
|
|
94
|
+
* that never provisions one, every first sign-in paid the full wait.)
|
|
95
|
+
*
|
|
96
|
+
* The consumer computes `isBypassed` using framework-specific APIs and
|
|
97
|
+
* passes it in, keeping this hook free of routing dependencies (DD-004).
|
|
105
98
|
*
|
|
106
99
|
* @example
|
|
107
100
|
* ```tsx
|
|
108
|
-
* const { state, retry, refresh } = useOrgGate({ isBypassed
|
|
101
|
+
* const { state, retry, refresh } = useOrgGate({ isBypassed });
|
|
109
102
|
*
|
|
110
103
|
* switch (state.status) {
|
|
111
104
|
* case "bypassed":
|
|
@@ -113,8 +106,6 @@ export interface UseOrgGateReturn {
|
|
|
113
106
|
* return <>{children}</>;
|
|
114
107
|
* case "loading":
|
|
115
108
|
* return <Spinner />;
|
|
116
|
-
* case "provisioning":
|
|
117
|
-
* return <WelcomeScreen />;
|
|
118
109
|
* case "error":
|
|
119
110
|
* return <ErrorScreen message={state.message} onRetry={retry} />;
|
|
120
111
|
* case "no-orgs":
|
|
@@ -123,52 +114,12 @@ export interface UseOrgGateReturn {
|
|
|
123
114
|
* ```
|
|
124
115
|
*/
|
|
125
116
|
export function useOrgGate(options: UseOrgGateOptions): UseOrgGateReturn {
|
|
126
|
-
const { isBypassed
|
|
117
|
+
const { isBypassed } = options;
|
|
127
118
|
const { orgs, isLoading, error, retry, refresh } = useOrg();
|
|
128
119
|
|
|
129
|
-
const [provisioningStarted, setProvisioningStarted] = useState(false);
|
|
130
|
-
const [provisioningTimedOut, setProvisioningTimedOut] = useState(false);
|
|
131
|
-
|
|
132
|
-
// React-sanctioned "adjust state during render" pattern: the guard on
|
|
133
|
-
// `!provisioningStarted` prevents infinite re-render loops.
|
|
134
|
-
if (
|
|
135
|
-
!isBypassed &&
|
|
136
|
-
!provisioningStarted &&
|
|
137
|
-
!isLoading &&
|
|
138
|
-
orgs.length === 0 &&
|
|
139
|
-
!error &&
|
|
140
|
-
isOidcMode
|
|
141
|
-
) {
|
|
142
|
-
setProvisioningStarted(true);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const isProvisioning =
|
|
146
|
-
provisioningStarted && orgs.length === 0 && !provisioningTimedOut;
|
|
147
|
-
|
|
148
|
-
// Poll for the personal org while provisioning is in progress.
|
|
149
|
-
// Errors from refresh() are absorbed — transient failures (identity not
|
|
150
|
-
// yet created) are expected during the provisioning window.
|
|
151
|
-
useEffect(() => {
|
|
152
|
-
if (!isProvisioning) return;
|
|
153
|
-
|
|
154
|
-
const interval = setInterval(() => refresh(), PROVISIONING_POLL_MS);
|
|
155
|
-
const timeout = setTimeout(
|
|
156
|
-
() => setProvisioningTimedOut(true),
|
|
157
|
-
PROVISIONING_TIMEOUT_MS,
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
return () => {
|
|
161
|
-
clearInterval(interval);
|
|
162
|
-
clearTimeout(timeout);
|
|
163
|
-
};
|
|
164
|
-
}, [isProvisioning, refresh]);
|
|
165
|
-
|
|
166
|
-
// Resolve state — order matters: bypass and provisioning take priority.
|
|
167
120
|
let state: OrgGateState;
|
|
168
121
|
if (isBypassed) {
|
|
169
122
|
state = { status: "bypassed" };
|
|
170
|
-
} else if (isProvisioning) {
|
|
171
|
-
state = { status: "provisioning" };
|
|
172
123
|
} else if (isLoading) {
|
|
173
124
|
state = { status: "loading" };
|
|
174
125
|
} else if (error) {
|