@plantops/web-kit 0.1.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 (42) hide show
  1. package/README.md +88 -0
  2. package/dist/claims.d.ts +35 -0
  3. package/dist/claims.d.ts.map +1 -0
  4. package/dist/claims.js +76 -0
  5. package/dist/errors.d.ts +46 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +52 -0
  8. package/dist/grants-provider.d.ts +51 -0
  9. package/dist/grants-provider.d.ts.map +1 -0
  10. package/dist/grants-provider.js +46 -0
  11. package/dist/iam-provider.d.ts +95 -0
  12. package/dist/iam-provider.d.ts.map +1 -0
  13. package/dist/iam-provider.js +188 -0
  14. package/dist/index.d.ts +51 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +50 -0
  17. package/dist/plantops-provider.d.ts +48 -0
  18. package/dist/plantops-provider.d.ts.map +1 -0
  19. package/dist/plantops-provider.js +8 -0
  20. package/dist/require-auth.d.ts +45 -0
  21. package/dist/require-auth.d.ts.map +1 -0
  22. package/dist/require-auth.js +58 -0
  23. package/dist/scope-coverage.d.ts +54 -0
  24. package/dist/scope-coverage.d.ts.map +1 -0
  25. package/dist/scope-coverage.js +41 -0
  26. package/dist/token-store.d.ts +92 -0
  27. package/dist/token-store.d.ts.map +1 -0
  28. package/dist/token-store.js +144 -0
  29. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  30. package/dist/use-async.d.ts +43 -0
  31. package/dist/use-async.d.ts.map +1 -0
  32. package/dist/use-async.js +76 -0
  33. package/dist/use-navigation.d.ts +32 -0
  34. package/dist/use-navigation.d.ts.map +1 -0
  35. package/dist/use-navigation.js +24 -0
  36. package/dist/use-notices.d.ts +47 -0
  37. package/dist/use-notices.d.ts.map +1 -0
  38. package/dist/use-notices.js +60 -0
  39. package/dist/use-permission.d.ts +91 -0
  40. package/dist/use-permission.d.ts.map +1 -0
  41. package/dist/use-permission.js +48 -0
  42. package/package.json +49 -0
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # @plantops/web-kit
2
+
3
+ The React runtime a PlantOps console needs to talk to the IAM — the stateful
4
+ counterpart to [`@plantops/ui`](../ui).
5
+
6
+ Where that library renders, this one *knows things*: which client to call, where
7
+ the tokens live, who is signed in, what they may do, and what their menu is.
8
+ Between them they are everything a new console needs that is not its own
9
+ screens — which is the point, because gatepass and visitor management are next
10
+ (Doc 00 §9) and neither should reimplement sign-in.
11
+
12
+ ## The whole of a console's setup
13
+
14
+ ```tsx
15
+ 'use client';
16
+ import { PlantOpsProvider } from '@plantops/web-kit';
17
+
18
+ export function Providers({ children }) {
19
+ return (
20
+ <PlantOpsProvider baseUrl={process.env.NEXT_PUBLIC_IAM_API_URL ?? '/api'}>
21
+ {children}
22
+ </PlantOpsProvider>
23
+ );
24
+ }
25
+ ```
26
+
27
+ The fallback is a *path*, not an origin. `/api` resolves against whatever origin
28
+ served the page, so one build of a console runs at any hostname with nothing
29
+ customer-specific inlined into its bundle (Doc 11 §3); the environment variable
30
+ is for the case where the console and the API are genuinely on different origins
31
+ — local development, or a console hosted separately from its API. Either way the
32
+ transport composes `baseUrl + path`, and nothing below the provider can tell
33
+ which form it was given.
34
+
35
+ `PlantOpsProvider` nests theme → antd's feedback hooks → IAM client → session →
36
+ grants, in the order they depend on each other. That ordering is the part that is
37
+ easy to get wrong and impossible to notice: mount the grants provider above the
38
+ client and every permission answers `false` with no error anywhere.
39
+
40
+ ## What it gives you
41
+
42
+ | Hook / component | For |
43
+ |---|---|
44
+ | `useAuth()` | `status`, `subject`, `login`, `logout`, `endedReason`, `lastClientSlug` |
45
+ | `useIam()` | The typed `IamClient`, for anything the hooks do not cover |
46
+ | `useGrants()` | The resolved `ResolvedGrants`, fetched once per session |
47
+ | `usePermission(key)` / `usePermissions()` / `<Permitted>` | Permission-aware controls (Doc 09 §4) |
48
+ | `useNavigation()` | The pruned menu from `GET /iam/navigation` (Doc 05) |
49
+ | `useAsync(fn, deps)` | One endpoint, a skeleton, an error, a retry — without the race |
50
+ | `useNotices()` | Themed toasts, including the Doc 09 §4 "access updates may take a few seconds" notice |
51
+ | `<RequireAuth>` | The gate in front of authenticated screens |
52
+ | `describeError(e)` | Any thrown value → code, copy, request id, field details |
53
+ | `BrowserTokenStore` | `localStorage` + cross-tab session sync |
54
+ | `pathCovers` / `holdsPermissionAt` | The `ltree` coverage test, client-side |
55
+
56
+ ## What it deliberately does not do
57
+
58
+ - **No router.** Redirects are callbacks. A `next/navigation` import would pin
59
+ every future console to one framework and make the components untestable
60
+ without it. `RequireAuth` takes `onUnauthenticated`; the app decides where the
61
+ login screen is and how a deep link survives sign-in.
62
+ - **No authorisation.** `usePermission` hides controls the subject cannot use;
63
+ the server decides (Doc 09 §4). `claims.ts` reads the access token *unverified*
64
+ and says so at length — those values may be rendered and must never be used to
65
+ decide.
66
+ - **No data-fetching framework.** `useAsync` is forty lines and covers what an
67
+ admin console does. The two genuinely shared reads — grants and navigation —
68
+ are fetched once by their own providers.
69
+
70
+ ## Session handling, in one paragraph
71
+
72
+ Tokens live in `localStorage` (`BrowserTokenStore`; the reasoning, including the
73
+ XSS trade-off against a cross-origin cookie, is in that file's header).
74
+ `IamClient` renews an access token before it lapses and retries a `401` after a
75
+ *single* shared refresh; `IamProvider` adds a keepalive so a console left open on
76
+ a dashboard renews silently rather than failing on the next click. A session ends
77
+ in exactly one way — a `null` write to the token store — whether the user signed
78
+ out, a refresh was refused (Doc 03 §4.1), or another tab did either.
79
+
80
+ ## Tests
81
+
82
+ ```sh
83
+ npx nx test @plantops/web-kit
84
+ ```
85
+
86
+ `iam-provider.spec.tsx` drives the real provider, the real `IamClient` and the
87
+ real token store, replacing only the socket: sign in, reload, sign out, another
88
+ tab, and a refused refresh.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Reading the access token's claims in the browser — for display, never for a
3
+ * decision.
4
+ *
5
+ * ## Why this is safe, and where the line is
6
+ *
7
+ * A JWT's payload is base64url, not encryption: anyone holding the token can
8
+ * read it, and doing so tells the console the subject id, the tenant, the
9
+ * session id and the expiry (Doc 03 §2). That is exactly what the header and
10
+ * the session-expiry indicator need, and fetching it from `POST /iam/introspect`
11
+ * instead would be a network round trip to learn something already in hand.
12
+ *
13
+ * The line is that **nothing here is verified**. The signature is not checked —
14
+ * this library has no JWKS and no business having one. So these values may be
15
+ * used to render, and must never be used to decide. Concretely: the console
16
+ * does not read `cid` and conclude the user is a platform admin, and does not
17
+ * read any claim to decide whether to allow an action. Authorisation comes from
18
+ * `/iam/permissions/resolve` (Doc 04) and, in the end, from the server refusing
19
+ * (Doc 09 §4 — "client-side hiding is UX, not security"). Doc 03 §2 removes the
20
+ * temptation at the source by keeping permissions, roles and scopes *out* of
21
+ * the token entirely.
22
+ */
23
+ import type { JwtClaims } from '@plantops/contracts';
24
+ /** The claims, as read from an unverified token. Display only. */
25
+ export type UnverifiedClaims = JwtClaims;
26
+ /**
27
+ * The claims of an access token, or `null` if it is not a readable JWT.
28
+ *
29
+ * Every failure — wrong segment count, unparseable JSON, missing claims —
30
+ * answers `null` rather than throwing. A malformed token means "not signed in",
31
+ * which the caller already has to handle; an exception at render time would
32
+ * mean a blank console.
33
+ */
34
+ export declare function readTokenClaims(accessToken: string | null): UnverifiedClaims | null;
35
+ //# sourceMappingURL=claims.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claims.d.ts","sourceRoot":"","sources":["../src/claims.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD,kEAAkE;AAClE,MAAM,MAAM,gBAAgB,GAAG,SAAS,CAAC;AAmCzC;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,gBAAgB,GAAG,IAAI,CAmCnF"}
package/dist/claims.js ADDED
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+ /**
3
+ * Bytes → text, without assuming `TextDecoder` exists.
4
+ *
5
+ * It does in every browser, and it does *not* in a jsdom test environment,
6
+ * where jest does not expose Node's copy as a global. Percent-decoding is the
7
+ * fallback because it needs nothing but the language: `decodeURIComponent`
8
+ * performs the UTF-8 decode itself.
9
+ */
10
+ function bytesToUtf8(bytes) {
11
+ if (typeof TextDecoder === 'function')
12
+ return new TextDecoder().decode(bytes);
13
+ let percentEncoded = '';
14
+ for (const byte of bytes)
15
+ percentEncoded += `%${byte.toString(16).padStart(2, '0')}`;
16
+ return decodeURIComponent(percentEncoded);
17
+ }
18
+ function decodeBase64Url(segment) {
19
+ const padded = segment.replace(/-/g, '+').replace(/_/g, '/');
20
+ try {
21
+ if (typeof globalThis.atob === 'function') {
22
+ // `atob` yields one char per byte; the payload is UTF-8, so it is decoded
23
+ // rather than used directly — a name or a label outside Latin-1 would
24
+ // otherwise come back mangled.
25
+ const binary = globalThis.atob(padded);
26
+ return bytesToUtf8(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
27
+ }
28
+ // Server rendering, where `atob` may be absent.
29
+ return Buffer.from(padded, 'base64').toString('utf-8');
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /**
36
+ * The claims of an access token, or `null` if it is not a readable JWT.
37
+ *
38
+ * Every failure — wrong segment count, unparseable JSON, missing claims —
39
+ * answers `null` rather than throwing. A malformed token means "not signed in",
40
+ * which the caller already has to handle; an exception at render time would
41
+ * mean a blank console.
42
+ */
43
+ export function readTokenClaims(accessToken) {
44
+ if (accessToken === null)
45
+ return null;
46
+ const segments = accessToken.split('.');
47
+ if (segments.length !== 3)
48
+ return null;
49
+ const json = decodeBase64Url(segments[1] ?? '');
50
+ if (json === null)
51
+ return null;
52
+ try {
53
+ const parsed = JSON.parse(json);
54
+ if (typeof parsed !== 'object' || parsed === null)
55
+ return null;
56
+ const claims = parsed;
57
+ if (typeof claims.sub !== 'string' ||
58
+ typeof claims.cid !== 'string' ||
59
+ typeof claims.sid !== 'string' ||
60
+ (claims.sty !== 'user' && claims.sty !== 'service')) {
61
+ return null;
62
+ }
63
+ return {
64
+ iss: typeof claims.iss === 'string' ? claims.iss : '',
65
+ sub: claims.sub,
66
+ sty: claims.sty,
67
+ cid: claims.cid,
68
+ sid: claims.sid,
69
+ iat: typeof claims.iat === 'number' ? claims.iat : 0,
70
+ exp: typeof claims.exp === 'number' ? claims.exp : 0,
71
+ };
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Turning whatever `catch` produced into something a screen can render.
3
+ *
4
+ * `@plantops/iam-client` throws two classes — `IamApiError` for a refusal the
5
+ * IAM made, `IamTransportError` for a request that never got an answer — and a
6
+ * screen may also be handed a `TypeError` from its own code. All three arrive
7
+ * at the same `catch`, and every screen would otherwise grow the same
8
+ * `instanceof` ladder.
9
+ *
10
+ * The result pairs the machine-readable facts (code, status, request id) with
11
+ * the human-readable copy from `@plantops/ui`, so the caller renders a
12
+ * `<ScreenError>` without knowing which of the three it caught.
13
+ */
14
+ import type { IamErrorCode } from '@plantops/contracts';
15
+ import { type ErrorCopy } from '@plantops/ui';
16
+ export interface DescribedError {
17
+ /** The Doc 06 §2 code, or `null` when nothing answered. */
18
+ code: IamErrorCode | null;
19
+ status: number | null;
20
+ /** Words for a person. */
21
+ copy: ErrorCopy;
22
+ /** The server's own message, when it says more than the copy does. */
23
+ detail: string | null;
24
+ /** Correlates with server logs and the audit trail. */
25
+ requestId: string | null;
26
+ /** Field-level complaints; only a `VALIDATION_FAILED` carries them. */
27
+ details: readonly {
28
+ field: string;
29
+ message: string;
30
+ }[];
31
+ }
32
+ /** Normalises any thrown value into {@link DescribedError}. */
33
+ export declare function describeError(error: unknown): DescribedError;
34
+ /**
35
+ * True when the failure means "you may not", rather than "that went wrong".
36
+ *
37
+ * The distinction a screen acts on: a denial is a final answer that deserves an
38
+ * explanation panel, where a transient failure deserves a retry button. Both
39
+ * 403 codes count — `PERMISSION_DENIED` (no such permission) and `SCOPE_DENIED`
40
+ * (the permission, but not here) — because to the person looking at the screen
41
+ * they are the same wall, differing only in what they should ask for.
42
+ */
43
+ export declare function isAccessDenial(error: unknown): boolean;
44
+ /** True when the session is gone and the console should return to the login page. */
45
+ export declare function isAuthenticationFailure(error: unknown): boolean;
46
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,EAAsC,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAElF,MAAM,WAAW,cAAc;IAC7B,2DAA2D;IAC3D,IAAI,EAAE,YAAY,GAAG,IAAI,CAAC;IAC1B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,0BAA0B;IAC1B,IAAI,EAAE,SAAS,CAAC;IAChB,sEAAsE;IACtE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,uDAAuD;IACvD,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,uEAAuE;IACvE,OAAO,EAAE,SAAS;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACxD;AAED,+DAA+D;AAC/D,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,CAiC5D;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEtD;AAED,qFAAqF;AACrF,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE/D"}
package/dist/errors.js ADDED
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+ import { IamApiError, IamClientError } from '@plantops/iam-client';
3
+ import { errorCopyFor, TRANSPORT_ERROR_COPY } from '@plantops/ui';
4
+ /** Normalises any thrown value into {@link DescribedError}. */
5
+ export function describeError(error) {
6
+ if (error instanceof IamApiError) {
7
+ return {
8
+ code: error.code,
9
+ status: error.status,
10
+ copy: errorCopyFor(error.code),
11
+ detail: error.message,
12
+ requestId: error.requestId,
13
+ details: error.details,
14
+ };
15
+ }
16
+ if (error instanceof IamClientError) {
17
+ // A transport failure: DNS, TLS, a cut connection, a timeout, or a body
18
+ // that would not parse. Nothing was decided about the request.
19
+ return {
20
+ code: null,
21
+ status: null,
22
+ copy: TRANSPORT_ERROR_COPY,
23
+ detail: error.message,
24
+ requestId: null,
25
+ details: [],
26
+ };
27
+ }
28
+ return {
29
+ code: null,
30
+ status: null,
31
+ copy: TRANSPORT_ERROR_COPY,
32
+ detail: error instanceof Error ? error.message : null,
33
+ requestId: null,
34
+ details: [],
35
+ };
36
+ }
37
+ /**
38
+ * True when the failure means "you may not", rather than "that went wrong".
39
+ *
40
+ * The distinction a screen acts on: a denial is a final answer that deserves an
41
+ * explanation panel, where a transient failure deserves a retry button. Both
42
+ * 403 codes count — `PERMISSION_DENIED` (no such permission) and `SCOPE_DENIED`
43
+ * (the permission, but not here) — because to the person looking at the screen
44
+ * they are the same wall, differing only in what they should ask for.
45
+ */
46
+ export function isAccessDenial(error) {
47
+ return error instanceof IamApiError && error.status === 403;
48
+ }
49
+ /** True when the session is gone and the console should return to the login page. */
50
+ export function isAuthenticationFailure(error) {
51
+ return error instanceof IamApiError && error.status === 401;
52
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The signed-in subject's resolved grants, fetched once per session.
3
+ *
4
+ * `GET /iam/permissions/resolve` answers WHO × WHAT × WHERE for the bearer
5
+ * (Doc 04 §4) and is not paginated — it is one cacheable unit. A console needs
6
+ * it constantly: every permission-aware button consults it (Doc 09 §4). Fetched
7
+ * per component that asks, a screen with a dozen guarded controls would issue a
8
+ * dozen identical requests; fetched once here, it issues one.
9
+ *
10
+ * `IamClient.grants()` already caches with a short TTL, so this provider is
11
+ * mostly about *React* — giving every consumer the same object identity, and a
12
+ * single place to invalidate from.
13
+ *
14
+ * ## Staleness is expected, and is the server's business
15
+ *
16
+ * Doc 04 §7 says grant changes invalidate the server's cache immediately, and
17
+ * Doc 09 §4 asks the console to tell an admin that "access updates may take a
18
+ * few seconds" after a role or binding change. So this provider does not try to
19
+ * be clever: it holds what it fetched, exposes {@link GrantsContextValue.reload}
20
+ * for the screen that just changed something, and re-fetches when the identity
21
+ * changes. Anything it shows that is out of date is corrected by the server the
22
+ * moment the user actually tries the action.
23
+ */
24
+ import type { ResolvedGrants } from '@plantops/contracts';
25
+ import * as React from 'react';
26
+ export interface GrantsContextValue {
27
+ /** `undefined` until the first resolve lands. */
28
+ grants: ResolvedGrants | undefined;
29
+ loading: boolean;
30
+ error: unknown;
31
+ /** Re-resolves, bypassing the client's cache. */
32
+ reload: () => void;
33
+ }
34
+ export declare function useGrants(): GrantsContextValue;
35
+ export interface GrantsProviderProps {
36
+ children: React.ReactNode;
37
+ /**
38
+ * Narrows the resolve to one application's slice (Doc 06 §11).
39
+ *
40
+ * The admin console leaves it unset — it spans the IAM's whole permission
41
+ * namespace. A single-application console (gatepass, visitor) sets it, and
42
+ * gets a smaller answer that changes less often.
43
+ */
44
+ applicationId?: string;
45
+ }
46
+ /**
47
+ * Supplies {@link useGrants}. Mounted by `IamProvider`; a console does not
48
+ * normally render it directly.
49
+ */
50
+ export declare function GrantsProvider({ children, applicationId, }: GrantsProviderProps): React.ReactElement;
51
+ //# sourceMappingURL=grants-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grants-provider.d.ts","sourceRoot":"","sources":["../src/grants-provider.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAK/B,MAAM,WAAW,kBAAkB;IACjC,iDAAiD;IACjD,MAAM,EAAE,cAAc,GAAG,SAAS,CAAC;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,iDAAiD;IACjD,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AAID,wBAAgB,SAAS,IAAI,kBAAkB,CAM9C;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAC7B,QAAQ,EACR,aAAa,GACd,EAAE,mBAAmB,GAAG,KAAK,CAAC,YAAY,CAsC1C"}
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import * as React from 'react';
4
+ import { useAuth, useIam } from './iam-provider';
5
+ import { useAsync } from './use-async';
6
+ const GrantsContext = React.createContext(null);
7
+ export function useGrants() {
8
+ const value = React.useContext(GrantsContext);
9
+ if (value === null) {
10
+ throw new Error('useGrants() requires an <IamProvider> above it in the tree.');
11
+ }
12
+ return value;
13
+ }
14
+ /**
15
+ * Supplies {@link useGrants}. Mounted by `IamProvider`; a console does not
16
+ * normally render it directly.
17
+ */
18
+ export function GrantsProvider({ children, applicationId, }) {
19
+ const client = useIam();
20
+ const { status, subject } = useAuth();
21
+ const state = useAsync(() => client.grants(applicationId === undefined ? {} : { applicationId }),
22
+ // Keyed on the subject, not merely on "authenticated": signing out and
23
+ // straight back in as someone else must re-resolve. `IamClient` empties its
24
+ // own cache on any identity change for the same reason.
25
+ [client, subject?.id, subject?.sessionId, applicationId], { enabled: status === 'authenticated' });
26
+ // `state.reload` rather than `state`: the state object is new on every render,
27
+ // and depending on it would give every consumer of this context a new value
28
+ // each time — which for a context read by every permission-aware control in
29
+ // the console is a re-render of the whole screen per keystroke elsewhere.
30
+ const stateReload = state.reload;
31
+ const reload = React.useCallback(() => {
32
+ client.invalidateGrants(applicationId);
33
+ stateReload();
34
+ }, [client, applicationId, stateReload]);
35
+ const value = React.useMemo(() => ({
36
+ grants: status === 'authenticated' ? state.data : undefined,
37
+ // `initializing` counts as loading. The resolve has not been *asked for*
38
+ // yet at that point, so `useAsync` honestly reports `loading: false` —
39
+ // but a screen reading this to decide between a skeleton and "you have no
40
+ // access" would take that as an answer, and give the wrong one.
41
+ loading: state.loading || status === 'initializing',
42
+ error: state.error,
43
+ reload,
44
+ }), [status, state.data, state.loading, state.error, reload]);
45
+ return _jsx(GrantsContext.Provider, { value: value, children: children });
46
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The one provider a PlantOps console mounts to become an IAM client.
3
+ *
4
+ * It owns the `IamClient`, the browser token store, and the answer to "is
5
+ * someone signed in". Everything else in this library — grants, permissions,
6
+ * navigation — reads from it.
7
+ *
8
+ * ## Why the client and the session state live in one component
9
+ *
10
+ * They are one thing wearing two hats. `IamClient` already performs the token
11
+ * lifecycle: it renews an access token before it lapses, it retries a `401`
12
+ * after a single shared refresh, and it drops the pair when a refresh is
13
+ * refused (`libs/iam-client/src/auth.ts`). React needs to *know* when that
14
+ * happens, and splitting the client into one provider and the session state
15
+ * into another means wiring a callback from the first into the second and
16
+ * hoping neither remounts. Instead the store is observable, both providers'
17
+ * jobs happen here, and the two can never disagree.
18
+ *
19
+ * ## Three ways a session ends, all handled the same way
20
+ *
21
+ * The user signs out; a refresh is refused because the token was revoked,
22
+ * expired or replayed (Doc 03 §4.1); or another tab does either. All three end
23
+ * as a `null` write to the token store, and the store's subscription is what
24
+ * flips this provider to `unauthenticated` — so the console reacts identically
25
+ * whether the reason was local, remote or in a different tab.
26
+ *
27
+ * ## Silent refresh
28
+ *
29
+ * `IamClient` renews on demand: the renewal happens when a request asks for a
30
+ * token. A console that has been sitting on a dashboard for twenty minutes
31
+ * makes no requests, so its access token lapses and the *next* click pays for a
32
+ * refresh — or fails, if the refresh token has expired meanwhile. The keepalive
33
+ * below asks for the token on a timer and when the tab regains focus, which
34
+ * turns that into a renewal nobody sees. It is not a second refresh mechanism:
35
+ * it calls the same single-flight `accessToken()` every request calls.
36
+ */
37
+ import { IamClient, type FetchLike, type LoginInput, type SessionEndReason } from '@plantops/iam-client';
38
+ import * as React from 'react';
39
+ export type AuthStatus = 'initializing' | 'authenticated' | 'unauthenticated';
40
+ /** Who is signed in, as far as the browser can tell. Display only — see `claims.ts`. */
41
+ export interface AuthSubject {
42
+ /** `sub` — the user or service-account id. */
43
+ id: string;
44
+ /** `sty` — human or machine. */
45
+ type: 'user' | 'service';
46
+ /** `cid` — the tenant. */
47
+ clientId: string;
48
+ /** `sid` — the session, which is what `POST /auth/sessions/:id/revoke` kills. */
49
+ sessionId: string;
50
+ /** Epoch milliseconds the access token lapses at. */
51
+ expiresAt: number;
52
+ /** What the person typed at the login screen, kept so the header can say it. */
53
+ email: string | null;
54
+ clientSlug: string | null;
55
+ }
56
+ export interface AuthContextValue {
57
+ status: AuthStatus;
58
+ subject: AuthSubject | null;
59
+ /** Why the last session ended — `'refresh_failed'` is worth telling the user. */
60
+ endedReason: SessionEndReason | null;
61
+ login: (input: LoginInput) => Promise<void>;
62
+ logout: () => Promise<void>;
63
+ /**
64
+ * The tenant of the last successful sign-in, for pre-filling the login form.
65
+ *
66
+ * Survives sign-out on purpose: the client slug is not a secret, and asking
67
+ * someone to retype their organisation's name every morning is the kind of
68
+ * friction that gets a console described as annoying.
69
+ */
70
+ lastClientSlug: string | null;
71
+ }
72
+ /** The typed IAM client. Throws outside an {@link IamProvider}. */
73
+ export declare function useIam(): IamClient;
74
+ /** Session state and the sign-in/sign-out actions. */
75
+ export declare function useAuth(): AuthContextValue;
76
+ export interface IamProviderProps {
77
+ /** The API origin, without the `/iam` or `/auth` prefix. */
78
+ baseUrl: string;
79
+ children: React.ReactNode;
80
+ /** `localStorage` slot for the token pair. */
81
+ storageKey?: string;
82
+ /** Remembers the tenant across sign-outs, for the login form. */
83
+ clientSlugStorageKey?: string;
84
+ /** Abort a request that has taken this long. */
85
+ timeoutMs?: number;
86
+ /**
87
+ * Overrides the runtime's `fetch` — for a test, or for instrumentation.
88
+ *
89
+ * The escape hatch that keeps this provider testable without a network: a
90
+ * spec supplies a function and asserts on what the console actually sent.
91
+ */
92
+ fetch?: FetchLike;
93
+ }
94
+ export declare function IamProvider({ baseUrl, children, storageKey, clientSlugStorageKey, timeoutMs, fetch, }: IamProviderProps): React.ReactElement;
95
+ //# sourceMappingURL=iam-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iam-provider.d.ts","sourceRoot":"","sources":["../src/iam-provider.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,EACL,SAAS,EACT,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,gBAAgB,EACtB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAa/B,MAAM,MAAM,UAAU,GAAG,cAAc,GAAG,eAAe,GAAG,iBAAiB,CAAC;AAE9E,wFAAwF;AACxF,MAAM,WAAW,WAAW;IAC1B,8CAA8C;IAC9C,EAAE,EAAE,MAAM,CAAC;IACX,gCAAgC;IAChC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,0BAA0B;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,UAAU,CAAC;IACnB,OAAO,EAAE,WAAW,GAAG,IAAI,CAAC;IAC5B,iFAAiF;IACjF,WAAW,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACrC,KAAK,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B;;;;;;OAMG;IACH,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAKD,mEAAmE;AACnE,wBAAgB,MAAM,IAAI,SAAS,CAMlC;AAED,sDAAsD;AACtD,wBAAgB,OAAO,IAAI,gBAAgB,CAM1C;AAED,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,8CAA8C;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AAsCD,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,QAAQ,EACR,UAA8B,EAC9B,oBAA8C,EAC9C,SAAS,EACT,KAAK,GACN,EAAE,gBAAgB,GAAG,KAAK,CAAC,YAAY,CAkHvC"}