@lessly/sdk-app 62.0.0 → 63.0.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/gen/client.gen.d.ts +1 -29
- package/dist/_types/gen/manifest.gen.d.ts +1 -1
- package/dist/_types/gen/types.gen.d.ts +0 -191
- package/dist/_types/index.d.ts +1 -0
- package/dist/_types/runtime/access-reason.d.ts +20 -0
- package/dist/index.cjs +6 -140
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +10303 -15
- package/dist/index.js.map +1 -1
- package/docs/recipes/access.md +456 -15
- package/docs/rules.md +92 -0
- package/package.json +2 -12
- package/src/gen/bindings.gen.ts +1 -137
- package/src/gen/client.gen.ts +0 -52
- package/src/gen/manifest.gen.ts +1 -13
- package/src/gen/types.gen.ts +0 -226
- package/dist/_types/gen/playground/connect.gen.d.ts +0 -3
- package/dist/_types/gen/playground/index.d.ts +0 -3
- package/dist/_types/gen/playground/queryOptions.gen.d.ts +0 -50
- package/dist/chunk-2YAA3AF4.js +0 -10437
- package/dist/chunk-2YAA3AF4.js.map +0 -1
- package/dist/playground/index.cjs +0 -81
- package/dist/playground/index.cjs.map +0 -1
- package/dist/playground/index.d.cts +0 -1
- package/dist/playground/index.d.ts +0 -1
- package/dist/playground/index.js +0 -58
- package/dist/playground/index.js.map +0 -1
- package/src/gen/playground/connect.gen.ts +0 -9
- package/src/gen/playground/index.ts +0 -4
- package/src/gen/playground/queryOptions.gen.ts +0 -86
package/docs/recipes/access.md
CHANGED
|
@@ -33,10 +33,14 @@ using the *same matcher the gateway uses to decide*. That is the only reason a
|
|
|
33
33
|
client-side prediction is worth anything.
|
|
34
34
|
|
|
35
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
it is in flight, after a failure, and after `invalidate()`. So a control wired
|
|
37
|
+
to it starts disabled and enables itself — never the reverse. A control that
|
|
38
|
+
starts enabled and greys out a moment later is worse than one that never greyed
|
|
39
|
+
out at all.
|
|
40
|
+
|
|
41
|
+
`can()` being false is not by itself a reason to *tell the user their role is
|
|
42
|
+
the problem*, though — a failed `/me` is not a denial. See the state table in
|
|
43
|
+
§2 for what each `state` means for a control.
|
|
40
44
|
|
|
41
45
|
You can pass either a tool id or a generated method, which carries its own
|
|
42
46
|
identity:
|
|
@@ -58,17 +62,24 @@ drift from the method it is meant to describe; the method cannot.
|
|
|
58
62
|
// src/App.tsx
|
|
59
63
|
import { useEffect } from 'react';
|
|
60
64
|
import { createLesslyApp } from '@lessly/sdk-app';
|
|
65
|
+
import { TooltipProvider } from '@lessly/ui';
|
|
61
66
|
|
|
62
67
|
export default function App({ productId }: { productId: string }) {
|
|
63
68
|
const app = useMemo(() => createLesslyApp({ baseUrl: '/api', productId }), [productId]);
|
|
64
69
|
|
|
65
70
|
useEffect(() => {
|
|
66
|
-
// Fire and forget: a failure is reflected in app.access.state, and
|
|
67
|
-
//
|
|
71
|
+
// Fire and forget: a failure is reflected in app.access.state, and can() stays false
|
|
72
|
+
// until the snapshot is ready.
|
|
68
73
|
void app.access.load().catch(() => {});
|
|
69
74
|
}, [app]);
|
|
70
75
|
|
|
71
|
-
|
|
76
|
+
// One TooltipProvider per App canvas — see §2. Never one per control, and never
|
|
77
|
+
// borrowed from the shell: your standalone build and your tests render outside it.
|
|
78
|
+
return (
|
|
79
|
+
<TooltipProvider>
|
|
80
|
+
<Routes app={app} />
|
|
81
|
+
</TooltipProvider>
|
|
82
|
+
);
|
|
72
83
|
}
|
|
73
84
|
```
|
|
74
85
|
|
|
@@ -87,23 +98,174 @@ refetched on a timer.
|
|
|
87
98
|
## 2. `useCan` on primary actions — disable, don't hide
|
|
88
99
|
|
|
89
100
|
```tsx
|
|
90
|
-
import {
|
|
101
|
+
import { accessReason } from '@lessly/sdk-app';
|
|
102
|
+
import { useAccess, useCan } from '@lessly/sdk-app/react';
|
|
103
|
+
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@lessly/ui';
|
|
91
104
|
|
|
92
105
|
function AddDomainButton({ app, onClick }: { app: App; onClick: () => void }) {
|
|
106
|
+
const { state } = useAccess(app);
|
|
93
107
|
const canCreate = useCan(app, app.mail.domain.create);
|
|
94
108
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
// 'error' is NOT a denial: nothing is known about the role, so make no prediction.
|
|
110
|
+
const disabled = state === 'ready' ? !canCreate : state !== 'error';
|
|
111
|
+
// The reason names the level AND the operation key, both read off the method —
|
|
112
|
+
// never hand-typed per button, and never the caller's role.
|
|
113
|
+
const reason =
|
|
114
|
+
state === 'ready' && !canCreate
|
|
115
|
+
? accessReason(app.mail.domain.create)
|
|
116
|
+
: undefined;
|
|
117
|
+
|
|
118
|
+
const button = (
|
|
119
|
+
<Button disabled={disabled} onClick={onClick}>
|
|
101
120
|
Add domain
|
|
102
|
-
</
|
|
121
|
+
</Button>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
if (reason === undefined) return button;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<Tooltip>
|
|
128
|
+
{/* The trigger is the WRAPPER, not the Button: the base buttonVariants set
|
|
129
|
+
disabled:pointer-events-none, so a disabled control fires no hover and a
|
|
130
|
+
tooltip mounted on it never opens. */}
|
|
131
|
+
<TooltipTrigger asChild>
|
|
132
|
+
<span tabIndex={0}>{button}</span>
|
|
133
|
+
</TooltipTrigger>
|
|
134
|
+
<TooltipContent>{reason}</TooltipContent>
|
|
135
|
+
</Tooltip>
|
|
103
136
|
);
|
|
104
137
|
}
|
|
105
138
|
```
|
|
106
139
|
|
|
140
|
+
### The reason goes on a wrapper, never on `title=`
|
|
141
|
+
|
|
142
|
+
A `title` on a disabled Button is unreachable twice over: `disabled:pointer-events-none`
|
|
143
|
+
swallows the hover the browser needs to show it, and a native tooltip is unreachable
|
|
144
|
+
by keyboard and by touch even on an enabled control. That class sits in the **base**
|
|
145
|
+
`buttonVariants`, not in an icon-only branch — so the wrapper pattern applies to
|
|
146
|
+
*every* Button, not just the icon-only shape. The wrapping trigger carries
|
|
147
|
+
`tabIndex={0}` for the same reason: a keyboard user has to be able to reach the hint.
|
|
148
|
+
This mirrors what `@lessly/ui` documents above `ButtonIconOnlyProps` in `button.tsx`
|
|
149
|
+
(`Button --disabled-with-tooltip` shows both halves).
|
|
150
|
+
|
|
151
|
+
Controls that take no `title` at all — the kit's `Switch`, for one — have no other
|
|
152
|
+
option: the wrapper is the only place the reason can live.
|
|
153
|
+
|
|
154
|
+
**The trigger must sit outside every `pointer-events-none`, including your own.**
|
|
155
|
+
The kit's `disabled:pointer-events-none` is the common case, but the mechanism is
|
|
156
|
+
not the kit's: a list row, a chip or an overlay in your own App that you set
|
|
157
|
+
`pointer-events-none` on swallows hover identically, and a trigger nested inside
|
|
158
|
+
it is just as silent — in your code this time. Walk outwards from the control to
|
|
159
|
+
the first ancestor that still receives pointer events, and put the trigger there.
|
|
160
|
+
|
|
161
|
+
### One reason string, built from the method
|
|
162
|
+
|
|
163
|
+
Every App says the same sentence, and both of its values come off the generated
|
|
164
|
+
method:
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
import { accessReason } from '@lessly/sdk-app';
|
|
168
|
+
|
|
169
|
+
accessReason(app.mail.domain.create);
|
|
170
|
+
// "Requires level:admin (mail_domain_create). Ask an admin of this product."
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Use it in every example below; nothing in an App should assemble this text by
|
|
174
|
+
hand.
|
|
175
|
+
|
|
176
|
+
It takes **one** operation. Choosing which operation to explain — the first one
|
|
177
|
+
`can()` denied, out of the several a button performs — is the caller's job, and
|
|
178
|
+
it stays the caller's job: the helper accepts no array, because the rule for
|
|
179
|
+
picking is not the same in the two shapes below and a helper that guessed would
|
|
180
|
+
be wrong in one of them.
|
|
181
|
+
|
|
182
|
+
Its signature is `Operation | string`, and the two arms are not equals. Passing a
|
|
183
|
+
method is the norm. The string arm is a **degradation**, there for the controls
|
|
184
|
+
that have no generated method to point at — a runtime probe, an operation no
|
|
185
|
+
method covers — and it says less because less is known:
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
accessReason('some_runtime_probe');
|
|
189
|
+
// "Requires access to some_runtime_probe. Ask an admin of this product."
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
It drops the level rather than inventing one: with only a key there is nothing to
|
|
193
|
+
read a level from, and `level:undefined` in front of a user is worse than saying
|
|
194
|
+
less. It is not permission to pass strings where a method exists — the accessor
|
|
195
|
+
path is a lossy view of the key, so a hand-written string can drift from the
|
|
196
|
+
operation it names, and only the method's arm gives an admin both halves of what
|
|
197
|
+
they grant on. An App whose every gate receives an `Operation` never writes the
|
|
198
|
+
string form at all; that is the normal shape of an App, not a gap in it.
|
|
199
|
+
|
|
200
|
+
`can()` is cautious with a bare string in the same way. It resolves the level
|
|
201
|
+
from the catalog when the key is one the SDK knows, but a key with **no** catalog
|
|
202
|
+
entry can only be matched by key pattern — no level grant reaches it, which is
|
|
203
|
+
exactly what the gateway does with an uncatalogued key.
|
|
204
|
+
|
|
205
|
+
> **On an older SDK.** `accessReason` ships from `@lessly/sdk-app`. On a version
|
|
206
|
+
> before it landed, define it locally with exactly this signature and body, then
|
|
207
|
+
> replace it with the import when you upgrade:
|
|
208
|
+
>
|
|
209
|
+
> ```ts
|
|
210
|
+
> import type { Operation } from '@lessly/sdk-app';
|
|
211
|
+
> export const accessReason = (op: Operation | string): string =>
|
|
212
|
+
> typeof op === 'string'
|
|
213
|
+
> ? `Requires access to ${op}. Ask an admin of this product.`
|
|
214
|
+
> : `Requires level:${op.level} (${op.operationKey}). Ask an admin of this product.`;
|
|
215
|
+
> ```
|
|
216
|
+
|
|
217
|
+
**Why both values.** The reason says what an admin has to *grant*, not what the
|
|
218
|
+
caller happens to be — a role name gives them nothing to act on, and "your role
|
|
219
|
+
cannot do this" sends the user into a conversation with no next step. But a level
|
|
220
|
+
alone is not enough either: seventeen controls on a screen can all read
|
|
221
|
+
`level:write` and be seventeen different grants. An admin grants both **by level
|
|
222
|
+
and by exact operation key**, so the user has to be able to pass on both. The key
|
|
223
|
+
is what makes the sentence actionable; the level is what makes it
|
|
224
|
+
comprehensible.
|
|
225
|
+
|
|
226
|
+
**Both come only from the method.** `op.level` and `op.operationKey` — every
|
|
227
|
+
generated method satisfies `interface Operation { operationKey, level }`. No
|
|
228
|
+
lookup table in your App, no literal in the JSX, and above all no rule of thumb
|
|
229
|
+
about which verbs are which level. There is no such rule: `analytics_dashboard_delete`
|
|
230
|
+
and `analytics_insight_delete` are `write`, `mail_domain_create` is `admin`, and
|
|
231
|
+
the whole `users` namespace has exactly six `admin` operations. Only the catalog
|
|
232
|
+
knows, the method carries what the catalog said, and a reason built from the
|
|
233
|
+
method cannot drift the day an operation is re-levelled. The accessor path is a
|
|
234
|
+
lossy view of the key, so never rebuild `operationKey` from it either — read it
|
|
235
|
+
off the method.
|
|
236
|
+
|
|
237
|
+
### Your App mounts its own `TooltipProvider`
|
|
238
|
+
|
|
239
|
+
Mount **one `TooltipProvider` per App canvas** — at the root of your remote, as in
|
|
240
|
+
§1 — and never one per control. Do not rely on the shell for it: `Tooltip.Root`
|
|
241
|
+
*throws* without a provider above it, so a remote that inherits one only by luck
|
|
242
|
+
does not lose its hint, it crashes — and your standalone build, your dev entry and
|
|
243
|
+
your tests all render outside the shell entirely.
|
|
244
|
+
|
|
245
|
+
### What each access state means for a control
|
|
246
|
+
|
|
247
|
+
| `state` | Control | Reason shown |
|
|
248
|
+
| --- | --- | --- |
|
|
249
|
+
| `idle` / `loading` | disabled | none |
|
|
250
|
+
| `ready` | disabled iff `can()` is false | only when `can()` is false |
|
|
251
|
+
| `error` | **enabled** | none — no prediction |
|
|
252
|
+
|
|
253
|
+
`idle`/`loading` start disabled and enable themselves — a control that starts
|
|
254
|
+
enabled and greys out a moment later is worse than one that never greyed out.
|
|
255
|
+
But it claims **no reason** while the snapshot is still in flight: nothing is
|
|
256
|
+
known yet about which level is missing, so there is nothing to say.
|
|
257
|
+
|
|
258
|
+
`error` (the `/me` call failed: a 500, a dropped connection, an unparsable product
|
|
259
|
+
id) is the case worth getting right: **make no prediction**. Leave the control
|
|
260
|
+
enabled, name no missing level, let the gateway decide, and render the refusal
|
|
261
|
+
neutrally through `isAccessDenied` — disabling on error makes your client the
|
|
262
|
+
authority and tells the user a level is missing when nothing at all is known
|
|
263
|
+
about their access.
|
|
264
|
+
|
|
265
|
+
`can()` itself is unchanged by any of this: it is `false` unless the state is
|
|
266
|
+
`ready`. The `error` branch is a decision your *control* makes, not something
|
|
267
|
+
`can()` reports.
|
|
268
|
+
|
|
107
269
|
**Disable, with a hint. Do not hide.** A user who cannot see the button cannot
|
|
108
270
|
tell their admin which permission they are missing — they file a "the app is
|
|
109
271
|
broken" ticket instead, and the admin who could have fixed it in ten seconds
|
|
@@ -123,6 +285,179 @@ if (state === 'error') return <p>Could not load your permissions. <button onClic
|
|
|
123
285
|
React is an **optional peer** of this package: `@lessly/sdk-app/react` is only
|
|
124
286
|
loaded if you import it, and installing the SDK does not pull React in.
|
|
125
287
|
|
|
288
|
+
### Actions without a button
|
|
289
|
+
|
|
290
|
+
A write does not stop being a write because nothing on screen says "Save". Drag to
|
|
291
|
+
reorder, an inline-editable cell, a field that autosaves on blur — each is a
|
|
292
|
+
gated operation, and each is gated the **same way** a button is: put the control
|
|
293
|
+
into its inactive state the native way for that control, then hang the reason on
|
|
294
|
+
a keyboard-reachable `Tooltip` wrapper exactly as above.
|
|
295
|
+
|
|
296
|
+
| Control | Inactive state |
|
|
297
|
+
| --- | --- |
|
|
298
|
+
| Drag handle / sortable row | `draggable={false}` (and drop the drag listeners) |
|
|
299
|
+
| Inline-editable field | `readOnly` |
|
|
300
|
+
| Autosave-on-blur input | `readOnly` |
|
|
301
|
+
| Switch / checkbox | `disabled` |
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
function ReorderableRow({ app, row }: { app: App; row: Row }) {
|
|
305
|
+
const { state } = useAccess(app);
|
|
306
|
+
const canReorder = useCan(app, app.mail.domain.update);
|
|
307
|
+
const denied = state === 'ready' && !canReorder;
|
|
308
|
+
|
|
309
|
+
const item = (
|
|
310
|
+
<li draggable={!denied} onDragStart={denied ? undefined : startDrag}>
|
|
311
|
+
{row.name}
|
|
312
|
+
</li>
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
if (!denied) return item;
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<Tooltip>
|
|
319
|
+
<TooltipTrigger asChild>
|
|
320
|
+
<span tabIndex={0}>{item}</span>
|
|
321
|
+
</TooltipTrigger>
|
|
322
|
+
<TooltipContent>
|
|
323
|
+
{accessReason(app.mail.domain.update)}
|
|
324
|
+
</TooltipContent>
|
|
325
|
+
</Tooltip>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**Gate the operation, not the gesture.** A write is often reachable by more than
|
|
331
|
+
one path — a row that reorders by mouse drag *and* by arrow keys, a control that
|
|
332
|
+
fires on click *and* on Enter, an action with a button *and* a hotkey. The gate
|
|
333
|
+
has to silence all of them at once. Gating the drag and leaving the arrow keys
|
|
334
|
+
live is not a cosmetic miss: it is a hole, and it is a hole that only keyboard
|
|
335
|
+
users fall into. Derive the flag once per operation and let every path read it,
|
|
336
|
+
rather than attaching a check to each handler. Acceptance under a viewer checks
|
|
337
|
+
the alternative path too, not just the obvious one.
|
|
338
|
+
|
|
339
|
+
**Enter inside a form field is one of those paths.** A form with a single text
|
|
340
|
+
input submits on Enter whether or not you wired anything to it — that is implicit
|
|
341
|
+
submission, and a disabled submit button does not reliably stop it. Gate the
|
|
342
|
+
`onSubmit` handler itself, or make the fields `readOnly`; a greyed-out button
|
|
343
|
+
beside a live Enter key is the same keyboard-only hole in a different costume.
|
|
344
|
+
|
|
345
|
+
```tsx
|
|
346
|
+
<form
|
|
347
|
+
onSubmit={(event) => {
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
if (denied) return; // the button being disabled is NOT enough
|
|
350
|
+
save();
|
|
351
|
+
}}
|
|
352
|
+
>
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
"Don't gate it, let the gateway refuse" is allowed for exactly one case: the
|
|
356
|
+
element physically has no wrapper that can be made focusable. That is rare, and
|
|
357
|
+
it is not a judgement call you make silently — write it into your App's spec as a
|
|
358
|
+
named exception with the reason. An ungated write that nobody wrote down is
|
|
359
|
+
indistinguishable from one nobody thought about.
|
|
360
|
+
|
|
361
|
+
### Buttons that touch more than one operation
|
|
362
|
+
|
|
363
|
+
Two shapes, and they gate differently. Get this wrong and you either grey out a
|
|
364
|
+
working button or leave a broken one live.
|
|
365
|
+
|
|
366
|
+
**A button that performs several operations per click** — "Deploy" that creates,
|
|
367
|
+
uploads and promotes — is disabled if **any** of them is denied, and names the
|
|
368
|
+
level and key of the one that blocked it, not those of the headline operation. Call
|
|
369
|
+
`useAccess` once and use the synchronous `access.can()` for each; `useCan` is a
|
|
370
|
+
hook and cannot be called in a loop.
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
const OPS = [app.mail.domain.create, app.mail.domain.verify] as const;
|
|
374
|
+
|
|
375
|
+
function AddAndVerifyButton({ app, onClick }: { app: App; onClick: () => void }) {
|
|
376
|
+
const { state, access } = useAccess(app);
|
|
377
|
+
const blocking = state === 'ready' ? OPS.find((op) => !access.can(op)) : undefined;
|
|
378
|
+
|
|
379
|
+
const disabled = state === 'ready' ? blocking !== undefined : state !== 'error';
|
|
380
|
+
const reason = blocking
|
|
381
|
+
? accessReason(blocking)
|
|
382
|
+
: undefined;
|
|
383
|
+
|
|
384
|
+
// …same wrapper as above when `reason` is set.
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
**A dispatcher button** — one operation per click, picked by a mode or a switch
|
|
389
|
+
beside it: *Import* over `users | waitlist`, *Save* that creates or updates
|
|
390
|
+
depending on whether the record exists — is gated by the operation it is
|
|
391
|
+
**actually about to call**, and its reason names *that* operation's level and key. Not
|
|
392
|
+
the union: a caller who may import users but not the waitlist keeps a working
|
|
393
|
+
Import button, and it greys out when they flip the switch.
|
|
394
|
+
|
|
395
|
+
```tsx
|
|
396
|
+
const op = mode === 'users' ? app.users.users.import : app.users.waitlist.import;
|
|
397
|
+
|
|
398
|
+
const { state, access } = useAccess(app);
|
|
399
|
+
const denied = state === 'ready' && !access.can(op);
|
|
400
|
+
const reason = denied
|
|
401
|
+
? accessReason(op)
|
|
402
|
+
: undefined;
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Both shapes hand the method to `accessReason`, so neither needs to know which
|
|
406
|
+
operation it ended up gating on.
|
|
407
|
+
|
|
408
|
+
### Forms with a deferred save
|
|
409
|
+
|
|
410
|
+
A form the reader fills in and *then* saves is the one case where a hint per
|
|
411
|
+
control is the wrong shape. Fifteen disabled fields, each explaining itself on
|
|
412
|
+
hover, tell the same sentence fifteen times — and the reader who is going to be
|
|
413
|
+
refused learns it only after typing.
|
|
414
|
+
|
|
415
|
+
Prefer **one line in the card's footer**, stating the constraint before anyone
|
|
416
|
+
starts editing:
|
|
417
|
+
|
|
418
|
+
```tsx
|
|
419
|
+
import { Card, CardNote } from '@lessly/ui';
|
|
420
|
+
|
|
421
|
+
<Card title="Domain settings">
|
|
422
|
+
{fields}
|
|
423
|
+
{denied && (
|
|
424
|
+
<CardNote>{accessReason(app.mail.domain.update)}</CardNote>
|
|
425
|
+
)}
|
|
426
|
+
</Card>
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
`CardNote` is exactly this line — a lock and one sentence at the card's foot,
|
|
430
|
+
with no tone and no variant, precisely so a frozen card does not grow a box it
|
|
431
|
+
did not have when it was live. It speaks for the card, not for one control on it;
|
|
432
|
+
a constraint that covers a single row still belongs on that row.
|
|
433
|
+
|
|
434
|
+
The fields themselves stay `readOnly` and the Save button stays disabled — the
|
|
435
|
+
footer note replaces the *per-control tooltips*, not the gate.
|
|
436
|
+
|
|
437
|
+
### Verify the hint from the keyboard
|
|
438
|
+
|
|
439
|
+
The whole point of the wrapper is that the reason survives the disabled state, and
|
|
440
|
+
the only proof of that is **reaching it with the keyboard**. Tab to the wrapper;
|
|
441
|
+
the tooltip opens.
|
|
442
|
+
|
|
443
|
+
```tsx
|
|
444
|
+
it('explains the missing level to a keyboard user', async () => {
|
|
445
|
+
const user = userEvent.setup();
|
|
446
|
+
render(<AddDomainButton app={viewerApp} onClick={() => {}} />);
|
|
447
|
+
|
|
448
|
+
await user.tab(); // focus lands on the wrapper
|
|
449
|
+
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
|
450
|
+
'Requires level:admin (mail_domain_create). Ask an admin of this product.'
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Assert the **rendered tooltip**, not an attribute. A test that only checks that
|
|
456
|
+
some `title`, `aria-describedby` or `data-*` is present passes cheerfully against
|
|
457
|
+
a hint no user can ever see — which is exactly the bug this pattern exists to fix.
|
|
458
|
+
Manual acceptance is the same check by hand: Tab to the control, the reason
|
|
459
|
+
appears, no mouse involved.
|
|
460
|
+
|
|
126
461
|
---
|
|
127
462
|
|
|
128
463
|
## 3. `isAccessDenied` in the shared query state — a neutral empty state
|
|
@@ -163,6 +498,94 @@ one: it sends the user to an admin who can do nothing about it.
|
|
|
163
498
|
frequent and not the user's mistake. Painting it as a crash teaches people to
|
|
164
499
|
ignore the colour you need for real failures.
|
|
165
500
|
|
|
501
|
+
|
|
502
|
+
### A refusal inside the kit's `ConfirmDialog`
|
|
503
|
+
|
|
504
|
+
`ConfirmDialog` takes `onError?: (error: unknown) => string` — a string, and the
|
|
505
|
+
kit picks the tone from it. So a 403 raised by the confirmed action cannot yet be
|
|
506
|
+
rendered *neutrally* inside the dialog the way it is on a screen: you own the
|
|
507
|
+
words, the kit owns the colour.
|
|
508
|
+
|
|
509
|
+
Do the half you own. Route the error through the same classifier your shared
|
|
510
|
+
query state uses, so the sentence a denied caller reads is the same neutral one
|
|
511
|
+
everywhere:
|
|
512
|
+
|
|
513
|
+
```tsx
|
|
514
|
+
<ConfirmDialog
|
|
515
|
+
title="Delete domain"
|
|
516
|
+
onConfirm={() => app.mail.domain.delete({ id })}
|
|
517
|
+
onError={(error) =>
|
|
518
|
+
isAccessDenied(error)
|
|
519
|
+
? "You don't have access to delete domains. Ask an admin of this product."
|
|
520
|
+
: 'Could not delete the domain. Try again.'
|
|
521
|
+
}
|
|
522
|
+
/>
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
**Do not move the confirm flow into your App to win the colour.** Reimplementing
|
|
526
|
+
the dialog costs you the focus trap, the busy state, the confirm phrase and the
|
|
527
|
+
step-up — all to repaint one line of text.
|
|
528
|
+
|
|
529
|
+
This is known kit debt, tracked as platform **#2915** (`onError` returning
|
|
530
|
+
`{ text, tone }` so the kit can render a denial neutrally). It is tolerable in
|
|
531
|
+
the meantime because a denied caller rarely reaches the dialog at all: the
|
|
532
|
+
control that opens it is disabled under exactly the roles that would be refused.
|
|
533
|
+
A red refusal inside the dialog is only reachable in the snapshot's `error`
|
|
534
|
+
state, where — by the rule above — the App deliberately makes no prediction and
|
|
535
|
+
lets the gateway answer.
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## 4. Access fixtures in e2e
|
|
540
|
+
|
|
541
|
+
Do not point an e2e run at a real product to get a real role. Mock
|
|
542
|
+
`GET /governance/api/v1/products/:productId/me` per scenario instead: it is one
|
|
543
|
+
flat body, and the role you need is whichever one the scenario is about.
|
|
544
|
+
|
|
545
|
+
Never hard-code a real product uuid in the repo, and never create a long-lived
|
|
546
|
+
"smoke test" product to own these roles — both rot into a shared fixture nobody
|
|
547
|
+
dares change, and both make the suite depend on grants that live outside it.
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
// An owner: everything, minus the deny patterns seeded on every product.
|
|
551
|
+
const OWNER = {
|
|
552
|
+
role: 'owner',
|
|
553
|
+
allow: ['*'],
|
|
554
|
+
deny: [],
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// A member: read and write, no governance and no billing.
|
|
558
|
+
const MEMBER = {
|
|
559
|
+
role: 'member',
|
|
560
|
+
allow: ['level:read', 'level:write'],
|
|
561
|
+
deny: [
|
|
562
|
+
'*_roles_create',
|
|
563
|
+
'*_roles_update',
|
|
564
|
+
'*_roles_delete',
|
|
565
|
+
'*_members_assign-role',
|
|
566
|
+
'*_billing_*',
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// A viewer: read only — the scenario that greys out every primary action.
|
|
571
|
+
const VIEWER = {
|
|
572
|
+
role: 'viewer',
|
|
573
|
+
allow: ['level:read'],
|
|
574
|
+
deny: [
|
|
575
|
+
'*_roles_create',
|
|
576
|
+
'*_roles_update',
|
|
577
|
+
'*_roles_delete',
|
|
578
|
+
'*_members_assign-role',
|
|
579
|
+
'*_billing_*',
|
|
580
|
+
],
|
|
581
|
+
};
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Two more responses are worth a scenario each, because they are the two the UI
|
|
585
|
+
gets wrong: a **failing** `/me` (500 or a dropped connection — every control
|
|
586
|
+
stays enabled and claims nothing about the role) and a **403** from the action
|
|
587
|
+
itself with a `role_denied` body (the neutral empty state, not the red one).
|
|
588
|
+
|
|
166
589
|
---
|
|
167
590
|
|
|
168
591
|
## What not to do
|
|
@@ -175,3 +598,21 @@ ignore the colour you need for real failures.
|
|
|
175
598
|
| Hide controls the caller cannot use | Makes the missing permission unreportable. Disable with a reason instead. |
|
|
176
599
|
| 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
600
|
| Poll `load()` on a timer | The snapshot changes when a role changes; call `invalidate()` then, and only then. |
|
|
601
|
+
| Hand-write the reason text | It goes stale when the catalog re-levels the tool. Build it with `accessReason(op)`. |
|
|
602
|
+
| Name the caller's role in the reason | A role is not something an admin grants. Name the level and the operation key. |
|
|
603
|
+
| Assert only that a hint attribute exists | Green against a tooltip no user can reach. Tab to it and assert the rendered `role="tooltip"`. |
|
|
604
|
+
| Rely on a disabled submit button to stop Enter | Implicit submission fires anyway. Gate `onSubmit`, or make the fields `readOnly`. |
|
|
605
|
+
| Gate a dispatcher button on the union of its modes | It calls one operation per click. Gate the one it is about to call. |
|
|
606
|
+
| Repeat the same tooltip on every field of a deferred-save form | One `CardNote` in the footer, read before editing rather than after. |
|
|
607
|
+
| Gate the drag but not the arrow keys | Same operation, two paths. The hole is keyboard-only, which is the worst place for one. |
|
|
608
|
+
| Nest the Tooltip trigger inside your own `pointer-events-none` | Same silence as the kit's disabled button, in your code. Put the trigger outside it. |
|
|
609
|
+
| Infer a level from the verb ("delete means admin") | There is no such rule: analytics deletes are `write`. Read `op.level`. |
|
|
610
|
+
| Say only the level, with no operation key | Seventeen controls can share one level. The key is what an admin grants on. |
|
|
611
|
+
| Render `level:undefined` for a key with no method | Use the string arm of `accessReason`: name the key, drop the level. |
|
|
612
|
+
| Pass a string key where the method exists | The string arm is a degradation for methodless operations, not an equal input. |
|
|
613
|
+
| Hand `accessReason` a list of operations | It explains one. Pick the denied one first; the rule differs per button shape. |
|
|
614
|
+
| Reimplement `ConfirmDialog` to repaint a denial | Costs the focus trap, busy state, confirm phrase and step-up for one line of text. Kit debt #2915. |
|
|
615
|
+
| Leave a drag handle or inline edit ungated because it has no button | It is the same write. Set the native inactive state and wrap it. Skipping the gate is a spec'd exception, not a default. |
|
|
616
|
+
| Name the headline operation on a multi-operation button | Name the level and key of the operation that actually blocked it. |
|
|
617
|
+
| Put the reason in a native `title=` on the control | `disabled:pointer-events-none` swallows the hover, and a native tooltip is unreachable by keyboard and by touch. Wrap the control in a `TooltipTrigger`. |
|
|
618
|
+
| Disable a control because `state === 'error'` | Nothing is known about the role. Stay enabled, say nothing about the role, let the gateway answer. |
|
package/docs/rules.md
CHANGED
|
@@ -338,6 +338,91 @@ An App SHOULD reflect the caller's access in its UI: **disable, with a reason**
|
|
|
338
338
|
(not hide) a primary action the caller cannot perform, using `useCan` from
|
|
339
339
|
`@lessly/sdk-app/react` or `app.access.can()` directly, and render a 403 that
|
|
340
340
|
`isAccessDenied()` recognises as a **neutral empty state** rather than an error.
|
|
341
|
+
That reason MUST live on a `Tooltip` wrapper around the disabled control, never
|
|
342
|
+
on a native `title=` of the control itself: the base `buttonVariants` carry
|
|
343
|
+
`disabled:pointer-events-none`, so a disabled control fires no hover for the
|
|
344
|
+
browser to render a `title` from, and a native tooltip is unreachable by
|
|
345
|
+
keyboard and by touch in any case. An App that draws such a tooltip MUST mount
|
|
346
|
+
its own `TooltipProvider` once at the root of its remote — `Tooltip.Root` throws
|
|
347
|
+
without one, and the standalone build, the dev entry and the tests all render
|
|
348
|
+
outside the shell.
|
|
349
|
+
|
|
350
|
+
That reason MUST be exactly one sentence, the same in every App:
|
|
351
|
+
|
|
352
|
+
> `Requires level:<op.level> (<op.operationKey>). Ask an admin of this product.`
|
|
353
|
+
|
|
354
|
+
— e.g. `Requires level:admin (mail_domain_create). Ask an admin of this product.`
|
|
355
|
+
Both values MUST be read from the generated method, never written as literals.
|
|
356
|
+
`@lessly/sdk-app` exports `accessReason(op)`, which is that sentence; an App
|
|
357
|
+
SHOULD use it rather than assembling the text itself.
|
|
358
|
+
|
|
359
|
+
Where a control has only an operation key and no generated method to point at,
|
|
360
|
+
the App MUST NOT guess the level or render `level:undefined`; it says instead:
|
|
361
|
+
|
|
362
|
+
> `Requires access to <operationKey>. Ask an admin of this product.`
|
|
363
|
+
|
|
364
|
+
That string form is a **degradation** for operations no generated method covers,
|
|
365
|
+
not licence to pass a key where a method exists — the accessor path is a lossy
|
|
366
|
+
view of the key. An App whose every gate holds an `Operation` never uses it, and
|
|
367
|
+
that is the norm rather than an omission. `accessReason` explains ONE operation;
|
|
368
|
+
choosing which one to explain among a button's several is the caller's, not the
|
|
369
|
+
helper's.
|
|
370
|
+
It names what an admin can grant, not the caller's role: a role name gives them
|
|
371
|
+
nothing to act on. It names the operation key as well as the level because a
|
|
372
|
+
level alone does not identify the grant — seventeen controls on one screen can
|
|
373
|
+
all require `level:write` — and an admin grants both by level and by exact key,
|
|
374
|
+
so the user has to be able to pass on both. Building it from the method also
|
|
375
|
+
keeps it from drifting when the catalog re-levels a tool. Whatever renders it MUST be verified reachable **from the keyboard** —
|
|
376
|
+
Tab to the wrapper, the tooltip opens (in tests: `userEvent.tab()` then
|
|
377
|
+
`findByRole('tooltip')`). Asserting only that a hint attribute is present does
|
|
378
|
+
not satisfy this: that assertion is green against a hint no user can reach.
|
|
379
|
+
|
|
380
|
+
The level MUST come from the generated method itself (`op.level`, on the
|
|
381
|
+
`Operation` the method satisfies) and from nowhere else: not a lookup table in
|
|
382
|
+
the App, not a literal, and never a rule of thumb about verbs — `analytics_*_delete`
|
|
383
|
+
is `write` while `mail_domain_create` is `admin`, and only the catalog knows.
|
|
384
|
+
|
|
385
|
+
The gate belongs to the OPERATION, not to a gesture: where a write is reachable
|
|
386
|
+
by several paths (drag and arrow keys, click and Enter, button and hotkey) all of
|
|
387
|
+
them MUST be gated together, or the hole left behind is one only keyboard users
|
|
388
|
+
find. Implicit form submission is such a path: a disabled submit button does not
|
|
389
|
+
reliably stop Enter inside a field, so the `onSubmit` handler MUST be gated (or
|
|
390
|
+
the fields made `readOnly`) rather than the button alone.
|
|
391
|
+
|
|
392
|
+
A control that performs SEVERAL operations per click is disabled if ANY of them
|
|
393
|
+
is denied. A DISPATCHER control — one operation per click, selected by a mode or
|
|
394
|
+
a switch — MUST instead be gated on the operation it is about to call, and name
|
|
395
|
+
that operation's level and key; gating it on the union greys out a button the caller can
|
|
396
|
+
in fact use.
|
|
397
|
+
|
|
398
|
+
An App SHOULD state the constraint once in a card footer (`CardNote`) rather than
|
|
399
|
+
on every control of a form whose save is deferred: the reader meets the sentence
|
|
400
|
+
before editing rather than after, and one line replaces fifteen identical
|
|
401
|
+
tooltips. The fields stay inactive and the save stays disabled either way — the
|
|
402
|
+
note replaces the per-control hints, not the gate. And the `Tooltip` trigger MUST sit outside every element carrying
|
|
403
|
+
`pointer-events-none` — the App's own lists, chips and overlays as much as the
|
|
404
|
+
kit's disabled buttons — since the hint is swallowed by the same mechanics either
|
|
405
|
+
way.
|
|
406
|
+
|
|
407
|
+
This applies to every gated write, not only the ones with a button. A drag
|
|
408
|
+
handle, an inline-editable cell and a field that autosaves on blur are gated the
|
|
409
|
+
same way: the native inactive state for that control (`draggable={false}`,
|
|
410
|
+
`readOnly`, `disabled`) plus the reason on a keyboard-reachable `Tooltip`
|
|
411
|
+
wrapper. Leaving a write ungated for the gateway to refuse is permitted ONLY
|
|
412
|
+
where the element has no wrapper that can be made focusable, and that exception
|
|
413
|
+
MUST be written into the App's own spec with its reason. A button that performs
|
|
414
|
+
several operations is disabled if ANY of them is denied, and its reason names
|
|
415
|
+
the level and key of the operation that blocked it.
|
|
416
|
+
|
|
417
|
+
The reason is claimed only when the App actually knows it. With
|
|
418
|
+
`access.state === 'error'` the `/me` call failed and **nothing** is known about
|
|
419
|
+
the caller's role: the App MUST make no prediction — leave the control enabled,
|
|
420
|
+
name no missing operation, and let the gateway answer, rendering any refusal through
|
|
421
|
+
`isAccessDenied`. Disabling on error makes the client the authority and tells
|
|
422
|
+
the user their role forbids something no one has established. (`can()` itself is
|
|
423
|
+
unaffected: it stays `false` unless the state is `ready`.) While the state is
|
|
424
|
+
`idle` or `loading` the control SHOULD start disabled and enable itself, but
|
|
425
|
+
SHOULD NOT name a missing level or operation it does not yet know.
|
|
341
426
|
|
|
342
427
|
An App MUST NOT treat that prediction as enforcement. `can()` answers from a
|
|
343
428
|
snapshot fetched once from the `organization_product_me` tool; the gateway is
|
|
@@ -363,4 +448,11 @@ Pass the generated method rather than a hand-written tool id
|
|
|
363
448
|
(`app.access.can(app.mail.domain.create)`): the accessor path is a lossy view
|
|
364
449
|
of the tool id, so a literal string can silently drift from the call it gates.
|
|
365
450
|
|
|
451
|
+
One gap is known and accepted: `@lessly/ui`'s `ConfirmDialog` takes an `onError`
|
|
452
|
+
that returns a string, so the kit picks the tone and a refusal raised inside the
|
|
453
|
+
dialog cannot be painted neutrally yet. An App SHOULD still route that error
|
|
454
|
+
through `isAccessDenied()` for the *words*, and MUST NOT reimplement the confirm
|
|
455
|
+
flow to win the colour — it would forfeit the focus trap, busy state, confirm
|
|
456
|
+
phrase and step-up. Tracked as platform #2915.
|
|
457
|
+
|
|
366
458
|
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": "
|
|
3
|
+
"version": "63.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"engines": {
|
|
@@ -148,16 +148,6 @@
|
|
|
148
148
|
"default": "./dist/organization/index.cjs"
|
|
149
149
|
}
|
|
150
150
|
},
|
|
151
|
-
"./playground": {
|
|
152
|
-
"import": {
|
|
153
|
-
"types": "./dist/playground/index.d.ts",
|
|
154
|
-
"default": "./dist/playground/index.js"
|
|
155
|
-
},
|
|
156
|
-
"require": {
|
|
157
|
-
"types": "./dist/playground/index.d.cts",
|
|
158
|
-
"default": "./dist/playground/index.cjs"
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
151
|
"./realtime": {
|
|
162
152
|
"import": {
|
|
163
153
|
"types": "./dist/realtime/index.d.ts",
|
|
@@ -209,5 +199,5 @@
|
|
|
209
199
|
}
|
|
210
200
|
}
|
|
211
201
|
},
|
|
212
|
-
"sdkContentHash": "sha256:
|
|
202
|
+
"sdkContentHash": "sha256:dd29109a46bbe0d03bc1d40d04cb3be51eecfdcc76db40b6eceead9bf9a05f9b"
|
|
213
203
|
}
|