@lessly/sdk-app 61.0.2 → 61.2.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.
- package/dist/_types/index.d.ts +2 -1
- package/dist/_types/react/index.d.ts +33 -0
- package/dist/_types/runtime/access.d.ts +40 -0
- package/dist/_types/runtime/createLesslyApp.d.ts +4 -0
- package/dist/_types/runtime/errors.d.ts +11 -0
- package/dist/_types/runtime/permission-match.d.ts +62 -0
- package/dist/index.cjs +691 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +691 -1
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +29 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +1 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +26 -0
- package/dist/react/index.js.map +1 -0
- package/docs/README.md +1 -0
- package/docs/recipes/access.md +177 -0
- package/docs/rules.md +33 -0
- package/package.json +24 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
|
|
5
|
+
// src/react/index.ts
|
|
6
|
+
function useAccess(app) {
|
|
7
|
+
const { access } = app;
|
|
8
|
+
const subscribe = react.useCallback((cb) => access.subscribe(cb), [access]);
|
|
9
|
+
const getState = react.useCallback(() => access.state, [access]);
|
|
10
|
+
const state = react.useSyncExternalStore(subscribe, getState, getState);
|
|
11
|
+
react.useEffect(() => {
|
|
12
|
+
void access.load().catch(() => {
|
|
13
|
+
});
|
|
14
|
+
}, [access]);
|
|
15
|
+
const reload = react.useCallback(() => {
|
|
16
|
+
access.invalidate();
|
|
17
|
+
return access.load();
|
|
18
|
+
}, [access]);
|
|
19
|
+
return { state, role: access.role, access, reload };
|
|
20
|
+
}
|
|
21
|
+
function useCan(app, op) {
|
|
22
|
+
const { state, access } = useAccess(app);
|
|
23
|
+
return state === "ready" && access.can(op);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
exports.useAccess = useAccess;
|
|
27
|
+
exports.useCan = useCan;
|
|
28
|
+
//# sourceMappingURL=index.cjs.map
|
|
29
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react/index.ts"],"names":["useCallback","useSyncExternalStore","useEffect"],"mappings":";;;;;AA4BO,SAAS,UAAU,GAAA,EAA+B;AACvD,EAAA,MAAM,EAAE,QAAO,GAAI,GAAA;AACnB,EAAA,MAAM,SAAA,GAAYA,iBAAA,CAAY,CAAC,EAAA,KAAmB,MAAA,CAAO,UAAU,EAAE,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAGhF,EAAA,MAAM,WAAWA,iBAAA,CAAY,MAAM,OAAO,KAAA,EAAO,CAAC,MAAM,CAAC,CAAA;AACzD,EAAA,MAAM,KAAA,GAAQC,0BAAA,CAAqB,SAAA,EAAW,QAAA,EAAU,QAAQ,CAAA;AAEhE,EAAAC,eAAA,CAAU,MAAM;AAId,IAAA,KAAK,MAAA,CAAO,IAAA,EAAK,CAAE,KAAA,CAAM,MAAM;AAAA,IAAC,CAAC,CAAA;AAAA,EACnC,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,MAAM,MAAA,GAASF,kBAAY,MAAM;AAC/B,IAAA,MAAA,CAAO,UAAA,EAAW;AAClB,IAAA,OAAO,OAAO,IAAA,EAAK;AAAA,EACrB,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,OAAO,EAAE,KAAA,EAAO,IAAA,EAAM,MAAA,CAAO,IAAA,EAAM,QAAQ,MAAA,EAAO;AACpD;AASO,SAAS,MAAA,CAAO,KAAc,EAAA,EAAiC;AACpE,EAAA,MAAM,EAAE,KAAA,EAAO,MAAA,EAAO,GAAI,UAAU,GAAG,CAAA;AACvC,EAAA,OAAO,KAAA,KAAU,OAAA,IAAW,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC3C","file":"index.cjs","sourcesContent":["import { useCallback, useEffect, useSyncExternalStore } from 'react';\nimport type { Access, AccessApi, AccessState } from '../runtime/access';\nimport type { Operation } from '../runtime/types';\n\n/**\n * The only thing these hooks need from a client — so a test double is one object literal, and\n * (deliberately) no React type appears in any exported signature: `react` is an OPTIONAL peer,\n * so the published declarations must typecheck for a consumer who has not installed it.\n */\nexport interface AppLike {\n readonly access: AccessApi;\n}\n\nexport interface UseAccessResult {\n state: AccessState;\n role: string | undefined;\n access: AccessApi;\n reload: () => Promise<Access>;\n}\n\n/**\n * Subscribe a component to the client's access snapshot, loading it once on mount.\n *\n * `useSyncExternalStore` rather than `useState` + an effect: the snapshot lives outside React\n * (several components share one `AccessApi`), and this is the hook that is safe against tearing\n * under concurrent rendering. `load()` is de-duplicated inside `createAccess`, so every component\n * may call this without coordinating — two components produce one request.\n */\nexport function useAccess(app: AppLike): UseAccessResult {\n const { access } = app;\n const subscribe = useCallback((cb: () => void) => access.subscribe(cb), [access]);\n // Server snapshot is the same getter: there is nothing to hydrate, an unloaded AccessApi\n // reports 'idle' on both sides.\n const getState = useCallback(() => access.state, [access]);\n const state = useSyncExternalStore(subscribe, getState, getState);\n\n useEffect(() => {\n // The rejection is already reflected in `state === 'error'`; swallowing it here only stops an\n // unhandled rejection from reaching the page. A caller who wants the error awaits load()\n // (or reload()) itself.\n void access.load().catch(() => {});\n }, [access]);\n\n const reload = useCallback(() => {\n access.invalidate();\n return access.load();\n }, [access]);\n\n return { state, role: access.role, access, reload };\n}\n\n/**\n * True iff the caller is PREDICTED to be allowed to perform `op`. False until the snapshot is\n * ready — so a control wired to this starts disabled and enables itself, never the reverse.\n *\n * Loads on mount through `useAccess`, so a component may use it without the App having called\n * `load()` anywhere. This is a hint for the UI, never authorisation: see APP-012.\n */\nexport function useCan(app: AppLike, op: Operation | string): boolean {\n const { state, access } = useAccess(app);\n return state === 'ready' && access.can(op);\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '../_types/react/index.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '../_types/react/index.js';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
// src/react/index.ts
|
|
4
|
+
function useAccess(app) {
|
|
5
|
+
const { access } = app;
|
|
6
|
+
const subscribe = useCallback((cb) => access.subscribe(cb), [access]);
|
|
7
|
+
const getState = useCallback(() => access.state, [access]);
|
|
8
|
+
const state = useSyncExternalStore(subscribe, getState, getState);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
void access.load().catch(() => {
|
|
11
|
+
});
|
|
12
|
+
}, [access]);
|
|
13
|
+
const reload = useCallback(() => {
|
|
14
|
+
access.invalidate();
|
|
15
|
+
return access.load();
|
|
16
|
+
}, [access]);
|
|
17
|
+
return { state, role: access.role, access, reload };
|
|
18
|
+
}
|
|
19
|
+
function useCan(app, op) {
|
|
20
|
+
const { state, access } = useAccess(app);
|
|
21
|
+
return state === "ready" && access.can(op);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { useAccess, useCan };
|
|
25
|
+
//# sourceMappingURL=index.js.map
|
|
26
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react/index.ts"],"names":[],"mappings":";;;AA4BO,SAAS,UAAU,GAAA,EAA+B;AACvD,EAAA,MAAM,EAAE,QAAO,GAAI,GAAA;AACnB,EAAA,MAAM,SAAA,GAAY,WAAA,CAAY,CAAC,EAAA,KAAmB,MAAA,CAAO,UAAU,EAAE,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAGhF,EAAA,MAAM,WAAW,WAAA,CAAY,MAAM,OAAO,KAAA,EAAO,CAAC,MAAM,CAAC,CAAA;AACzD,EAAA,MAAM,KAAA,GAAQ,oBAAA,CAAqB,SAAA,EAAW,QAAA,EAAU,QAAQ,CAAA;AAEhE,EAAA,SAAA,CAAU,MAAM;AAId,IAAA,KAAK,MAAA,CAAO,IAAA,EAAK,CAAE,KAAA,CAAM,MAAM;AAAA,IAAC,CAAC,CAAA;AAAA,EACnC,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,MAAM,MAAA,GAAS,YAAY,MAAM;AAC/B,IAAA,MAAA,CAAO,UAAA,EAAW;AAClB,IAAA,OAAO,OAAO,IAAA,EAAK;AAAA,EACrB,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,OAAO,EAAE,KAAA,EAAO,IAAA,EAAM,MAAA,CAAO,IAAA,EAAM,QAAQ,MAAA,EAAO;AACpD;AASO,SAAS,MAAA,CAAO,KAAc,EAAA,EAAiC;AACpE,EAAA,MAAM,EAAE,KAAA,EAAO,MAAA,EAAO,GAAI,UAAU,GAAG,CAAA;AACvC,EAAA,OAAO,KAAA,KAAU,OAAA,IAAW,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC3C","file":"index.js","sourcesContent":["import { useCallback, useEffect, useSyncExternalStore } from 'react';\nimport type { Access, AccessApi, AccessState } from '../runtime/access';\nimport type { Operation } from '../runtime/types';\n\n/**\n * The only thing these hooks need from a client — so a test double is one object literal, and\n * (deliberately) no React type appears in any exported signature: `react` is an OPTIONAL peer,\n * so the published declarations must typecheck for a consumer who has not installed it.\n */\nexport interface AppLike {\n readonly access: AccessApi;\n}\n\nexport interface UseAccessResult {\n state: AccessState;\n role: string | undefined;\n access: AccessApi;\n reload: () => Promise<Access>;\n}\n\n/**\n * Subscribe a component to the client's access snapshot, loading it once on mount.\n *\n * `useSyncExternalStore` rather than `useState` + an effect: the snapshot lives outside React\n * (several components share one `AccessApi`), and this is the hook that is safe against tearing\n * under concurrent rendering. `load()` is de-duplicated inside `createAccess`, so every component\n * may call this without coordinating — two components produce one request.\n */\nexport function useAccess(app: AppLike): UseAccessResult {\n const { access } = app;\n const subscribe = useCallback((cb: () => void) => access.subscribe(cb), [access]);\n // Server snapshot is the same getter: there is nothing to hydrate, an unloaded AccessApi\n // reports 'idle' on both sides.\n const getState = useCallback(() => access.state, [access]);\n const state = useSyncExternalStore(subscribe, getState, getState);\n\n useEffect(() => {\n // The rejection is already reflected in `state === 'error'`; swallowing it here only stops an\n // unhandled rejection from reaching the page. A caller who wants the error awaits load()\n // (or reload()) itself.\n void access.load().catch(() => {});\n }, [access]);\n\n const reload = useCallback(() => {\n access.invalidate();\n return access.load();\n }, [access]);\n\n return { state, role: access.role, access, reload };\n}\n\n/**\n * True iff the caller is PREDICTED to be allowed to perform `op`. False until the snapshot is\n * ready — so a control wired to this starts disabled and enables itself, never the reverse.\n *\n * Loads on mount through `useAccess`, so a component may use it without the App having called\n * `load()` anywhere. This is a hint for the UI, never authorisation: see APP-012.\n */\nexport function useCan(app: AppLike, op: Operation | string): boolean {\n const { state, access } = useAccess(app);\n return state === 'ready' && access.can(op);\n}\n"]}
|
package/docs/README.md
CHANGED
|
@@ -73,6 +73,7 @@ you need to touch it directly.
|
|
|
73
73
|
| `rules.md` | Always — normative `APP-*` rules. Read this first, before writing any code. |
|
|
74
74
|
| `recipes/sdk-usage.md` | You need to call the platform API: creating the SDK client, handling `LesslyApiError`, using the generated `*QueryOptions`/`*MutationOptions` factories. |
|
|
75
75
|
| `recipes/federation.md` | You're touching the Module Federation setup: shared singletons, the dual-mode build, or the dual-mode CSS graph (the federation vs standalone stylesheets). |
|
|
76
|
+
| `recipes/access.md` | You're gating UI on the caller's permissions: `app.access`, `useCan`, and rendering a 403 as an empty state rather than an error (APP-012). |
|
|
76
77
|
| `recipes/local-dev.md` | You're running the app locally: the dev proxy, device-code login, `.env.local`, or composing your app under a local shell. |
|
|
77
78
|
|
|
78
79
|
## Keeping this guide current
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Recipe: reflecting the caller's access in the UI
|
|
2
|
+
|
|
3
|
+
Some of your users cannot do some of the things your App offers. This recipe
|
|
4
|
+
shows how to grey out those controls and how to render the refusal you will
|
|
5
|
+
still occasionally get — without ever making your App the authority on who is
|
|
6
|
+
allowed to do what.
|
|
7
|
+
|
|
8
|
+
**The one rule everything here rests on:** `can()` is a *prediction*, the server
|
|
9
|
+
is the authority. See APP-012 in `../rules.md`.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What `app.access` is
|
|
14
|
+
|
|
15
|
+
Every client from `createLesslyApp` carries an `access` object:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createLesslyApp } from '@lessly/sdk-app';
|
|
19
|
+
|
|
20
|
+
const app = createLesslyApp({ baseUrl: '/api', productId });
|
|
21
|
+
|
|
22
|
+
await app.access.load(); // one GET; cached; concurrent calls share it
|
|
23
|
+
app.access.role; // 'owner' | 'admin' | 'member' | a custom role name
|
|
24
|
+
app.access.state; // 'idle' | 'loading' | 'ready' | 'error'
|
|
25
|
+
app.access.can('mail_domain_create'); // boolean, synchronous
|
|
26
|
+
app.access.invalidate(); // drop the cache; the next load() refetches
|
|
27
|
+
app.access.subscribe(() => {}); // fires on every state change
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`load()` fetches the caller's own membership of the active product — their role
|
|
31
|
+
and their allow/deny grant patterns — and `can()` answers from that snapshot
|
|
32
|
+
using the *same matcher the gateway uses to decide*. That is the only reason a
|
|
33
|
+
client-side prediction is worth anything.
|
|
34
|
+
|
|
35
|
+
`can()` is **false whenever the snapshot is not ready**: before `load()`, while
|
|
36
|
+
it is in flight, after a failure, and after `invalidate()`. So anything you
|
|
37
|
+
render from it must start in the disabled state and enable itself — never the
|
|
38
|
+
reverse. A control that starts enabled and greys out a moment later is worse
|
|
39
|
+
than one that never greyed out at all.
|
|
40
|
+
|
|
41
|
+
You can pass either a tool id or a generated method, which carries its own
|
|
42
|
+
identity:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
app.access.can('mail_domain_create'); // a tool id
|
|
46
|
+
app.access.can(app.mail.domain.create); // the method itself — no id to keep in sync
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Prefer the second form. The accessor path is a lossy view of the tool id (a
|
|
50
|
+
hyphenated resource joins several id segments), so a hand-written string can
|
|
51
|
+
drift from the method it is meant to describe; the method cannot.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 1. Load once at app mount
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
// src/App.tsx
|
|
59
|
+
import { useEffect } from 'react';
|
|
60
|
+
import { createLesslyApp } from '@lessly/sdk-app';
|
|
61
|
+
|
|
62
|
+
export default function App({ productId }: { productId: string }) {
|
|
63
|
+
const app = useMemo(() => createLesslyApp({ baseUrl: '/api', productId }), [productId]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
// Fire and forget: a failure is reflected in app.access.state, and every consumer of
|
|
67
|
+
// can() is already fail-closed.
|
|
68
|
+
void app.access.load().catch(() => {});
|
|
69
|
+
}, [app]);
|
|
70
|
+
|
|
71
|
+
return <Routes app={app} />;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
You do not have to do this. `useAccess`/`useCan` load on mount themselves, and
|
|
76
|
+
`load()` is de-duplicated inside the client — ten components mounting at once
|
|
77
|
+
produce **one** request. Loading at the root is just the cheapest way to have
|
|
78
|
+
the snapshot ready before the first screen paints.
|
|
79
|
+
|
|
80
|
+
Call `app.access.invalidate()` after anything that can change the caller's own
|
|
81
|
+
grants (they accepted an invitation, an admin changed their role in a settings
|
|
82
|
+
screen you own). Nothing else invalidates it: the snapshot is deliberately not
|
|
83
|
+
refetched on a timer.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 2. `useCan` on primary actions — disable, don't hide
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import { useCan } from '@lessly/sdk-app/react';
|
|
91
|
+
|
|
92
|
+
function AddDomainButton({ app, onClick }: { app: App; onClick: () => void }) {
|
|
93
|
+
const canCreate = useCan(app, app.mail.domain.create);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
onClick={onClick}
|
|
98
|
+
disabled={!canCreate}
|
|
99
|
+
title={canCreate ? undefined : 'Your role cannot add domains. Ask an admin of this product.'}
|
|
100
|
+
>
|
|
101
|
+
Add domain
|
|
102
|
+
</button>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Disable, with a hint. Do not hide.** A user who cannot see the button cannot
|
|
108
|
+
tell their admin which permission they are missing — they file a "the app is
|
|
109
|
+
broken" ticket instead, and the admin who could have fixed it in ten seconds
|
|
110
|
+
never hears about it. The greyed-out control with a one-line reason *is* the
|
|
111
|
+
support channel.
|
|
112
|
+
|
|
113
|
+
`useAccess` gives you the same information plus the role, for the cases where
|
|
114
|
+
one flag is not enough:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
const { state, role, reload } = useAccess(app);
|
|
118
|
+
|
|
119
|
+
if (state === 'loading') return <Spinner />;
|
|
120
|
+
if (state === 'error') return <p>Could not load your permissions. <button onClick={reload}>Retry</button></p>;
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
React is an **optional peer** of this package: `@lessly/sdk-app/react` is only
|
|
124
|
+
loaded if you import it, and installing the SDK does not pull React in.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 3. `isAccessDenied` in the shared query state — a neutral empty state
|
|
129
|
+
|
|
130
|
+
A prediction is not a guarantee, so keep handling the real refusal. Grants can
|
|
131
|
+
change between the load and the click, and a screen can always be reached
|
|
132
|
+
before the snapshot is.
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
import { isAccessDenied } from '@lessly/sdk-app';
|
|
136
|
+
import { useQuery } from '@tanstack/react-query';
|
|
137
|
+
import { mailDomainListQueryOptions } from '@lessly/sdk-app/mail';
|
|
138
|
+
|
|
139
|
+
function DomainList({ app }: { app: App }) {
|
|
140
|
+
const { data, error, isPending } = useQuery(mailDomainListQueryOptions(app, {}));
|
|
141
|
+
|
|
142
|
+
if (isPending) return <Spinner />;
|
|
143
|
+
|
|
144
|
+
// A permission boundary is a NORMAL condition, not a failure of your App.
|
|
145
|
+
if (isAccessDenied(error)) {
|
|
146
|
+
return <EmptyState title="You don't have access to domains" body="Ask an admin of this product to grant it." />;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (error) return <ErrorState error={error} />; // red, retryable — real failures only
|
|
150
|
+
|
|
151
|
+
return <Table rows={data.domains} />;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Put that branch in your shared query-state component once, rather than in every
|
|
156
|
+
screen. `isAccessDenied` is true for exactly the three gateway codes that mean
|
|
157
|
+
*the caller's access was the reason* — `role_denied`,
|
|
158
|
+
`operation_uncatalogued`, `no_tool_permission` — and false for every other 403.
|
|
159
|
+
A `tenant_blocked` 403 is not a permission problem and must not be rendered as
|
|
160
|
+
one: it sends the user to an admin who can do nothing about it.
|
|
161
|
+
|
|
162
|
+
**Never render an access denial in red.** A permission boundary is expected,
|
|
163
|
+
frequent and not the user's mistake. Painting it as a crash teaches people to
|
|
164
|
+
ignore the colour you need for real failures.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## What not to do
|
|
169
|
+
|
|
170
|
+
| Don't | Why |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| Skip the call because `can()` returned false | The snapshot can be stale or simply not loaded. Let the server answer; handle the 403. |
|
|
173
|
+
| Treat `can() === true` as authorisation | It is a prediction. The gateway may still refuse, and only the gateway's answer is a fact. |
|
|
174
|
+
| Drop the `isAccessDenied` branch because every button is gated | A screen can be deep-linked, and grants change mid-session. |
|
|
175
|
+
| Hide controls the caller cannot use | Makes the missing permission unreportable. Disable with a reason instead. |
|
|
176
|
+
| Rebuild a tool id from the accessor path | The path is a lossy view of the id. Pass the method: `app.mail.domain.create`. |
|
|
177
|
+
| Poll `load()` on a timer | The snapshot changes when a role changes; call `invalidate()` then, and only then. |
|
package/docs/rules.md
CHANGED
|
@@ -331,3 +331,36 @@ validates the top-level surface and `nav.icon` only; it neither rejects nor
|
|
|
331
331
|
warns on `nav.sections`, and it will not catch a malformed one. The platform's
|
|
332
332
|
registration validation is the authority — a menu that lints clean locally can
|
|
333
333
|
still fail registration.
|
|
334
|
+
|
|
335
|
+
### APP-012 (SHOULD) — Reflect access in the UI, but never enforce it there
|
|
336
|
+
|
|
337
|
+
An App SHOULD reflect the caller's access in its UI: **disable, with a reason**
|
|
338
|
+
(not hide) a primary action the caller cannot perform, using `useCan` from
|
|
339
|
+
`@lessly/sdk-app/react` or `app.access.can()` directly, and render a 403 that
|
|
340
|
+
`isAccessDenied()` recognises as a **neutral empty state** rather than an error.
|
|
341
|
+
|
|
342
|
+
An App MUST NOT treat that prediction as enforcement. `can()` answers from a
|
|
343
|
+
snapshot fetched once from the `organization_product_me` tool; the gateway is
|
|
344
|
+
the only authority, grants can change between the load and the click, and the
|
|
345
|
+
prediction is `false` for a caller whose snapshot simply has not loaded yet.
|
|
346
|
+
Concretely: never skip a call because `can()` returned false, never present
|
|
347
|
+
`can() === true` to the user as authorisation, and never drop the
|
|
348
|
+
`isAccessDenied` branch from a screen because every button on it is gated.
|
|
349
|
+
|
|
350
|
+
Two failure modes are worth naming, because both ship regularly:
|
|
351
|
+
|
|
352
|
+
- **Hiding rather than disabling.** A user who cannot see the button cannot ask
|
|
353
|
+
their admin for the permission; they report the App as broken instead, and
|
|
354
|
+
the admin who could have granted it in seconds never hears about it.
|
|
355
|
+
- **Painting a denial red.** A permission boundary is a normal, expected
|
|
356
|
+
condition and not the user's mistake. Rendering it as a crash trains people
|
|
357
|
+
to ignore the colour reserved for real failures. `isAccessDenied()` is true
|
|
358
|
+
for exactly the three gateway codes that mean the caller's access was the
|
|
359
|
+
reason (`role_denied`, `operation_uncatalogued`, `no_tool_permission`) and
|
|
360
|
+
false for every other 403 — a `tenant_blocked` is not a permission problem.
|
|
361
|
+
|
|
362
|
+
Pass the generated method rather than a hand-written tool id
|
|
363
|
+
(`app.access.can(app.mail.domain.create)`): the accessor path is a lossy view
|
|
364
|
+
of the tool id, so a literal string can silently drift from the call it gates.
|
|
365
|
+
|
|
366
|
+
See `recipes/access.md` for the load-once pattern and the empty-state shape.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessly/sdk-app",
|
|
3
|
-
"version": "61.0
|
|
3
|
+
"version": "61.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"engines": {
|
|
@@ -27,13 +27,26 @@
|
|
|
27
27
|
"test": "vitest run"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
+
"@testing-library/react": "^16.3.3",
|
|
30
31
|
"@types/node": "^20.0.0",
|
|
32
|
+
"@types/react": "^18.3.31",
|
|
33
|
+
"jsdom": "^25.0.1",
|
|
31
34
|
"json-schema-to-typescript": "^15.0.3",
|
|
35
|
+
"react": "^18.3.1",
|
|
36
|
+
"react-dom": "^18.3.1",
|
|
32
37
|
"tsup": "^8.3.0",
|
|
33
38
|
"tsx": "^4.19.0",
|
|
34
39
|
"typescript": "^5.6.0",
|
|
35
40
|
"vitest": "^2.1.0"
|
|
36
41
|
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"react": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"react": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
37
50
|
"exports": {
|
|
38
51
|
".": {
|
|
39
52
|
"import": {
|
|
@@ -45,6 +58,16 @@
|
|
|
45
58
|
"default": "./dist/index.cjs"
|
|
46
59
|
}
|
|
47
60
|
},
|
|
61
|
+
"./react": {
|
|
62
|
+
"import": {
|
|
63
|
+
"types": "./dist/react/index.d.ts",
|
|
64
|
+
"default": "./dist/react/index.js"
|
|
65
|
+
},
|
|
66
|
+
"require": {
|
|
67
|
+
"types": "./dist/react/index.d.cts",
|
|
68
|
+
"default": "./dist/react/index.cjs"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
48
71
|
"./analytics": {
|
|
49
72
|
"import": {
|
|
50
73
|
"types": "./dist/analytics/index.d.ts",
|