@lessly/sdk-app 61.2.0 → 62.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 (38) hide show
  1. package/dist/_types/gen/client.gen.d.ts +53 -2
  2. package/dist/_types/gen/manifest.gen.d.ts +1 -1
  3. package/dist/_types/gen/organization/index.d.ts +1 -1
  4. package/dist/_types/gen/organization/queryOptions.gen.d.ts +57 -5
  5. package/dist/_types/gen/playground/connect.gen.d.ts +3 -0
  6. package/dist/_types/gen/playground/index.d.ts +3 -0
  7. package/dist/_types/gen/playground/queryOptions.gen.d.ts +50 -0
  8. package/dist/_types/gen/types.gen.d.ts +374 -10
  9. package/dist/_types/index.d.ts +1 -0
  10. package/dist/_types/runtime/access-reason.d.ts +20 -0
  11. package/dist/chunk-2YAA3AF4.js +10437 -0
  12. package/dist/chunk-2YAA3AF4.js.map +1 -0
  13. package/dist/index.cjs +444 -66
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.js +60 -10106
  16. package/dist/index.js.map +1 -1
  17. package/dist/organization/index.cjs +70 -5
  18. package/dist/organization/index.cjs.map +1 -1
  19. package/dist/organization/index.js +57 -5
  20. package/dist/organization/index.js.map +1 -1
  21. package/dist/playground/index.cjs +81 -0
  22. package/dist/playground/index.cjs.map +1 -0
  23. package/dist/playground/index.d.cts +1 -0
  24. package/dist/playground/index.d.ts +1 -0
  25. package/dist/playground/index.js +58 -0
  26. package/dist/playground/index.js.map +1 -0
  27. package/docs/recipes/access.md +456 -15
  28. package/docs/rules.md +92 -0
  29. package/package.json +12 -2
  30. package/src/gen/bindings.gen.ts +395 -40
  31. package/src/gen/client.gen.ts +104 -3
  32. package/src/gen/manifest.gen.ts +53 -28
  33. package/src/gen/organization/index.ts +1 -1
  34. package/src/gen/organization/queryOptions.gen.ts +87 -6
  35. package/src/gen/playground/connect.gen.ts +9 -0
  36. package/src/gen/playground/index.ts +4 -0
  37. package/src/gen/playground/queryOptions.gen.ts +86 -0
  38. package/src/gen/types.gen.ts +441 -12
