@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
@@ -0,0 +1,188 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ /**
4
+ * The one provider a PlantOps console mounts to become an IAM client.
5
+ *
6
+ * It owns the `IamClient`, the browser token store, and the answer to "is
7
+ * someone signed in". Everything else in this library — grants, permissions,
8
+ * navigation — reads from it.
9
+ *
10
+ * ## Why the client and the session state live in one component
11
+ *
12
+ * They are one thing wearing two hats. `IamClient` already performs the token
13
+ * lifecycle: it renews an access token before it lapses, it retries a `401`
14
+ * after a single shared refresh, and it drops the pair when a refresh is
15
+ * refused (`libs/iam-client/src/auth.ts`). React needs to *know* when that
16
+ * happens, and splitting the client into one provider and the session state
17
+ * into another means wiring a callback from the first into the second and
18
+ * hoping neither remounts. Instead the store is observable, both providers'
19
+ * jobs happen here, and the two can never disagree.
20
+ *
21
+ * ## Three ways a session ends, all handled the same way
22
+ *
23
+ * The user signs out; a refresh is refused because the token was revoked,
24
+ * expired or replayed (Doc 03 §4.1); or another tab does either. All three end
25
+ * as a `null` write to the token store, and the store's subscription is what
26
+ * flips this provider to `unauthenticated` — so the console reacts identically
27
+ * whether the reason was local, remote or in a different tab.
28
+ *
29
+ * ## Silent refresh
30
+ *
31
+ * `IamClient` renews on demand: the renewal happens when a request asks for a
32
+ * token. A console that has been sitting on a dashboard for twenty minutes
33
+ * makes no requests, so its access token lapses and the *next* click pays for a
34
+ * refresh — or fails, if the refresh token has expired meanwhile. The keepalive
35
+ * below asks for the token on a timer and when the tab regains focus, which
36
+ * turns that into a renewal nobody sees. It is not a second refresh mechanism:
37
+ * it calls the same single-flight `accessToken()` every request calls.
38
+ */
39
+ import { IamClient, } from '@plantops/iam-client';
40
+ import * as React from 'react';
41
+ import { readTokenClaims } from './claims';
42
+ import { BrowserTokenStore, TOKEN_STORAGE_KEY, } from './token-store';
43
+ /** How often the keepalive asks for a token. Well inside the 15-minute TTL. */
44
+ const KEEPALIVE_INTERVAL_MS = 60_000;
45
+ const IamClientContext = React.createContext(null);
46
+ const AuthContext = React.createContext(null);
47
+ /** The typed IAM client. Throws outside an {@link IamProvider}. */
48
+ export function useIam() {
49
+ const client = React.useContext(IamClientContext);
50
+ if (client === null) {
51
+ throw new Error('useIam() requires an <IamProvider> above it in the tree.');
52
+ }
53
+ return client;
54
+ }
55
+ /** Session state and the sign-in/sign-out actions. */
56
+ export function useAuth() {
57
+ const auth = React.useContext(AuthContext);
58
+ if (auth === null) {
59
+ throw new Error('useAuth() requires an <IamProvider> above it in the tree.');
60
+ }
61
+ return auth;
62
+ }
63
+ /** Where the remembered tenant lives. Not a credential; see `lastClientSlug`. */
64
+ const CLIENT_SLUG_STORAGE_KEY = 'plantops.last-client-slug';
65
+ function readLocal(key) {
66
+ try {
67
+ return globalThis.localStorage?.getItem(key) ?? null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ function writeLocal(key, value) {
74
+ try {
75
+ globalThis.localStorage?.setItem(key, value);
76
+ }
77
+ catch {
78
+ /* storage unavailable; the field simply will not pre-fill */
79
+ }
80
+ }
81
+ function subjectFrom(session) {
82
+ const claims = readTokenClaims(session?.tokens.accessToken ?? null);
83
+ if (claims === null)
84
+ return null;
85
+ return {
86
+ id: claims.sub,
87
+ type: claims.sty,
88
+ clientId: claims.cid,
89
+ sessionId: claims.sid,
90
+ expiresAt: claims.exp * 1000,
91
+ email: session?.identity?.email ?? null,
92
+ clientSlug: session?.identity?.clientSlug ?? null,
93
+ };
94
+ }
95
+ export function IamProvider({ baseUrl, children, storageKey = TOKEN_STORAGE_KEY, clientSlugStorageKey = CLIENT_SLUG_STORAGE_KEY, timeoutMs, fetch, }) {
96
+ // Why the session ended, reported by the client.
97
+ //
98
+ // Routed through a ref because the client is constructed once, before the
99
+ // setter it needs exists, and because the ordering matters: `TokenSession`
100
+ // clears the store *first* and calls this *second* (`auth.ts`'s `forget`), so
101
+ // the store subscription below has already run by the time the reason
102
+ // arrives. Both writes land in one React batch and the reason wins, which is
103
+ // what makes "your session expired" distinguishable from "you signed out".
104
+ const reportSessionEnd = React.useRef(() => {
105
+ /* replaced below, before any request can fail */
106
+ });
107
+ const [{ client, store }] = React.useState(() => {
108
+ const tokenStore = new BrowserTokenStore(storageKey);
109
+ return {
110
+ store: tokenStore,
111
+ client: new IamClient({
112
+ baseUrl,
113
+ tokenStore,
114
+ timeoutMs,
115
+ fetch,
116
+ onSessionEnded: (reason) => reportSessionEnd.current(reason),
117
+ }),
118
+ };
119
+ });
120
+ const [status, setStatus] = React.useState('initializing');
121
+ const [subject, setSubject] = React.useState(null);
122
+ const [endedReason, setEndedReason] = React.useState(null);
123
+ const [lastClientSlug, setLastClientSlug] = React.useState(null);
124
+ reportSessionEnd.current = setEndedReason;
125
+ React.useEffect(() => {
126
+ const apply = (session) => {
127
+ const next = subjectFrom(session);
128
+ setSubject(next);
129
+ setStatus(next === null ? 'unauthenticated' : 'authenticated');
130
+ // Only cleared on the way *in*. On the way out the reason is the client's
131
+ // to report, and overwriting it here with `null` would erase it.
132
+ if (next !== null)
133
+ setEndedReason(null);
134
+ };
135
+ // The first read is what turns `initializing` into a real answer. It runs
136
+ // in an effect rather than during render because `localStorage` does not
137
+ // exist on the server, and rendering "signed in" on the client while the
138
+ // server rendered "signed out" would discard the tree.
139
+ apply(store.readSession());
140
+ // Unsubscribing also detaches the store's `storage` listener when this is
141
+ // the last subscriber, so there is nothing further to tear down — and
142
+ // nothing that React's development double-mount can tear down permanently.
143
+ return store.subscribe(apply);
144
+ }, [store]);
145
+ React.useEffect(() => {
146
+ setLastClientSlug(readLocal(clientSlugStorageKey));
147
+ }, [clientSlugStorageKey]);
148
+ // The keepalive. `accessToken()` renews when the token is within its leeway
149
+ // and is otherwise free, so this costs one function call a minute.
150
+ React.useEffect(() => {
151
+ if (status !== 'authenticated')
152
+ return;
153
+ const touch = () => {
154
+ void client.session.accessToken();
155
+ };
156
+ const timer = setInterval(touch, KEEPALIVE_INTERVAL_MS);
157
+ const onVisible = () => {
158
+ if (globalThis.document?.visibilityState === 'visible')
159
+ touch();
160
+ };
161
+ globalThis.addEventListener?.('visibilitychange', onVisible);
162
+ globalThis.addEventListener?.('online', touch);
163
+ return () => {
164
+ clearInterval(timer);
165
+ globalThis.removeEventListener?.('visibilitychange', onVisible);
166
+ globalThis.removeEventListener?.('online', touch);
167
+ };
168
+ }, [client, status]);
169
+ const login = React.useCallback(async (input) => {
170
+ await client.auth.login(input);
171
+ // Written after the login lands, so a failed attempt does not overwrite
172
+ // the tenant that worked yesterday.
173
+ store.writeIdentity({
174
+ email: input.email,
175
+ clientSlug: input.client_slug,
176
+ });
177
+ writeLocal(clientSlugStorageKey, input.client_slug);
178
+ setLastClientSlug(input.client_slug);
179
+ }, [client, store, clientSlugStorageKey]);
180
+ const logout = React.useCallback(async () => {
181
+ // `IamClient.auth.logout()` revokes server-side and clears locally even if
182
+ // the revocation call fails — and clearing calls `onSessionEnded('logout')`,
183
+ // so the reason and the status both arrive without further help here.
184
+ await client.auth.logout();
185
+ }, [client]);
186
+ const auth = React.useMemo(() => ({ status, subject, endedReason, login, logout, lastClientSlug }), [status, subject, endedReason, login, logout, lastClientSlug]);
187
+ return (_jsx(IamClientContext.Provider, { value: client, children: _jsx(AuthContext.Provider, { value: auth, children: children }) }));
188
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `@plantops/web-kit` — the React runtime a PlantOps console needs to talk to
3
+ * the IAM.
4
+ *
5
+ * The stateful counterpart to `@plantops/ui`. Where that library renders,
6
+ * this one *knows things*: which client to call, where the tokens live, who is
7
+ * signed in, what they may do, and what their menu is. Between them they are
8
+ * everything a new console needs that is not its own screens — which is the
9
+ * point, because gatepass and visitor management are next (Doc 00 §9) and
10
+ * neither should reimplement sign-in.
11
+ *
12
+ * ```tsx
13
+ * // The whole of a console's setup.
14
+ * <PlantOpsProvider baseUrl={process.env.NEXT_PUBLIC_IAM_API_URL ?? '/api'}>
15
+ * <RequireAuth onUnauthenticated={(next) => router.replace(`/login?next=${next}`)}>
16
+ * <AppShell nav={<NavMenu tree={useNavigation().tree} … />}>…</AppShell>
17
+ * </RequireAuth>
18
+ * </PlantOpsProvider>
19
+ * ```
20
+ *
21
+ * The base is a *path* there rather than an origin, and deliberately so: `/api`
22
+ * resolves against whatever origin served the page, so one build of a console
23
+ * runs at any hostname and nothing customer-specific is inlined into its bundle
24
+ * (Doc 11 §3). `HttpTransport` composes a request URL as `baseUrl + path`, so
25
+ * nothing under this provider can tell which of the two forms it was handed.
26
+ *
27
+ * ## What it deliberately does not do
28
+ *
29
+ * - **No router.** Redirects are callbacks. A `next/navigation` import would
30
+ * pin every future console to one framework and make the components
31
+ * untestable without it.
32
+ * - **No authorisation.** `usePermission` hides controls the subject cannot
33
+ * use; the server decides (Doc 09 §4). Nothing here is a security boundary,
34
+ * and `claims.ts` says so at the one place that reads a token.
35
+ * - **No data-fetching framework.** `useAsync` is forty lines and covers what an
36
+ * admin console does. The two genuinely shared reads — grants and navigation
37
+ * — are fetched once by their own providers.
38
+ */
39
+ export * from './claims';
40
+ export * from './errors';
41
+ export * from './grants-provider';
42
+ export * from './iam-provider';
43
+ export * from './plantops-provider';
44
+ export * from './require-auth';
45
+ export * from './scope-coverage';
46
+ export * from './token-store';
47
+ export * from './use-async';
48
+ export * from './use-navigation';
49
+ export * from './use-notices';
50
+ export * from './use-permission';
51
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `@plantops/web-kit` — the React runtime a PlantOps console needs to talk to
3
+ * the IAM.
4
+ *
5
+ * The stateful counterpart to `@plantops/ui`. Where that library renders,
6
+ * this one *knows things*: which client to call, where the tokens live, who is
7
+ * signed in, what they may do, and what their menu is. Between them they are
8
+ * everything a new console needs that is not its own screens — which is the
9
+ * point, because gatepass and visitor management are next (Doc 00 §9) and
10
+ * neither should reimplement sign-in.
11
+ *
12
+ * ```tsx
13
+ * // The whole of a console's setup.
14
+ * <PlantOpsProvider baseUrl={process.env.NEXT_PUBLIC_IAM_API_URL ?? '/api'}>
15
+ * <RequireAuth onUnauthenticated={(next) => router.replace(`/login?next=${next}`)}>
16
+ * <AppShell nav={<NavMenu tree={useNavigation().tree} … />}>…</AppShell>
17
+ * </RequireAuth>
18
+ * </PlantOpsProvider>
19
+ * ```
20
+ *
21
+ * The base is a *path* there rather than an origin, and deliberately so: `/api`
22
+ * resolves against whatever origin served the page, so one build of a console
23
+ * runs at any hostname and nothing customer-specific is inlined into its bundle
24
+ * (Doc 11 §3). `HttpTransport` composes a request URL as `baseUrl + path`, so
25
+ * nothing under this provider can tell which of the two forms it was handed.
26
+ *
27
+ * ## What it deliberately does not do
28
+ *
29
+ * - **No router.** Redirects are callbacks. A `next/navigation` import would
30
+ * pin every future console to one framework and make the components
31
+ * untestable without it.
32
+ * - **No authorisation.** `usePermission` hides controls the subject cannot
33
+ * use; the server decides (Doc 09 §4). Nothing here is a security boundary,
34
+ * and `claims.ts` says so at the one place that reads a token.
35
+ * - **No data-fetching framework.** `useAsync` is forty lines and covers what an
36
+ * admin console does. The two genuinely shared reads — grants and navigation
37
+ * — are fetched once by their own providers.
38
+ */
39
+ export * from './claims';
40
+ export * from './errors';
41
+ export * from './grants-provider';
42
+ export * from './iam-provider';
43
+ export * from './plantops-provider';
44
+ export * from './require-auth';
45
+ export * from './scope-coverage';
46
+ export * from './token-store';
47
+ export * from './use-async';
48
+ export * from './use-navigation';
49
+ export * from './use-notices';
50
+ export * from './use-permission';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * One component that turns a React tree into a PlantOps console.
3
+ *
4
+ * Theme, antd's feedback hooks, the IAM client, the session, and the resolved
5
+ * grants — in the order they depend on each other, which is the part that is
6
+ * easy to get wrong and impossible to notice: mount `GrantsProvider` above
7
+ * `IamProvider` and every permission answers `false` with no error anywhere.
8
+ *
9
+ * ```tsx
10
+ * // apps/gatepass-web/src/app/providers.tsx
11
+ * <PlantOpsProvider baseUrl={API_URL} applicationId={GATEPASS_APP_ID}>
12
+ * {children}
13
+ * </PlantOpsProvider>
14
+ * ```
15
+ *
16
+ * The composition also keeps the two libraries' seam honest. `@plantops/ui`
17
+ * knows nothing about the IAM and `@plantops/web-kit` renders nothing of its
18
+ * own; this file is the only place the two meet, and it meets them by nesting
19
+ * rather than by either importing the other's internals.
20
+ */
21
+ import type { FetchLike } from '@plantops/iam-client';
22
+ import { type ColorMode } from '@plantops/ui';
23
+ import * as React from 'react';
24
+ export interface PlantOpsProviderProps {
25
+ /** The IAM's origin, without the `/iam` or `/auth` prefix. */
26
+ baseUrl: string;
27
+ children: React.ReactNode;
28
+ /**
29
+ * Narrows resolved grants to one application (Doc 06 §11).
30
+ *
31
+ * Set it in a single-application console. The admin console leaves it unset:
32
+ * it spans the whole `iam.platform.*` / `iam.client.*` namespace and renders
33
+ * the cross-application shell.
34
+ */
35
+ applicationId?: string;
36
+ /** Fixes the colour mode, disabling the stored preference. */
37
+ colorMode?: ColorMode;
38
+ /** Initial mode before the stored preference is read. */
39
+ defaultColorMode?: ColorMode;
40
+ /** `localStorage` slot for the token pair. */
41
+ tokenStorageKey?: string;
42
+ /** Abort a request that has taken this long. */
43
+ timeoutMs?: number;
44
+ /** Overrides the runtime's `fetch` — for a test, or for instrumentation. */
45
+ fetch?: FetchLike;
46
+ }
47
+ export declare function PlantOpsProvider({ baseUrl, children, applicationId, colorMode, defaultColorMode, tokenStorageKey, timeoutMs, fetch, }: PlantOpsProviderProps): React.ReactElement;
48
+ //# sourceMappingURL=plantops-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plantops-provider.d.ts","sourceRoot":"","sources":["../src/plantops-provider.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAyB,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAK/B,MAAM,WAAW,qBAAqB;IACpC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,yDAAyD;IACzD,gBAAgB,CAAC,EAAE,SAAS,CAAC;IAC7B,8CAA8C;IAC9C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,OAAO,EACP,QAAQ,EACR,aAAa,EACb,SAAS,EACT,gBAAgB,EAChB,eAAe,EACf,SAAS,EACT,KAAK,GACN,EAAE,qBAAqB,GAAG,KAAK,CAAC,YAAY,CAa5C"}
@@ -0,0 +1,8 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { PlantOpsThemeProvider } from '@plantops/ui';
4
+ import { GrantsProvider } from './grants-provider';
5
+ import { IamProvider } from './iam-provider';
6
+ export function PlantOpsProvider({ baseUrl, children, applicationId, colorMode, defaultColorMode, tokenStorageKey, timeoutMs, fetch, }) {
7
+ return (_jsx(PlantOpsThemeProvider, { mode: colorMode, defaultMode: defaultColorMode, children: _jsx(IamProvider, { baseUrl: baseUrl, storageKey: tokenStorageKey, timeoutMs: timeoutMs, fetch: fetch, children: _jsx(GrantsProvider, { applicationId: applicationId, children: children }) }) }));
8
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The gate in front of every authenticated screen.
3
+ *
4
+ * Three states, three behaviours: while the browser is still working out
5
+ * whether a session exists it renders `fallback` (a session almost certainly
6
+ * *does* exist — the tokens are one synchronous `localStorage` read away — so
7
+ * flashing the login screen here would be a lie); with a session it renders the
8
+ * screen; without one it calls `onUnauthenticated` and renders nothing.
9
+ *
10
+ * Routing is a callback rather than an import. This library has no router: a
11
+ * `next/navigation` import here would tie the gatepass and visitor consoles to
12
+ * the Next app router forever, and would make this component untestable without
13
+ * one. The caller passes `(target) => router.replace(...)` and keeps the
14
+ * decision about *where* the login screen lives.
15
+ *
16
+ * ## The `next` parameter
17
+ *
18
+ * `onUnauthenticated` receives where the user was going. A deep link into a
19
+ * screen — from a bookmark, from a colleague's message — must survive the
20
+ * sign-in, or the link is only useful to someone already signed in. The caller
21
+ * decides how to carry it; a query parameter is the usual answer.
22
+ *
23
+ * ## This is not authorisation
24
+ *
25
+ * It answers "is anyone signed in", not "may they see this". Permission is the
26
+ * server's answer, arriving as a 403 the screen renders with `<ScreenError>`
27
+ * (Doc 09 §4). A component that hid screens by permission would be re-deriving
28
+ * the pruning the navigation endpoint already did, and would still have to
29
+ * handle the 403 for the deep link it got wrong.
30
+ */
31
+ import * as React from 'react';
32
+ export interface RequireAuthProps {
33
+ children: React.ReactNode;
34
+ /**
35
+ * Called once, when the browser has established that nobody is signed in.
36
+ *
37
+ * @param target The path the user was trying to reach, or `null` when it
38
+ * could not be determined (server rendering).
39
+ */
40
+ onUnauthenticated: (target: string | null) => void;
41
+ /** Rendered while the session is being established. */
42
+ fallback?: React.ReactNode;
43
+ }
44
+ export declare function RequireAuth({ children, onUnauthenticated, fallback, }: RequireAuthProps): React.ReactNode;
45
+ //# sourceMappingURL=require-auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"require-auth.d.ts","sourceRoot":"","sources":["../src/require-auth.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B;;;;;OAKG;IACH,iBAAiB,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACnD,uDAAuD;IACvD,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC5B;AAED,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,iBAAiB,EACjB,QAAe,GAChB,EAAE,gBAAgB,GAAG,KAAK,CAAC,SAAS,CA0BpC"}
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+ /**
3
+ * The gate in front of every authenticated screen.
4
+ *
5
+ * Three states, three behaviours: while the browser is still working out
6
+ * whether a session exists it renders `fallback` (a session almost certainly
7
+ * *does* exist — the tokens are one synchronous `localStorage` read away — so
8
+ * flashing the login screen here would be a lie); with a session it renders the
9
+ * screen; without one it calls `onUnauthenticated` and renders nothing.
10
+ *
11
+ * Routing is a callback rather than an import. This library has no router: a
12
+ * `next/navigation` import here would tie the gatepass and visitor consoles to
13
+ * the Next app router forever, and would make this component untestable without
14
+ * one. The caller passes `(target) => router.replace(...)` and keeps the
15
+ * decision about *where* the login screen lives.
16
+ *
17
+ * ## The `next` parameter
18
+ *
19
+ * `onUnauthenticated` receives where the user was going. A deep link into a
20
+ * screen — from a bookmark, from a colleague's message — must survive the
21
+ * sign-in, or the link is only useful to someone already signed in. The caller
22
+ * decides how to carry it; a query parameter is the usual answer.
23
+ *
24
+ * ## This is not authorisation
25
+ *
26
+ * It answers "is anyone signed in", not "may they see this". Permission is the
27
+ * server's answer, arriving as a 403 the screen renders with `<ScreenError>`
28
+ * (Doc 09 §4). A component that hid screens by permission would be re-deriving
29
+ * the pruning the navigation endpoint already did, and would still have to
30
+ * handle the 403 for the deep link it got wrong.
31
+ */
32
+ import * as React from 'react';
33
+ import { useAuth } from './iam-provider';
34
+ export function RequireAuth({ children, onUnauthenticated, fallback = null, }) {
35
+ const { status } = useAuth();
36
+ // Held in a ref so a re-render — a colour-mode change, a resize — cannot fire
37
+ // a second redirect while the first is still in flight.
38
+ const redirected = React.useRef(false);
39
+ const redirect = React.useRef(onUnauthenticated);
40
+ redirect.current = onUnauthenticated;
41
+ React.useEffect(() => {
42
+ if (status !== 'unauthenticated' || redirected.current)
43
+ return;
44
+ redirected.current = true;
45
+ const location = globalThis.location;
46
+ const target = location === undefined ? null : `${location.pathname}${location.search}`;
47
+ redirect.current(target);
48
+ }, [status]);
49
+ React.useEffect(() => {
50
+ if (status === 'authenticated')
51
+ redirected.current = false;
52
+ }, [status]);
53
+ if (status === 'authenticated')
54
+ return children;
55
+ if (status === 'initializing')
56
+ return fallback;
57
+ return null;
58
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Does a grant at *this* node reach *that* node? (Doc 04 §4.2)
3
+ *
4
+ * A binding at Plant B covers Plant B and everything under it — its
5
+ * departments, its gates — because scope paths are `ltree` materialized paths
6
+ * and coverage is the ancestor test `<@`. In Postgres that is one operator; in
7
+ * a browser it is a string prefix on `.`-separated labels, which is what these
8
+ * functions are.
9
+ *
10
+ * ## This is a copy, and that is deliberate
11
+ *
12
+ * `libs/auth-kit`'s `ScopeResolver` performs the same test server-side, and the
13
+ * boundary rules keep it out of a browser bundle on purpose: `auth-kit` is
14
+ * NestJS code, `scope:auth` is importable only by `iam-api` and future module
15
+ * APIs (Doc 08 §2). Rather than widen that boundary to share thirty lines, the
16
+ * predicate is restated here — with the understanding that **this copy decides
17
+ * nothing**. It hides a button (Doc 09 §4: "client-side hiding is UX; server
18
+ * enforces"). If the two ever disagree, the server wins and the user sees a
19
+ * 403, which is a cosmetic bug rather than a security one. Widening the
20
+ * boundary to avoid it would trade a cosmetic risk for a structural one.
21
+ *
22
+ * The label alphabet is what keeps the prefix test honest: labels are
23
+ * `n_<uuid-hex>` (Doc 01 §3.5), all the same length, containing no `.`. There
24
+ * is no `n_abc` / `n_abcdef` sibling pair for a naive `startsWith` to confuse —
25
+ * but the separator check below does not rely on that.
26
+ */
27
+ import type { PermissionKey, ResolvedGrants, ScopePath } from '@plantops/contracts';
28
+ /**
29
+ * True when `grantedPath` is `targetPath` or an ancestor of it.
30
+ *
31
+ * The `.` in the prefix test is load-bearing: without it `n_aa` would appear to
32
+ * cover `n_aab`, which is a different subtree.
33
+ */
34
+ export declare function pathCovers(grantedPath: ScopePath, targetPath: ScopePath): boolean;
35
+ /** True when any granted path covers the target. */
36
+ export declare function anyPathCovers(grantedPaths: readonly ScopePath[], targetPath: ScopePath): boolean;
37
+ /** True when the subject holds the permission anywhere at all. */
38
+ export declare function holdsPermission(grants: ResolvedGrants | undefined, permission: PermissionKey): boolean;
39
+ /**
40
+ * True when the subject holds the permission at a node covering `scopePath`.
41
+ *
42
+ * The narrower question, and the one a scope-specific control asks — "may I
43
+ * approve *this* gate's pass", not "may I approve passes".
44
+ */
45
+ export declare function holdsPermissionAt(grants: ResolvedGrants | undefined, permission: PermissionKey, scopePath: ScopePath): boolean;
46
+ /**
47
+ * The minimal set of paths the subject holds this permission at (Doc 04 §4.1).
48
+ *
49
+ * Already minimized by the server — a descendant is dropped when an ancestor is
50
+ * present — so a consumer narrowing a query by these paths needs no further
51
+ * reduction.
52
+ */
53
+ export declare function permissionScopes(grants: ResolvedGrants | undefined, permission: PermissionKey): readonly ScopePath[];
54
+ //# sourceMappingURL=scope-coverage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-coverage.d.ts","sourceRoot":"","sources":["../src/scope-coverage.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAEpF;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,GAAG,OAAO,CAGjF;AAED,oDAAoD;AACpD,wBAAgB,aAAa,CAC3B,YAAY,EAAE,SAAS,SAAS,EAAE,EAClC,UAAU,EAAE,SAAS,GACpB,OAAO,CAET;AAED,kEAAkE;AAClE,wBAAgB,eAAe,CAC7B,MAAM,EAAE,cAAc,GAAG,SAAS,EAClC,UAAU,EAAE,aAAa,GACxB,OAAO,CAET;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,cAAc,GAAG,SAAS,EAClC,UAAU,EAAE,aAAa,EACzB,SAAS,EAAE,SAAS,GACnB,OAAO,CAGT;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,cAAc,GAAG,SAAS,EAClC,UAAU,EAAE,aAAa,GACxB,SAAS,SAAS,EAAE,CAEtB"}
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+ /**
3
+ * True when `grantedPath` is `targetPath` or an ancestor of it.
4
+ *
5
+ * The `.` in the prefix test is load-bearing: without it `n_aa` would appear to
6
+ * cover `n_aab`, which is a different subtree.
7
+ */
8
+ export function pathCovers(grantedPath, targetPath) {
9
+ if (grantedPath === '' || targetPath === '')
10
+ return false;
11
+ return targetPath === grantedPath || targetPath.startsWith(`${grantedPath}.`);
12
+ }
13
+ /** True when any granted path covers the target. */
14
+ export function anyPathCovers(grantedPaths, targetPath) {
15
+ return grantedPaths.some((granted) => pathCovers(granted, targetPath));
16
+ }
17
+ /** True when the subject holds the permission anywhere at all. */
18
+ export function holdsPermission(grants, permission) {
19
+ return grants?.permissions.includes(permission) ?? false;
20
+ }
21
+ /**
22
+ * True when the subject holds the permission at a node covering `scopePath`.
23
+ *
24
+ * The narrower question, and the one a scope-specific control asks — "may I
25
+ * approve *this* gate's pass", not "may I approve passes".
26
+ */
27
+ export function holdsPermissionAt(grants, permission, scopePath) {
28
+ if (grants === undefined)
29
+ return false;
30
+ return anyPathCovers(grants.scopes[permission] ?? [], scopePath);
31
+ }
32
+ /**
33
+ * The minimal set of paths the subject holds this permission at (Doc 04 §4.1).
34
+ *
35
+ * Already minimized by the server — a descendant is dropped when an ancestor is
36
+ * present — so a consumer narrowing a query by these paths needs no further
37
+ * reduction.
38
+ */
39
+ export function permissionScopes(grants, permission) {
40
+ return grants?.scopes[permission] ?? [];
41
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Where a browser keeps its tokens, and how the tabs agree about them.
3
+ *
4
+ * `@plantops/iam-client` deliberately names no storage mechanism — its
5
+ * `TokenStore` is a two-method port and its default keeps tokens in memory,
6
+ * which is right for a service process and useless for a console, where a page
7
+ * reload would sign the user out. This is the browser half of that port.
8
+ *
9
+ * ## Why `localStorage`, and what that costs
10
+ *
11
+ * The console and the API are separate origins (Doc 08 §6 — Vercel and
12
+ * Railway), so a cookie would have to be `SameSite=None; Secure` on a shared
13
+ * parent domain, and the IAM would need CSRF defences for every mutating route
14
+ * because the browser would then attach it automatically. The IAM is a bearer-
15
+ * token API by design (Doc 03): nothing it exposes is authorised by ambient
16
+ * credentials, so there is no CSRF surface to defend — and putting the token
17
+ * back into a cookie would create one.
18
+ *
19
+ * The honest cost is XSS: script running on this origin can read the tokens.
20
+ * That is mitigated rather than eliminated — short access tokens (15 min,
21
+ * `ACCESS_TOKEN_TTL_SECONDS`), rotating refresh tokens with reuse detection
22
+ * (Doc 03 §4), and revocation that takes effect within seconds (Doc 03 §6) —
23
+ * and the residual risk is accepted knowingly here rather than by default. A
24
+ * deployment that disagrees implements this interface differently; nothing else
25
+ * changes.
26
+ *
27
+ * ## Cross-tab
28
+ *
29
+ * Two tabs share one origin and therefore one store. Without the `storage`
30
+ * listener, signing out in one tab leaves the other showing a console whose
31
+ * every request now fails, and signing *in* in one tab leaves the other on the
32
+ * login screen. The listener makes both immediate. It fires only in the *other*
33
+ * tabs — the writing tab never hears its own event — so a local change notifies
34
+ * through {@link BrowserTokenStore.write} instead.
35
+ */
36
+ import type { StoredTokens, TokenStore } from '@plantops/iam-client';
37
+ /** Default slot. Namespaced so two PlantOps consoles on one host can differ. */
38
+ export declare const TOKEN_STORAGE_KEY = "plantops.tokens";
39
+ /**
40
+ * Display-only facts about the signed-in person, kept beside the tokens.
41
+ *
42
+ * The access token carries `sub`/`cid`/`sid` and nothing human-readable
43
+ * (Doc 03 §2 — exactly seven claims), so after a reload the console knows *who*
44
+ * in the sense of a uuid and cannot put a name in the header. These two strings
45
+ * come from what the user typed at the login screen and exist purely so the
46
+ * header can say something true. Nothing is authorised from them.
47
+ */
48
+ export interface IdentityHint {
49
+ email: string;
50
+ clientSlug: string;
51
+ }
52
+ export interface StoredSession {
53
+ tokens: StoredTokens;
54
+ identity: IdentityHint | null;
55
+ }
56
+ export type TokenStoreListener = (session: StoredSession | null) => void;
57
+ /**
58
+ * A `TokenStore` backed by `localStorage`, with change notification.
59
+ *
60
+ * Safe to construct during server rendering: every method tolerates the absence
61
+ * of `window`, answering "no session", which is the correct answer on a server
62
+ * that has no user.
63
+ */
64
+ export declare class BrowserTokenStore implements TokenStore {
65
+ private readonly storageKey;
66
+ private readonly listeners;
67
+ private readonly onStorage;
68
+ constructor(storageKey?: string);
69
+ /**
70
+ * Notified on every change, in this tab and in others.
71
+ *
72
+ * The `storage` listener is attached with the first subscriber and detached
73
+ * with the last, rather than in the constructor. That is what makes the store
74
+ * survive React's development double-mount: a listener attached at
75
+ * construction and removed on unmount is gone for good after the first
76
+ * remount — cross-tab sign-out then silently stops working, in development
77
+ * only, which is the worst place for it to hide.
78
+ */
79
+ subscribe(listener: TokenStoreListener): () => void;
80
+ /** Drops every subscriber and the window listener with them. */
81
+ dispose(): void;
82
+ read(): StoredTokens | null;
83
+ write(tokens: StoredTokens | null): void;
84
+ readSession(): StoredSession | null;
85
+ /** Records who signed in, for the header. Never used to authorise anything. */
86
+ writeIdentity(identity: IdentityHint | null): void;
87
+ clear(): void;
88
+ private writeSession;
89
+ private storage;
90
+ private emit;
91
+ }
92
+ //# sourceMappingURL=token-store.d.ts.map