@@ -0,0 +1 @@
1
+ export * from '../_types/gen/playground/index.js';
@@ -0,0 +1 @@
1
+ export * from '../_types/gen/playground/index.js';
@@ -0,0 +1,58 @@
1
+ import { wsBindings } from '../chunk-2YAA3AF4.js';
2
+
3
+ // src/gen/playground/queryOptions.gen.ts
4
+ var playgroundAnalyticsOverviewQueryOptions = (sdk, input) => ({
5
+ queryKey: ["playground", "analytics", "overview", input],
6
+ queryFn: () => sdk["playground"]["analytics"]["overview"](input)
7
+ });
8
+ var playgroundBillingEntitlementsQueryOptions = (sdk, input) => ({
9
+ queryKey: ["playground", "billing", "entitlements", input],
10
+ queryFn: () => sdk["playground"]["billing"]["entitlements"](input)
11
+ });
12
+ var playgroundBillingRecordUsageMutationOptions = (sdk) => ({
13
+ mutationKey: ["playground", "billing", "record-usage"],
14
+ mutationFn: (input) => sdk["playground"]["billing"]["record-usage"](input)
15
+ });
16
+ var playgroundClickupCreateTaskMutationOptions = (sdk) => ({
17
+ mutationKey: ["playground", "clickup", "create-task"],
18
+ mutationFn: (input) => sdk["playground"]["clickup"]["create-task"](input)
19
+ });
20
+ var playgroundClickupGetLastWebhookQueryOptions = (sdk, input) => ({
21
+ queryKey: ["playground", "clickup", "get-last-webhook", input],
22
+ queryFn: () => sdk["playground"]["clickup"]["get-last-webhook"](input)
23
+ });
24
+ var playgroundClickupGetOverviewQueryOptions = (sdk, input) => ({
25
+ queryKey: ["playground", "clickup", "get-overview", input],
26
+ queryFn: () => sdk["playground"]["clickup"]["get-overview"](input)
27
+ });
28
+ var playgroundGdriveGetLastChangeQueryOptions = (sdk, input) => ({
29
+ queryKey: ["playground", "gdrive", "get-last-change", input],
30
+ queryFn: () => sdk["playground"]["gdrive"]["get-last-change"](input)
31
+ });
32
+ var playgroundGdriveReadFileQueryOptions = (sdk, input) => ({
33
+ queryKey: ["playground", "gdrive", "read-file", input],
34
+ queryFn: () => sdk["playground"]["gdrive"]["read-file"](input)
35
+ });
36
+ var playgroundGoogleadsReadCustomerQueryOptions = (sdk, input) => ({
37
+ queryKey: ["playground", "googleads", "read-customer", input],
38
+ queryFn: () => sdk["playground"]["googleads"]["read-customer"](input)
39
+ });
40
+ var playgroundLifecycleGetStateQueryOptions = (sdk, input) => ({
41
+ queryKey: ["playground", "lifecycle", "get-state", input],
42
+ queryFn: () => sdk["playground"]["lifecycle"]["get-state"](input)
43
+ });
44
+ var playgroundLifecycleListEventsQueryOptions = (sdk, input) => ({
45
+ queryKey: ["playground", "lifecycle", "list-events", input],
46
+ queryFn: () => sdk["playground"]["lifecycle"]["list-events"](input)
47
+ });
48
+ var playgroundWebhookGetLastQueryOptions = (sdk, input) => ({
49
+ queryKey: ["playground", "webhook", "get-last", input],
50
+ queryFn: () => sdk["playground"]["webhook"]["get-last"](input)
51
+ });
52
+
53
+ // src/gen/playground/connect.gen.ts
54
+ var playgroundWsEchoConnect = (sdk, input) => sdk.openStream(wsBindings["playground"]["ws"]["echo"], input);
55
+
56
+ export { playgroundAnalyticsOverviewQueryOptions, playgroundBillingEntitlementsQueryOptions, playgroundBillingRecordUsageMutationOptions, playgroundClickupCreateTaskMutationOptions, playgroundClickupGetLastWebhookQueryOptions, playgroundClickupGetOverviewQueryOptions, playgroundGdriveGetLastChangeQueryOptions, playgroundGdriveReadFileQueryOptions, playgroundGoogleadsReadCustomerQueryOptions, playgroundLifecycleGetStateQueryOptions, playgroundLifecycleListEventsQueryOptions, playgroundWebhookGetLastQueryOptions, playgroundWsEchoConnect };
57
+ //# sourceMappingURL=index.js.map
58
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/gen/playground/queryOptions.gen.ts","../../src/gen/playground/connect.gen.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// AUTOGENERATED by scripts/generate.ts — do not edit.\nimport type { GeneratedClient } from '../client.gen';\nimport type {\n PlaygroundAnalyticsOverviewInput,\n PlaygroundAnalyticsOverviewOutput,\n PlaygroundBillingEntitlementsInput,\n PlaygroundBillingEntitlementsOutput,\n PlaygroundBillingRecordUsageInput,\n PlaygroundClickupCreateTaskInput,\n PlaygroundClickupGetLastWebhookInput,\n PlaygroundClickupGetLastWebhookOutput,\n PlaygroundClickupGetOverviewInput,\n PlaygroundClickupGetOverviewOutput,\n PlaygroundGdriveGetLastChangeInput,\n PlaygroundGdriveGetLastChangeOutput,\n PlaygroundGdriveReadFileInput,\n PlaygroundGdriveReadFileOutput,\n PlaygroundGoogleadsReadCustomerInput,\n PlaygroundGoogleadsReadCustomerOutput,\n PlaygroundLifecycleGetStateInput,\n PlaygroundLifecycleGetStateOutput,\n PlaygroundLifecycleListEventsInput,\n PlaygroundLifecycleListEventsOutput,\n PlaygroundWebhookGetLastInput,\n PlaygroundWebhookGetLastOutput,\n} from '../types.gen';\n\nexport const playgroundAnalyticsOverviewQueryOptions = (sdk: GeneratedClient, input: PlaygroundAnalyticsOverviewInput) => ({\n queryKey: ['playground', 'analytics', 'overview', input] as const,\n queryFn: () => sdk['playground']['analytics']['overview'](input),\n});\n\nexport const playgroundBillingEntitlementsQueryOptions = (sdk: GeneratedClient, input: PlaygroundBillingEntitlementsInput) => ({\n queryKey: ['playground', 'billing', 'entitlements', input] as const,\n queryFn: () => sdk['playground']['billing']['entitlements'](input),\n});\n\nexport const playgroundBillingRecordUsageMutationOptions = (sdk: GeneratedClient) => ({\n mutationKey: ['playground', 'billing', 'record-usage'],\n mutationFn: (input: PlaygroundBillingRecordUsageInput) => sdk['playground']['billing']['record-usage'](input),\n});\n\nexport const playgroundClickupCreateTaskMutationOptions = (sdk: GeneratedClient) => ({\n mutationKey: ['playground', 'clickup', 'create-task'],\n mutationFn: (input: PlaygroundClickupCreateTaskInput) => sdk['playground']['clickup']['create-task'](input),\n});\n\nexport const playgroundClickupGetLastWebhookQueryOptions = (sdk: GeneratedClient, input: PlaygroundClickupGetLastWebhookInput) => ({\n queryKey: ['playground', 'clickup', 'get-last-webhook', input] as const,\n queryFn: () => sdk['playground']['clickup']['get-last-webhook'](input),\n});\n\nexport const playgroundClickupGetOverviewQueryOptions = (sdk: GeneratedClient, input: PlaygroundClickupGetOverviewInput) => ({\n queryKey: ['playground', 'clickup', 'get-overview', input] as const,\n queryFn: () => sdk['playground']['clickup']['get-overview'](input),\n});\n\nexport const playgroundGdriveGetLastChangeQueryOptions = (sdk: GeneratedClient, input: PlaygroundGdriveGetLastChangeInput) => ({\n queryKey: ['playground', 'gdrive', 'get-last-change', input] as const,\n queryFn: () => sdk['playground']['gdrive']['get-last-change'](input),\n});\n\nexport const playgroundGdriveReadFileQueryOptions = (sdk: GeneratedClient, input: PlaygroundGdriveReadFileInput) => ({\n queryKey: ['playground', 'gdrive', 'read-file', input] as const,\n queryFn: () => sdk['playground']['gdrive']['read-file'](input),\n});\n\nexport const playgroundGoogleadsReadCustomerQueryOptions = (sdk: GeneratedClient, input: PlaygroundGoogleadsReadCustomerInput) => ({\n queryKey: ['playground', 'googleads', 'read-customer', input] as const,\n queryFn: () => sdk['playground']['googleads']['read-customer'](input),\n});\n\nexport const playgroundLifecycleGetStateQueryOptions = (sdk: GeneratedClient, input: PlaygroundLifecycleGetStateInput) => ({\n queryKey: ['playground', 'lifecycle', 'get-state', input] as const,\n queryFn: () => sdk['playground']['lifecycle']['get-state'](input),\n});\n\nexport const playgroundLifecycleListEventsQueryOptions = (sdk: GeneratedClient, input: PlaygroundLifecycleListEventsInput) => ({\n queryKey: ['playground', 'lifecycle', 'list-events', input] as const,\n queryFn: () => sdk['playground']['lifecycle']['list-events'](input),\n});\n\nexport const playgroundWebhookGetLastQueryOptions = (sdk: GeneratedClient, input: PlaygroundWebhookGetLastInput) => ({\n queryKey: ['playground', 'webhook', 'get-last', input] as const,\n queryFn: () => sdk['playground']['webhook']['get-last'](input),\n});\n","// AUTOGENERATED by scripts/generate.ts — do not edit.\nimport { wsBindings } from '../bindings.gen';\nimport type { LesslyStream, LesslyStreamOpener } from '../../runtime/types';\nimport type {\n PlaygroundWsEchoInput,\n} from '../types.gen';\n\nexport const playgroundWsEchoConnect = (sdk: LesslyStreamOpener, input: PlaygroundWsEchoInput): LesslyStream =>\n sdk.openStream(wsBindings['playground']!['ws']!['echo']!, input as Record<string, unknown>);\n"]}
@@ -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 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.
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 every consumer of
67
- // can() is already fail-closed.
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
- return <Routes app={app} />;
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 { useCan } from '@lessly/sdk-app/react';
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
- 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
- >
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
- </button>
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. |