@colixsystems/widget-sdk 0.12.0 → 0.14.1
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/README.md +66 -2
- package/dist/contract.cjs +118 -1
- package/dist/contract.js +118 -1
- package/dist/hooks.js +270 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +5 -0
- package/dist/index.native.js +5 -0
- package/dist/linter.cjs +96 -1
- package/dist/linter.js +120 -1
- package/dist/property-schema.js +7 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,7 +6,23 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
9
|
-
`v0.
|
|
9
|
+
`v0.14.1` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
10
|
+
|
|
11
|
+
### What's new in 0.14.1
|
|
12
|
+
|
|
13
|
+
- **`groupRef` property type (REQ-USERMGMT M4 / §4.8).** Authors can now declare `{ type: 'groupRef', label: 'Group' }` in their `propertySchema` to render a Group picker in the Studio Properties Panel. The widget receives a bare `AppUserGroup` UUID — REQ-GEN-07 compliant, so tenant-copy walks the value transparently. Used by the built-in `appstudio.user-management` widget for its `defaultGroupId` prop and available to third-party widgets that need to anchor behaviour on a specific group.
|
|
14
|
+
- **Patch bump** — additive enumeration entry, no exported function signature changed. `CONTRACT.version` stays `1.4.0`.
|
|
15
|
+
|
|
16
|
+
### What's new in 0.14.0
|
|
17
|
+
|
|
18
|
+
- **`useUsers()` + `useGroups()` — AppUser administration hooks (REQ-USERMGMT / REQ-ACL-SYS M3).** A widget can now invite, deactivate, reactivate, and remove members, and create / delete groups + add / remove members, from a published-app surface. Returns `{ users | groups, loading, error, refetch, ... }` plus imperative mutation methods. Reads gated by `users.read:*` / `groups.read:*`; mutations by `users.write:*` / `groups.write:*`. Rejections surface as a structured `DirectoryError` (new named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY`. The host signs an `X-Widget-Scopes` header against `JWT_SECRET` so an APP_USER cannot forge a scope set, and the backend additionally gates the request behind a SystemAcl `users.*` / `groups.*` capability grant (REQ-ACL-SYS M1 + M3).
|
|
19
|
+
- **Managing app users from a widget — see the section below.**
|
|
20
|
+
- **New linter rule `no-scope-mismatch-useUsers` / `no-scope-mismatch-useGroups`.** Calling `useUsers().invite()` / `.deactivate()` / `.reactivate()` / `.remove()` without `users.write:*` in the manifest's `requestedScopes` fails the lint; calling `useGroups()` mutation methods without `groups.write:*` fails the lint. Keeps the manifest honest at submission time.
|
|
21
|
+
- **`CONTRACT.version` → `1.4.0`** (additive: two new hooks, two new context slices, six new scope verbs, one new error class). No existing export changed signature.
|
|
22
|
+
|
|
23
|
+
### What's new in 0.13.0
|
|
24
|
+
|
|
25
|
+
- **`WidgetTree` component + `useChildRenderer()` hook** wired. Container widgets (Tabs, Card, …) can now render arbitrary author-authored child page-tree nodes through the SDK without importing the host renderer. The host pre-binds the surrounding render context (breakpoint, page ctx, parent) into a closure on `ctx.renderer.renderNode(node)` — the widget just passes the child node. Additive.
|
|
10
26
|
|
|
11
27
|
### What's new in 0.12.0
|
|
12
28
|
|
|
@@ -85,10 +101,58 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
85
101
|
|
|
86
102
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
87
103
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
88
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreMutation`, `useDirectory`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation` — hooks that read from the host-provided `WidgetContext`. `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (`id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useFile(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base.
|
|
104
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer` — hooks that read from the host-provided `WidgetContext`. `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (`id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useFile(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case).
|
|
105
|
+
- `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
|
|
89
106
|
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet` — re-exported from `react-native`. The web build aliases `react-native` to `react-native-web` so widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. See https://reactnative.dev/docs/ for per-component props.
|
|
90
107
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
91
108
|
|
|
109
|
+
## Managing app users from a widget
|
|
110
|
+
|
|
111
|
+
REQ-USERMGMT / REQ-ACL-SYS M3 added two hooks that let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. The hooks are gated by two layers: the widget's manifest must declare the scope (so the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability in the tenant (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller does not hold the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that to the end-user as a "you do not have permission to do that" message.
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
import { Text, View, Pressable, useUsers, useGroups } from "@colixsystems/widget-sdk";
|
|
115
|
+
|
|
116
|
+
export default function MemberManager() {
|
|
117
|
+
const { users, loading, invite, deactivate, remove } = useUsers({ q: "" });
|
|
118
|
+
const { groups, addMember } = useGroups();
|
|
119
|
+
if (loading) return <Text>Loading…</Text>;
|
|
120
|
+
return (
|
|
121
|
+
<View>
|
|
122
|
+
{users.map((u) => (
|
|
123
|
+
<View key={u.id}>
|
|
124
|
+
<Text>{u.name} — {u.isActive ? "active" : "inactive"}</Text>
|
|
125
|
+
<Pressable onPress={() => deactivate(u.id)}><Text>Deactivate</Text></Pressable>
|
|
126
|
+
<Pressable onPress={() => remove(u.id)}><Text>Remove</Text></Pressable>
|
|
127
|
+
</View>
|
|
128
|
+
))}
|
|
129
|
+
<Pressable
|
|
130
|
+
onPress={async () => {
|
|
131
|
+
try {
|
|
132
|
+
await invite({ email: "a@b.com", name: "New User", groupIds: [groups[0]?.id].filter(Boolean) });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// err.code is one of FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY
|
|
135
|
+
}
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<Text>Invite</Text>
|
|
139
|
+
</Pressable>
|
|
140
|
+
</View>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The matching manifest declares the scopes:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
{
|
|
149
|
+
// ...
|
|
150
|
+
requestedScopes: ["users.read:*", "users.write:*", "groups.read:*", "groups.write:*"],
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The host rejects calls whose scope is not declared in the manifest (the SDK linter catches this statically too). Declaring a write scope is also a consent prompt the Studio admin sees at install time — the wider the scope set, the more careful the admin is about granting the install.
|
|
155
|
+
|
|
92
156
|
## Linter
|
|
93
157
|
|
|
94
158
|
```sh
|
package/dist/contract.cjs
CHANGED
|
@@ -68,6 +68,15 @@ const HOOKS = [
|
|
|
68
68
|
requiredContextSlice: ["user"],
|
|
69
69
|
scopes: null,
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
name: "useChildRenderer",
|
|
73
|
+
signature: "useChildRenderer()",
|
|
74
|
+
returnShape: {
|
|
75
|
+
renderNode: "(node) => ReactElement",
|
|
76
|
+
},
|
|
77
|
+
requiredContextSlice: ["renderer.renderNode"],
|
|
78
|
+
scopes: null,
|
|
79
|
+
},
|
|
71
80
|
{
|
|
72
81
|
name: "useNavigation",
|
|
73
82
|
signature: "useNavigation()",
|
|
@@ -161,6 +170,76 @@ const HOOKS = [
|
|
|
161
170
|
requiredContextSlice: ["payments.requestPayment"],
|
|
162
171
|
scopes: ["payments.charge:appUser"],
|
|
163
172
|
},
|
|
173
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
174
|
+
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
175
|
+
// Reads need `users.read:*` scope; mutations additionally need
|
|
176
|
+
// `users.write:*`. The `invite` call accepts `{ email, name, groupIds? }`
|
|
177
|
+
// and returns the resulting AppUserInvite row (the email is sent by the
|
|
178
|
+
// host). Mutating users from a widget requires the corresponding
|
|
179
|
+
// SystemAcl `users.write` capability grant in the tenant; widgets that
|
|
180
|
+
// only call read methods need only `users.read:*`.
|
|
181
|
+
{
|
|
182
|
+
name: "useUsers",
|
|
183
|
+
signature: "useUsers(query?)",
|
|
184
|
+
description:
|
|
185
|
+
"AppUser administration. Returns { users, loading, error, refetch, invite, " +
|
|
186
|
+
"deactivate, reactivate, remove }. Reads need users.read:* scope; mutations " +
|
|
187
|
+
"need users.write:*. The `invite` call accepts { email, name, groupIds? } " +
|
|
188
|
+
"and returns the resulting AppUserInvite row (the email is sent by the host).",
|
|
189
|
+
returnShape: {
|
|
190
|
+
users: "Array<{ id, name, email?, role, isActive }>",
|
|
191
|
+
loading: "boolean",
|
|
192
|
+
error: "DirectoryError | null",
|
|
193
|
+
refetch: "() => Promise<void>",
|
|
194
|
+
invite:
|
|
195
|
+
"({ email, name, groupIds? }) => Promise<Invite> // rejects with DirectoryError",
|
|
196
|
+
deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
|
|
197
|
+
reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
|
|
198
|
+
remove: "(userId) => Promise<void> // rejects with DirectoryError",
|
|
199
|
+
},
|
|
200
|
+
requiredContextSlice: [
|
|
201
|
+
"users.listUsers",
|
|
202
|
+
"users.invite",
|
|
203
|
+
"users.deactivate",
|
|
204
|
+
"users.reactivate",
|
|
205
|
+
"users.remove",
|
|
206
|
+
],
|
|
207
|
+
scopes: ["users.read:*"],
|
|
208
|
+
},
|
|
209
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
|
|
210
|
+
// `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
|
|
211
|
+
// Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
|
|
212
|
+
// member requires the corresponding `users.write` capability since it
|
|
213
|
+
// affects the user's effective access.
|
|
214
|
+
{
|
|
215
|
+
name: "useGroups",
|
|
216
|
+
signature: "useGroups(query?)",
|
|
217
|
+
description:
|
|
218
|
+
"AppUserGroup administration. Returns { groups, loading, error, refetch, " +
|
|
219
|
+
"create, remove, addMember, removeMember }. Reads need groups.read:*; " +
|
|
220
|
+
"mutations need groups.write:*.",
|
|
221
|
+
returnShape: {
|
|
222
|
+
groups: "Array<{ id, name, memberCount }>",
|
|
223
|
+
loading: "boolean",
|
|
224
|
+
error: "DirectoryError | null",
|
|
225
|
+
refetch: "() => Promise<void>",
|
|
226
|
+
create:
|
|
227
|
+
"({ name }) => Promise<Group> // rejects with DirectoryError",
|
|
228
|
+
remove: "(groupId) => Promise<void> // rejects with DirectoryError",
|
|
229
|
+
addMember:
|
|
230
|
+
"(groupId, userId) => Promise<void> // rejects with DirectoryError",
|
|
231
|
+
removeMember:
|
|
232
|
+
"(groupId, userId) => Promise<void> // rejects with DirectoryError",
|
|
233
|
+
},
|
|
234
|
+
requiredContextSlice: [
|
|
235
|
+
"groups.listGroups",
|
|
236
|
+
"groups.create",
|
|
237
|
+
"groups.remove",
|
|
238
|
+
"groups.addMember",
|
|
239
|
+
"groups.removeMember",
|
|
240
|
+
],
|
|
241
|
+
scopes: ["groups.read:*"],
|
|
242
|
+
},
|
|
164
243
|
];
|
|
165
244
|
|
|
166
245
|
// REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
|
|
@@ -407,6 +486,12 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
407
486
|
required: true,
|
|
408
487
|
fields: { get: "function" },
|
|
409
488
|
},
|
|
489
|
+
renderer: {
|
|
490
|
+
description:
|
|
491
|
+
"Host child-node renderer. { renderNode(node) -> ReactElement }. Backs WidgetTree / useChildRenderer; lets a container widget (Tabs, Card, …) render arbitrary author-authored child nodes without importing the host renderer. The closure pre-binds breakpoint + page ctx + parent so the widget passes only the child node.",
|
|
492
|
+
required: true,
|
|
493
|
+
fields: { renderNode: "function" },
|
|
494
|
+
},
|
|
410
495
|
events: {
|
|
411
496
|
description: "{ emit(name, payload) }.",
|
|
412
497
|
required: true,
|
|
@@ -418,6 +503,38 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
418
503
|
required: true,
|
|
419
504
|
fields: { requestPayment: "function", getPayment: "function" },
|
|
420
505
|
},
|
|
506
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration facade backing
|
|
507
|
+
// useUsers(). Reads gated by `users.read:*`; mutations by `users.write:*`.
|
|
508
|
+
// The host signs an `X-Widget-Scopes` header against JWT_SECRET so an
|
|
509
|
+
// APP_USER cannot forge scope claims, and the request is additionally
|
|
510
|
+
// gated by a SystemAcl `users.read` / `users.write` capability grant.
|
|
511
|
+
users: {
|
|
512
|
+
description:
|
|
513
|
+
"AppUser administration. { listUsers(query?) -> Promise<User[]>, invite({ email, name, groupIds? }) -> Promise<Invite>, deactivate(userId) -> Promise<User>, reactivate(userId) -> Promise<User>, remove(userId) -> Promise<void> }. Backs useUsers(); reads require users.read:*, mutations require users.write:*.",
|
|
514
|
+
required: true,
|
|
515
|
+
fields: {
|
|
516
|
+
listUsers: "function",
|
|
517
|
+
invite: "function",
|
|
518
|
+
deactivate: "function",
|
|
519
|
+
reactivate: "function",
|
|
520
|
+
remove: "function",
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
|
|
524
|
+
// backing useGroups(). Reads gated by `groups.read:*`; mutations by
|
|
525
|
+
// `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
|
|
526
|
+
groups: {
|
|
527
|
+
description:
|
|
528
|
+
"AppUserGroup administration. { listGroups(query?) -> Promise<Group[]>, create({ name }) -> Promise<Group>, remove(groupId) -> Promise<void>, addMember(groupId, userId) -> Promise<void>, removeMember(groupId, userId) -> Promise<void> }. Backs useGroups(); reads require groups.read:*, mutations require groups.write:*.",
|
|
529
|
+
required: true,
|
|
530
|
+
fields: {
|
|
531
|
+
listGroups: "function",
|
|
532
|
+
create: "function",
|
|
533
|
+
remove: "function",
|
|
534
|
+
addMember: "function",
|
|
535
|
+
removeMember: "function",
|
|
536
|
+
},
|
|
537
|
+
},
|
|
421
538
|
i18n: {
|
|
422
539
|
description: "{ t(key, fallback?), locale }.",
|
|
423
540
|
required: true,
|
|
@@ -510,7 +627,7 @@ function deepFreeze(value) {
|
|
|
510
627
|
}
|
|
511
628
|
|
|
512
629
|
const CONTRACT = deepFreeze({
|
|
513
|
-
version: "1.
|
|
630
|
+
version: "1.4.0",
|
|
514
631
|
hooks: HOOKS,
|
|
515
632
|
primitives: PRIMITIVES,
|
|
516
633
|
manifestSchema: MANIFEST_SCHEMA,
|
package/dist/contract.js
CHANGED
|
@@ -68,6 +68,15 @@ const HOOKS = [
|
|
|
68
68
|
requiredContextSlice: ["user"],
|
|
69
69
|
scopes: null,
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
name: "useChildRenderer",
|
|
73
|
+
signature: "useChildRenderer()",
|
|
74
|
+
returnShape: {
|
|
75
|
+
renderNode: "(node) => ReactElement",
|
|
76
|
+
},
|
|
77
|
+
requiredContextSlice: ["renderer.renderNode"],
|
|
78
|
+
scopes: null,
|
|
79
|
+
},
|
|
71
80
|
{
|
|
72
81
|
name: "useNavigation",
|
|
73
82
|
signature: "useNavigation()",
|
|
@@ -162,6 +171,76 @@ const HOOKS = [
|
|
|
162
171
|
requiredContextSlice: ["payments.requestPayment"],
|
|
163
172
|
scopes: ["payments.charge:appUser"],
|
|
164
173
|
},
|
|
174
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
175
|
+
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
176
|
+
// Reads need `users.read:*` scope; mutations additionally need
|
|
177
|
+
// `users.write:*`. The `invite` call accepts `{ email, name, groupIds? }`
|
|
178
|
+
// and returns the resulting AppUserInvite row (the email is sent by the
|
|
179
|
+
// host). Mutating users from a widget requires the corresponding
|
|
180
|
+
// SystemAcl `users.write` capability grant in the tenant; widgets that
|
|
181
|
+
// only call read methods need only `users.read:*`.
|
|
182
|
+
{
|
|
183
|
+
name: "useUsers",
|
|
184
|
+
signature: "useUsers(query?)",
|
|
185
|
+
description:
|
|
186
|
+
"AppUser administration. Returns { users, loading, error, refetch, invite, " +
|
|
187
|
+
"deactivate, reactivate, remove }. Reads need users.read:* scope; mutations " +
|
|
188
|
+
"need users.write:*. The `invite` call accepts { email, name, groupIds? } " +
|
|
189
|
+
"and returns the resulting AppUserInvite row (the email is sent by the host).",
|
|
190
|
+
returnShape: {
|
|
191
|
+
users: "Array<{ id, name, email?, role, isActive }>",
|
|
192
|
+
loading: "boolean",
|
|
193
|
+
error: "DirectoryError | null",
|
|
194
|
+
refetch: "() => Promise<void>",
|
|
195
|
+
invite:
|
|
196
|
+
"({ email, name, groupIds? }) => Promise<Invite> // rejects with DirectoryError",
|
|
197
|
+
deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
|
|
198
|
+
reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
|
|
199
|
+
remove: "(userId) => Promise<void> // rejects with DirectoryError",
|
|
200
|
+
},
|
|
201
|
+
requiredContextSlice: [
|
|
202
|
+
"users.listUsers",
|
|
203
|
+
"users.invite",
|
|
204
|
+
"users.deactivate",
|
|
205
|
+
"users.reactivate",
|
|
206
|
+
"users.remove",
|
|
207
|
+
],
|
|
208
|
+
scopes: ["users.read:*"],
|
|
209
|
+
},
|
|
210
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
|
|
211
|
+
// `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
|
|
212
|
+
// Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
|
|
213
|
+
// member requires the corresponding `users.write` capability since it
|
|
214
|
+
// affects the user's effective access.
|
|
215
|
+
{
|
|
216
|
+
name: "useGroups",
|
|
217
|
+
signature: "useGroups(query?)",
|
|
218
|
+
description:
|
|
219
|
+
"AppUserGroup administration. Returns { groups, loading, error, refetch, " +
|
|
220
|
+
"create, remove, addMember, removeMember }. Reads need groups.read:*; " +
|
|
221
|
+
"mutations need groups.write:*.",
|
|
222
|
+
returnShape: {
|
|
223
|
+
groups: "Array<{ id, name, memberCount }>",
|
|
224
|
+
loading: "boolean",
|
|
225
|
+
error: "DirectoryError | null",
|
|
226
|
+
refetch: "() => Promise<void>",
|
|
227
|
+
create:
|
|
228
|
+
"({ name }) => Promise<Group> // rejects with DirectoryError",
|
|
229
|
+
remove: "(groupId) => Promise<void> // rejects with DirectoryError",
|
|
230
|
+
addMember:
|
|
231
|
+
"(groupId, userId) => Promise<void> // rejects with DirectoryError",
|
|
232
|
+
removeMember:
|
|
233
|
+
"(groupId, userId) => Promise<void> // rejects with DirectoryError",
|
|
234
|
+
},
|
|
235
|
+
requiredContextSlice: [
|
|
236
|
+
"groups.listGroups",
|
|
237
|
+
"groups.create",
|
|
238
|
+
"groups.remove",
|
|
239
|
+
"groups.addMember",
|
|
240
|
+
"groups.removeMember",
|
|
241
|
+
],
|
|
242
|
+
scopes: ["groups.read:*"],
|
|
243
|
+
},
|
|
165
244
|
];
|
|
166
245
|
|
|
167
246
|
// REQ-WSDK-RN-WEB: see contract.cjs for the source-of-truth comment.
|
|
@@ -401,6 +480,12 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
401
480
|
required: true,
|
|
402
481
|
fields: { get: "function" },
|
|
403
482
|
},
|
|
483
|
+
renderer: {
|
|
484
|
+
description:
|
|
485
|
+
"Host child-node renderer. { renderNode(node) -> ReactElement }. Backs WidgetTree / useChildRenderer; lets a container widget (Tabs, Card, …) render arbitrary author-authored child nodes without importing the host renderer. The closure pre-binds breakpoint + page ctx + parent so the widget passes only the child node.",
|
|
486
|
+
required: true,
|
|
487
|
+
fields: { renderNode: "function" },
|
|
488
|
+
},
|
|
404
489
|
events: {
|
|
405
490
|
description: "{ emit(name, payload) }.",
|
|
406
491
|
required: true,
|
|
@@ -412,6 +497,38 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
412
497
|
required: true,
|
|
413
498
|
fields: { requestPayment: "function", getPayment: "function" },
|
|
414
499
|
},
|
|
500
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration facade backing
|
|
501
|
+
// useUsers(). Reads gated by `users.read:*`; mutations by `users.write:*`.
|
|
502
|
+
// The host signs an `X-Widget-Scopes` header against JWT_SECRET so an
|
|
503
|
+
// APP_USER cannot forge scope claims, and the request is additionally
|
|
504
|
+
// gated by a SystemAcl `users.read` / `users.write` capability grant.
|
|
505
|
+
users: {
|
|
506
|
+
description:
|
|
507
|
+
"AppUser administration. { listUsers(query?) -> Promise<User[]>, invite({ email, name, groupIds? }) -> Promise<Invite>, deactivate(userId) -> Promise<User>, reactivate(userId) -> Promise<User>, remove(userId) -> Promise<void> }. Backs useUsers(); reads require users.read:*, mutations require users.write:*.",
|
|
508
|
+
required: true,
|
|
509
|
+
fields: {
|
|
510
|
+
listUsers: "function",
|
|
511
|
+
invite: "function",
|
|
512
|
+
deactivate: "function",
|
|
513
|
+
reactivate: "function",
|
|
514
|
+
remove: "function",
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
|
|
518
|
+
// backing useGroups(). Reads gated by `groups.read:*`; mutations by
|
|
519
|
+
// `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
|
|
520
|
+
groups: {
|
|
521
|
+
description:
|
|
522
|
+
"AppUserGroup administration. { listGroups(query?) -> Promise<Group[]>, create({ name }) -> Promise<Group>, remove(groupId) -> Promise<void>, addMember(groupId, userId) -> Promise<void>, removeMember(groupId, userId) -> Promise<void> }. Backs useGroups(); reads require groups.read:*, mutations require groups.write:*.",
|
|
523
|
+
required: true,
|
|
524
|
+
fields: {
|
|
525
|
+
listGroups: "function",
|
|
526
|
+
create: "function",
|
|
527
|
+
remove: "function",
|
|
528
|
+
addMember: "function",
|
|
529
|
+
removeMember: "function",
|
|
530
|
+
},
|
|
531
|
+
},
|
|
415
532
|
i18n: {
|
|
416
533
|
description: "{ t(key, fallback?), locale }.",
|
|
417
534
|
required: true,
|
|
@@ -502,7 +619,7 @@ function deepFreeze(value) {
|
|
|
502
619
|
}
|
|
503
620
|
|
|
504
621
|
const CONTRACT = deepFreeze({
|
|
505
|
-
version: "1.
|
|
622
|
+
version: "1.4.0",
|
|
506
623
|
hooks: HOOKS,
|
|
507
624
|
primitives: PRIMITIVES,
|
|
508
625
|
manifestSchema: MANIFEST_SCHEMA,
|
package/dist/hooks.js
CHANGED
|
@@ -517,6 +517,46 @@ export function useUser() {
|
|
|
517
517
|
return ctx.user;
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Returns the host's child-node renderer:
|
|
522
|
+
* { renderNode(node) }.
|
|
523
|
+
*
|
|
524
|
+
* Widgets like Tabs / Card / a custom container call `renderNode(child)`
|
|
525
|
+
* to render an author-authored page-tree node nested inside themselves.
|
|
526
|
+
* The renderer closes over the surrounding render context (breakpoint,
|
|
527
|
+
* page ctx, parent) that the host already knows, so the widget doesn't
|
|
528
|
+
* need to plumb any of that through.
|
|
529
|
+
*
|
|
530
|
+
* Prefer the `WidgetTree` component for the common case
|
|
531
|
+
* (`<WidgetTree node={child} />`); reach for the hook when you need to
|
|
532
|
+
* branch on the node's shape before rendering.
|
|
533
|
+
*/
|
|
534
|
+
export function useChildRenderer() {
|
|
535
|
+
const ctx = useWidgetContextOrThrow("useChildRenderer");
|
|
536
|
+
if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
|
|
537
|
+
throw new Error(
|
|
538
|
+
"useChildRenderer: host did not inject a child-node renderer",
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
return ctx.renderer;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Renders an author-authored page-tree node through the host's child
|
|
546
|
+
* renderer. The widget hands off a node (or null) and gets back a React
|
|
547
|
+
* element rendered with the same dispatch the top-level page uses, so
|
|
548
|
+
* Tabs / Card / custom container widgets can host arbitrary child
|
|
549
|
+
* widgets without importing the host.
|
|
550
|
+
*/
|
|
551
|
+
export function WidgetTree({ node }) {
|
|
552
|
+
const ctx = useWidgetContextOrThrow("WidgetTree");
|
|
553
|
+
if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
if (!node) return null;
|
|
557
|
+
return ctx.renderer.renderNode(node);
|
|
558
|
+
}
|
|
559
|
+
|
|
520
560
|
/**
|
|
521
561
|
* Returns the host-provided navigation surface:
|
|
522
562
|
* `{ goTo, goBack, push, replace, back, currentRoute }`.
|
|
@@ -647,6 +687,236 @@ export function usePayments() {
|
|
|
647
687
|
* the key is missing. The host's i18n.t may or may not honour the two-arg
|
|
648
688
|
* form; we degrade gracefully either way.
|
|
649
689
|
*/
|
|
690
|
+
/**
|
|
691
|
+
* REQ-USERMGMT / REQ-ACL-SYS M3 — structured error thrown by `useUsers` /
|
|
692
|
+
* `useGroups` callbacks. Carries a stable `code` so widgets can branch
|
|
693
|
+
* without parsing message strings.
|
|
694
|
+
*
|
|
695
|
+
* `code` is one of:
|
|
696
|
+
* - "FORBIDDEN" — 403 from the host (capability or scope missing).
|
|
697
|
+
* - "VALIDATION" — 400 / 422 (bad email, duplicate, etc.).
|
|
698
|
+
* - "NOT_FOUND" — 404 (group / user does not exist or cross-tenant).
|
|
699
|
+
* - "INVITE_ONLY" — 409 from accept-invite-style flows where the tenant
|
|
700
|
+
* is invite-only and the email is not on the list.
|
|
701
|
+
* - "INTERNAL" — anything else (network, 5xx).
|
|
702
|
+
*/
|
|
703
|
+
export class DirectoryError extends Error {
|
|
704
|
+
constructor(code, message, opts) {
|
|
705
|
+
super(message);
|
|
706
|
+
this.name = "DirectoryError";
|
|
707
|
+
this.code = code;
|
|
708
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function toDirectoryError(err) {
|
|
713
|
+
if (err instanceof DirectoryError) return err;
|
|
714
|
+
const status =
|
|
715
|
+
err && err.response && typeof err.response.status === "number"
|
|
716
|
+
? err.response.status
|
|
717
|
+
: null;
|
|
718
|
+
const bodyCode =
|
|
719
|
+
err && err.response && err.response.data && err.response.data.code;
|
|
720
|
+
const bodyMessage =
|
|
721
|
+
err && err.response && err.response.data && err.response.data.error;
|
|
722
|
+
let code = "INTERNAL";
|
|
723
|
+
if (bodyCode === "INVITE_ONLY") code = "INVITE_ONLY";
|
|
724
|
+
else if (status === 403) code = "FORBIDDEN";
|
|
725
|
+
else if (status === 404) code = "NOT_FOUND";
|
|
726
|
+
else if (status === 400 || status === 422) code = "VALIDATION";
|
|
727
|
+
else if (status === 409) {
|
|
728
|
+
// 409 is invite-only on the invite endpoints; treat the rest as
|
|
729
|
+
// validation conflicts (duplicate email, etc.).
|
|
730
|
+
code = bodyCode === "INVITE_ONLY" ? "INVITE_ONLY" : "VALIDATION";
|
|
731
|
+
}
|
|
732
|
+
const message =
|
|
733
|
+
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
734
|
+
(err && typeof err.message === "string"
|
|
735
|
+
? err.message
|
|
736
|
+
: "Directory call failed");
|
|
737
|
+
return new DirectoryError(code, message, { cause: err });
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
|
|
742
|
+
*
|
|
743
|
+
* Returns `{ users, loading, error, refetch, invite, deactivate,
|
|
744
|
+
* reactivate, remove }`. The list refetches whenever
|
|
745
|
+
* `JSON.stringify(query)` changes; the imperative methods reject with a
|
|
746
|
+
* `DirectoryError`. Reads require the `users.read:*` scope; mutations
|
|
747
|
+
* additionally require `users.write:*`. The host's signed
|
|
748
|
+
* `X-Widget-Scopes` header + a tenant-scoped SystemAcl `users.read` /
|
|
749
|
+
* `users.write` capability grant gate the underlying endpoint
|
|
750
|
+
* (REQ-ACL-SYS M3 §4.3) — a widget that declares the scopes but whose
|
|
751
|
+
* caller does not hold the grant gets a `FORBIDDEN`.
|
|
752
|
+
*/
|
|
753
|
+
export function useUsers(query) {
|
|
754
|
+
const ctx = useWidgetContextOrThrow("useUsers");
|
|
755
|
+
if (!ctx.users || typeof ctx.users.listUsers !== "function") {
|
|
756
|
+
throw new Error("useUsers: host did not inject a users client");
|
|
757
|
+
}
|
|
758
|
+
const [users, setUsers] = useState([]);
|
|
759
|
+
const [loading, setLoading] = useState(true);
|
|
760
|
+
const [error, setError] = useState(null);
|
|
761
|
+
|
|
762
|
+
const queryRef = useRef(query);
|
|
763
|
+
const usersRef = useRef(ctx.users);
|
|
764
|
+
queryRef.current = query;
|
|
765
|
+
usersRef.current = ctx.users;
|
|
766
|
+
|
|
767
|
+
const runRef = useRef(0);
|
|
768
|
+
|
|
769
|
+
const doFetch = useCallback(async () => {
|
|
770
|
+
const myRun = ++runRef.current;
|
|
771
|
+
setLoading(true);
|
|
772
|
+
setError(null);
|
|
773
|
+
try {
|
|
774
|
+
const rows = await usersRef.current.listUsers(queryRef.current);
|
|
775
|
+
if (runRef.current !== myRun) return;
|
|
776
|
+
setUsers(Array.isArray(rows) ? rows : []);
|
|
777
|
+
setLoading(false);
|
|
778
|
+
} catch (err) {
|
|
779
|
+
if (runRef.current !== myRun) return;
|
|
780
|
+
setError(toDirectoryError(err));
|
|
781
|
+
setLoading(false);
|
|
782
|
+
}
|
|
783
|
+
}, []);
|
|
784
|
+
|
|
785
|
+
const queryKey = (() => {
|
|
786
|
+
try {
|
|
787
|
+
return JSON.stringify(query);
|
|
788
|
+
} catch (_e) {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
})();
|
|
792
|
+
useEffect(() => {
|
|
793
|
+
doFetch();
|
|
794
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
795
|
+
}, [queryKey]);
|
|
796
|
+
|
|
797
|
+
const refetch = useCallback(async () => {
|
|
798
|
+
await doFetch();
|
|
799
|
+
}, [doFetch]);
|
|
800
|
+
|
|
801
|
+
const invite = useCallback(async (args) => {
|
|
802
|
+
try {
|
|
803
|
+
return await usersRef.current.invite(args);
|
|
804
|
+
} catch (err) {
|
|
805
|
+
throw toDirectoryError(err);
|
|
806
|
+
}
|
|
807
|
+
}, []);
|
|
808
|
+
const deactivate = useCallback(async (userId) => {
|
|
809
|
+
try {
|
|
810
|
+
return await usersRef.current.deactivate(userId);
|
|
811
|
+
} catch (err) {
|
|
812
|
+
throw toDirectoryError(err);
|
|
813
|
+
}
|
|
814
|
+
}, []);
|
|
815
|
+
const reactivate = useCallback(async (userId) => {
|
|
816
|
+
try {
|
|
817
|
+
return await usersRef.current.reactivate(userId);
|
|
818
|
+
} catch (err) {
|
|
819
|
+
throw toDirectoryError(err);
|
|
820
|
+
}
|
|
821
|
+
}, []);
|
|
822
|
+
const remove = useCallback(async (userId) => {
|
|
823
|
+
try {
|
|
824
|
+
return await usersRef.current.remove(userId);
|
|
825
|
+
} catch (err) {
|
|
826
|
+
throw toDirectoryError(err);
|
|
827
|
+
}
|
|
828
|
+
}, []);
|
|
829
|
+
|
|
830
|
+
return { users, loading, error, refetch, invite, deactivate, reactivate, remove };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration hook.
|
|
835
|
+
*
|
|
836
|
+
* Returns `{ groups, loading, error, refetch, create, remove, addMember,
|
|
837
|
+
* removeMember }`. Reads require `groups.read:*`; mutations require
|
|
838
|
+
* `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as `useUsers`.
|
|
839
|
+
*/
|
|
840
|
+
export function useGroups(query) {
|
|
841
|
+
const ctx = useWidgetContextOrThrow("useGroups");
|
|
842
|
+
if (!ctx.groups || typeof ctx.groups.listGroups !== "function") {
|
|
843
|
+
throw new Error("useGroups: host did not inject a groups client");
|
|
844
|
+
}
|
|
845
|
+
const [groups, setGroups] = useState([]);
|
|
846
|
+
const [loading, setLoading] = useState(true);
|
|
847
|
+
const [error, setError] = useState(null);
|
|
848
|
+
|
|
849
|
+
const queryRef = useRef(query);
|
|
850
|
+
const groupsRef = useRef(ctx.groups);
|
|
851
|
+
queryRef.current = query;
|
|
852
|
+
groupsRef.current = ctx.groups;
|
|
853
|
+
|
|
854
|
+
const runRef = useRef(0);
|
|
855
|
+
|
|
856
|
+
const doFetch = useCallback(async () => {
|
|
857
|
+
const myRun = ++runRef.current;
|
|
858
|
+
setLoading(true);
|
|
859
|
+
setError(null);
|
|
860
|
+
try {
|
|
861
|
+
const rows = await groupsRef.current.listGroups(queryRef.current);
|
|
862
|
+
if (runRef.current !== myRun) return;
|
|
863
|
+
setGroups(Array.isArray(rows) ? rows : []);
|
|
864
|
+
setLoading(false);
|
|
865
|
+
} catch (err) {
|
|
866
|
+
if (runRef.current !== myRun) return;
|
|
867
|
+
setError(toDirectoryError(err));
|
|
868
|
+
setLoading(false);
|
|
869
|
+
}
|
|
870
|
+
}, []);
|
|
871
|
+
|
|
872
|
+
const queryKey = (() => {
|
|
873
|
+
try {
|
|
874
|
+
return JSON.stringify(query);
|
|
875
|
+
} catch (_e) {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
})();
|
|
879
|
+
useEffect(() => {
|
|
880
|
+
doFetch();
|
|
881
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
882
|
+
}, [queryKey]);
|
|
883
|
+
|
|
884
|
+
const refetch = useCallback(async () => {
|
|
885
|
+
await doFetch();
|
|
886
|
+
}, [doFetch]);
|
|
887
|
+
|
|
888
|
+
const create = useCallback(async (args) => {
|
|
889
|
+
try {
|
|
890
|
+
return await groupsRef.current.create(args);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
throw toDirectoryError(err);
|
|
893
|
+
}
|
|
894
|
+
}, []);
|
|
895
|
+
const remove = useCallback(async (groupId) => {
|
|
896
|
+
try {
|
|
897
|
+
return await groupsRef.current.remove(groupId);
|
|
898
|
+
} catch (err) {
|
|
899
|
+
throw toDirectoryError(err);
|
|
900
|
+
}
|
|
901
|
+
}, []);
|
|
902
|
+
const addMember = useCallback(async (groupId, userId) => {
|
|
903
|
+
try {
|
|
904
|
+
return await groupsRef.current.addMember(groupId, userId);
|
|
905
|
+
} catch (err) {
|
|
906
|
+
throw toDirectoryError(err);
|
|
907
|
+
}
|
|
908
|
+
}, []);
|
|
909
|
+
const removeMember = useCallback(async (groupId, userId) => {
|
|
910
|
+
try {
|
|
911
|
+
return await groupsRef.current.removeMember(groupId, userId);
|
|
912
|
+
} catch (err) {
|
|
913
|
+
throw toDirectoryError(err);
|
|
914
|
+
}
|
|
915
|
+
}, []);
|
|
916
|
+
|
|
917
|
+
return { groups, loading, error, refetch, create, remove, addMember, removeMember };
|
|
918
|
+
}
|
|
919
|
+
|
|
650
920
|
export function useI18n() {
|
|
651
921
|
const ctx = useWidgetContextOrThrow("useI18n");
|
|
652
922
|
const i18n = ctx.i18n || {};
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,9 @@ export type WidgetPropertyType =
|
|
|
28
28
|
| "tableRef"
|
|
29
29
|
| "columnRef"
|
|
30
30
|
| "recordBinding"
|
|
31
|
+
// REQ-USERMGMT M4 / §4.8: Group picker that emits a bare
|
|
32
|
+
// AppUserGroup UUID into the page JSON.
|
|
33
|
+
| "groupRef"
|
|
31
34
|
| "expression"
|
|
32
35
|
| "eventBinding"
|
|
33
36
|
| "object"
|
|
@@ -382,6 +385,22 @@ export function useDatastoreRecord(
|
|
|
382
385
|
* refetch }`. The `url` is an absolute URL composed against the host's API
|
|
383
386
|
* base; safe to pass straight to `<Image source>`.
|
|
384
387
|
*/
|
|
388
|
+
/**
|
|
389
|
+
* The host's child-node renderer surface. `renderNode(node)` returns a
|
|
390
|
+
* React element rendered with the same dispatch the top-level page uses;
|
|
391
|
+
* the closure pre-binds breakpoint / page ctx / parent.
|
|
392
|
+
*/
|
|
393
|
+
export function useChildRenderer(): {
|
|
394
|
+
renderNode(node: unknown): unknown;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Renders an author-authored page-tree node through the host's child
|
|
399
|
+
* renderer. Prefer this over `useChildRenderer()` for the common case
|
|
400
|
+
* (`<WidgetTree node={child} />`).
|
|
401
|
+
*/
|
|
402
|
+
export const WidgetTree: (props: { node: unknown }) => unknown;
|
|
403
|
+
|
|
385
404
|
export function useFile(fileId: string | null | undefined): {
|
|
386
405
|
url: string | null;
|
|
387
406
|
file: {
|
|
@@ -464,6 +483,109 @@ export class DatastoreError extends Error {
|
|
|
464
483
|
);
|
|
465
484
|
}
|
|
466
485
|
|
|
486
|
+
/**
|
|
487
|
+
* REQ-USERMGMT / REQ-ACL-SYS M3 — error class thrown by `useUsers` and
|
|
488
|
+
* `useGroups` callbacks. The `code` is a stable categorisation widgets
|
|
489
|
+
* can branch on.
|
|
490
|
+
*/
|
|
491
|
+
export class DirectoryError extends Error {
|
|
492
|
+
code:
|
|
493
|
+
| "FORBIDDEN"
|
|
494
|
+
| "VALIDATION"
|
|
495
|
+
| "NOT_FOUND"
|
|
496
|
+
| "INVITE_ONLY"
|
|
497
|
+
| "INTERNAL";
|
|
498
|
+
constructor(
|
|
499
|
+
code: DirectoryError["code"],
|
|
500
|
+
message: string,
|
|
501
|
+
opts?: { cause?: unknown },
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// --------------------------------------------------------------- useUsers
|
|
506
|
+
//
|
|
507
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
|
|
508
|
+
|
|
509
|
+
export interface AppUserRow {
|
|
510
|
+
id: string;
|
|
511
|
+
name: string;
|
|
512
|
+
email?: string;
|
|
513
|
+
role: "USER" | "INTEGRATION";
|
|
514
|
+
isActive: boolean;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export interface UsersQuery {
|
|
518
|
+
q?: string;
|
|
519
|
+
role?: "USER" | "INTEGRATION" | "ALL";
|
|
520
|
+
isActive?: boolean;
|
|
521
|
+
limit?: number;
|
|
522
|
+
offset?: number;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export interface InviteArgs {
|
|
526
|
+
email: string;
|
|
527
|
+
name?: string;
|
|
528
|
+
groupIds?: string[];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export interface AppUserInviteRow {
|
|
532
|
+
id: string;
|
|
533
|
+
email: string;
|
|
534
|
+
status: string;
|
|
535
|
+
invitedAt?: string;
|
|
536
|
+
expiresAt?: string;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export interface UsersApi {
|
|
540
|
+
users: AppUserRow[];
|
|
541
|
+
loading: boolean;
|
|
542
|
+
error: DirectoryError | null;
|
|
543
|
+
refetch(): Promise<void>;
|
|
544
|
+
invite(args: InviteArgs): Promise<AppUserInviteRow>;
|
|
545
|
+
deactivate(userId: string): Promise<AppUserRow>;
|
|
546
|
+
reactivate(userId: string): Promise<AppUserRow>;
|
|
547
|
+
remove(userId: string): Promise<void>;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* AppUser administration. Reads require the `users.read:*` scope in the
|
|
552
|
+
* manifest; mutations additionally require `users.write:*`. Widgets that
|
|
553
|
+
* declare the scopes but whose calling APP_USER lacks the corresponding
|
|
554
|
+
* SystemAcl `users.*` capability grant get a `FORBIDDEN` DirectoryError.
|
|
555
|
+
*/
|
|
556
|
+
export function useUsers(query?: UsersQuery): UsersApi;
|
|
557
|
+
|
|
558
|
+
// --------------------------------------------------------------- useGroups
|
|
559
|
+
|
|
560
|
+
export interface AppUserGroupRow {
|
|
561
|
+
id: string;
|
|
562
|
+
name: string;
|
|
563
|
+
memberCount?: number;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export interface GroupsQuery {
|
|
567
|
+
q?: string;
|
|
568
|
+
limit?: number;
|
|
569
|
+
offset?: number;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export interface GroupsApi {
|
|
573
|
+
groups: AppUserGroupRow[];
|
|
574
|
+
loading: boolean;
|
|
575
|
+
error: DirectoryError | null;
|
|
576
|
+
refetch(): Promise<void>;
|
|
577
|
+
create(args: { name: string }): Promise<AppUserGroupRow>;
|
|
578
|
+
remove(groupId: string): Promise<void>;
|
|
579
|
+
addMember(groupId: string, userId: string): Promise<void>;
|
|
580
|
+
removeMember(groupId: string, userId: string): Promise<void>;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* AppUserGroup administration. Reads require `groups.read:*`; mutations
|
|
585
|
+
* require `groups.write:*`. Same SystemAcl gating as `useUsers`.
|
|
586
|
+
*/
|
|
587
|
+
export function useGroups(query?: GroupsQuery): GroupsApi;
|
|
588
|
+
|
|
467
589
|
export function WidgetContextProvider(props: {
|
|
468
590
|
value: WidgetContext;
|
|
469
591
|
children?: ReactNode;
|
package/dist/index.js
CHANGED
|
@@ -9,17 +9,22 @@ export {
|
|
|
9
9
|
WidgetContextProvider,
|
|
10
10
|
DatastoreError,
|
|
11
11
|
PaymentError,
|
|
12
|
+
DirectoryError,
|
|
12
13
|
useDatastoreQuery,
|
|
13
14
|
useDatastoreRecord,
|
|
14
15
|
useFile,
|
|
15
16
|
useDatastoreMutation,
|
|
16
17
|
useDirectory,
|
|
18
|
+
useUsers,
|
|
19
|
+
useGroups,
|
|
17
20
|
useWidgetEvent,
|
|
18
21
|
usePayments,
|
|
19
22
|
useTheme,
|
|
20
23
|
useI18n,
|
|
21
24
|
useUser,
|
|
22
25
|
useNavigation,
|
|
26
|
+
useChildRenderer,
|
|
27
|
+
WidgetTree,
|
|
23
28
|
} from "./hooks.js";
|
|
24
29
|
export {
|
|
25
30
|
Text,
|
package/dist/index.native.js
CHANGED
|
@@ -9,17 +9,22 @@ export {
|
|
|
9
9
|
WidgetContextProvider,
|
|
10
10
|
DatastoreError,
|
|
11
11
|
PaymentError,
|
|
12
|
+
DirectoryError,
|
|
12
13
|
useDatastoreQuery,
|
|
13
14
|
useDatastoreRecord,
|
|
14
15
|
useFile,
|
|
15
16
|
useDatastoreMutation,
|
|
16
17
|
useDirectory,
|
|
18
|
+
useUsers,
|
|
19
|
+
useGroups,
|
|
17
20
|
useWidgetEvent,
|
|
18
21
|
usePayments,
|
|
19
22
|
useTheme,
|
|
20
23
|
useI18n,
|
|
21
24
|
useUser,
|
|
22
25
|
useNavigation,
|
|
26
|
+
useChildRenderer,
|
|
27
|
+
WidgetTree,
|
|
23
28
|
} from "./hooks.js";
|
|
24
29
|
export {
|
|
25
30
|
Text,
|
package/dist/linter.cjs
CHANGED
|
@@ -74,7 +74,101 @@ function bannedIdentifiers() {
|
|
|
74
74
|
return CONTRACT.bannedApis.map((b) => b.identifier);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation. See
|
|
78
|
+
// linter.js for the rationale comment. The two files must stay in
|
|
79
|
+
// lockstep (the contract test asserts behaviour-equivalence).
|
|
80
|
+
const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate", "remove"];
|
|
81
|
+
const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
|
|
82
|
+
const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
|
|
83
|
+
|
|
84
|
+
function _scopeRules(source, manifest) {
|
|
85
|
+
const findings = [];
|
|
86
|
+
if (!manifest || typeof manifest !== "object") return findings;
|
|
87
|
+
const declared = new Set(
|
|
88
|
+
Array.isArray(manifest.requestedScopes)
|
|
89
|
+
? manifest.requestedScopes.filter((s) => typeof s === "string")
|
|
90
|
+
: [],
|
|
91
|
+
);
|
|
92
|
+
const usesUsersHook = /\buseUsers\s*\(/.test(source);
|
|
93
|
+
const usesGroupsHook = /\buseGroups\s*\(/.test(source);
|
|
94
|
+
|
|
95
|
+
if (usesUsersHook) {
|
|
96
|
+
const reads = declared.has("users.read:*") || declared.has("users.read");
|
|
97
|
+
if (!reads) {
|
|
98
|
+
findings.push({
|
|
99
|
+
rule: "scope-required-for-useUsers",
|
|
100
|
+
label:
|
|
101
|
+
"useUsers() requires `users.read:*` in manifest.requestedScopes",
|
|
102
|
+
line: 0,
|
|
103
|
+
snippet: "",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (usesGroupsHook) {
|
|
108
|
+
const reads = declared.has("groups.read:*") || declared.has("groups.read");
|
|
109
|
+
if (!reads) {
|
|
110
|
+
findings.push({
|
|
111
|
+
rule: "scope-required-for-useGroups",
|
|
112
|
+
label:
|
|
113
|
+
"useGroups() requires `groups.read:*` in manifest.requestedScopes",
|
|
114
|
+
line: 0,
|
|
115
|
+
snippet: "",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lines = source.split(/\r?\n/);
|
|
121
|
+
let firedUsersWrite = false;
|
|
122
|
+
let firedGroupsWrite = false;
|
|
123
|
+
for (let i = 0; i < lines.length; i++) {
|
|
124
|
+
const line = lines[i];
|
|
125
|
+
if (SKIP_COMMENT.test(line)) continue;
|
|
126
|
+
if (usesUsersHook && !firedUsersWrite) {
|
|
127
|
+
for (const m of USER_MUTATION_METHODS) {
|
|
128
|
+
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
129
|
+
if (re.test(line)) {
|
|
130
|
+
if (
|
|
131
|
+
!declared.has("users.write:*") &&
|
|
132
|
+
!declared.has("users.write")
|
|
133
|
+
) {
|
|
134
|
+
findings.push({
|
|
135
|
+
rule: "scope-required-for-user-mutation",
|
|
136
|
+
label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
|
|
137
|
+
line: i + 1,
|
|
138
|
+
snippet: line.trim().slice(0, 200),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
firedUsersWrite = true;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (usesGroupsHook && !firedGroupsWrite) {
|
|
147
|
+
for (const m of GROUP_MUTATION_METHODS) {
|
|
148
|
+
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
149
|
+
if (re.test(line)) {
|
|
150
|
+
if (m === "remove" && usesUsersHook) continue;
|
|
151
|
+
if (
|
|
152
|
+
!declared.has("groups.write:*") &&
|
|
153
|
+
!declared.has("groups.write")
|
|
154
|
+
) {
|
|
155
|
+
findings.push({
|
|
156
|
+
rule: "scope-required-for-group-mutation",
|
|
157
|
+
label: `useGroups().${m}() requires \`groups.write:*\` in manifest.requestedScopes`,
|
|
158
|
+
line: i + 1,
|
|
159
|
+
snippet: line.trim().slice(0, 200),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
firedGroupsWrite = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return findings;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function lintSource(source, options) {
|
|
78
172
|
if (typeof source !== "string") {
|
|
79
173
|
return {
|
|
80
174
|
ok: false,
|
|
@@ -98,6 +192,7 @@ function lintSource(source) {
|
|
|
98
192
|
}
|
|
99
193
|
}
|
|
100
194
|
}
|
|
195
|
+
findings.push(..._scopeRules(source, options && options.manifest));
|
|
101
196
|
return { ok: findings.length === 0, findings };
|
|
102
197
|
}
|
|
103
198
|
|
package/dist/linter.js
CHANGED
|
@@ -83,12 +83,127 @@ export function bannedIdentifiers() {
|
|
|
83
83
|
return CONTRACT.bannedApis.map((b) => b.identifier);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation.
|
|
87
|
+
//
|
|
88
|
+
// A widget that calls `useUsers().invite()` / `.deactivate()` /
|
|
89
|
+
// `.reactivate()` / `.remove()` MUST declare `users.write:*` in its
|
|
90
|
+
// manifest's `requestedScopes`; similarly `useGroups()` mutation methods
|
|
91
|
+
// require `groups.write:*`. The rule is enforced statically so a manifest
|
|
92
|
+
// that drifts from the source (e.g. an author forgot to add the scope
|
|
93
|
+
// after wiring up the mutation) is rejected at submission time.
|
|
94
|
+
//
|
|
95
|
+
// The check is pattern-based: scan the source for `.invite(`, `.deactivate(`
|
|
96
|
+
// etc. and only fire when the source ALSO contains `useUsers(` / `useGroups(`
|
|
97
|
+
// nearby (i.e. the destructured callee likely originated from the hook).
|
|
98
|
+
// False positives are accepted in exchange for keeping the linter
|
|
99
|
+
// AST-free; an author whose code happened to spell `.invite(` for an
|
|
100
|
+
// unrelated reason can opt out with a `// @appstudio-skip-scope-check`
|
|
101
|
+
// trailing comment on the offending line.
|
|
102
|
+
const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate", "remove"];
|
|
103
|
+
const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
|
|
104
|
+
const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
|
|
105
|
+
|
|
106
|
+
function _scopeRules(source, manifest) {
|
|
107
|
+
const findings = [];
|
|
108
|
+
if (!manifest || typeof manifest !== "object") return findings;
|
|
109
|
+
const declared = new Set(
|
|
110
|
+
Array.isArray(manifest.requestedScopes)
|
|
111
|
+
? manifest.requestedScopes.filter((s) => typeof s === "string")
|
|
112
|
+
: [],
|
|
113
|
+
);
|
|
114
|
+
const usesUsersHook = /\buseUsers\s*\(/.test(source);
|
|
115
|
+
const usesGroupsHook = /\buseGroups\s*\(/.test(source);
|
|
116
|
+
|
|
117
|
+
if (usesUsersHook) {
|
|
118
|
+
const reads = declared.has("users.read:*") || declared.has("users.read");
|
|
119
|
+
if (!reads) {
|
|
120
|
+
findings.push({
|
|
121
|
+
rule: "scope-required-for-useUsers",
|
|
122
|
+
label:
|
|
123
|
+
"useUsers() requires `users.read:*` in manifest.requestedScopes",
|
|
124
|
+
line: 0,
|
|
125
|
+
snippet: "",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (usesGroupsHook) {
|
|
130
|
+
const reads = declared.has("groups.read:*") || declared.has("groups.read");
|
|
131
|
+
if (!reads) {
|
|
132
|
+
findings.push({
|
|
133
|
+
rule: "scope-required-for-useGroups",
|
|
134
|
+
label:
|
|
135
|
+
"useGroups() requires `groups.read:*` in manifest.requestedScopes",
|
|
136
|
+
line: 0,
|
|
137
|
+
snippet: "",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lines = source.split(/\r?\n/);
|
|
143
|
+
let firedUsersWrite = false;
|
|
144
|
+
let firedGroupsWrite = false;
|
|
145
|
+
for (let i = 0; i < lines.length; i++) {
|
|
146
|
+
const line = lines[i];
|
|
147
|
+
if (SKIP_COMMENT.test(line)) continue;
|
|
148
|
+
if (usesUsersHook && !firedUsersWrite) {
|
|
149
|
+
for (const m of USER_MUTATION_METHODS) {
|
|
150
|
+
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
151
|
+
if (re.test(line)) {
|
|
152
|
+
if (
|
|
153
|
+
!declared.has("users.write:*") &&
|
|
154
|
+
!declared.has("users.write")
|
|
155
|
+
) {
|
|
156
|
+
findings.push({
|
|
157
|
+
rule: "scope-required-for-user-mutation",
|
|
158
|
+
label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
|
|
159
|
+
line: i + 1,
|
|
160
|
+
snippet: line.trim().slice(0, 200),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
firedUsersWrite = true;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (usesGroupsHook && !firedGroupsWrite) {
|
|
169
|
+
for (const m of GROUP_MUTATION_METHODS) {
|
|
170
|
+
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
171
|
+
if (re.test(line)) {
|
|
172
|
+
// `.remove(` is also a useUsers method; only blame groups
|
|
173
|
+
// when the source also uses the groups hook AND the source
|
|
174
|
+
// doesn't ALSO use the users hook (an ambiguous .remove(
|
|
175
|
+
// is best left to the user-mutation rule above).
|
|
176
|
+
if (m === "remove" && usesUsersHook) continue;
|
|
177
|
+
if (
|
|
178
|
+
!declared.has("groups.write:*") &&
|
|
179
|
+
!declared.has("groups.write")
|
|
180
|
+
) {
|
|
181
|
+
findings.push({
|
|
182
|
+
rule: "scope-required-for-group-mutation",
|
|
183
|
+
label: `useGroups().${m}() requires \`groups.write:*\` in manifest.requestedScopes`,
|
|
184
|
+
line: i + 1,
|
|
185
|
+
snippet: line.trim().slice(0, 200),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
firedGroupsWrite = true;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return findings;
|
|
195
|
+
}
|
|
196
|
+
|
|
86
197
|
/**
|
|
87
198
|
* Lint a JavaScript source string.
|
|
199
|
+
*
|
|
88
200
|
* @param {string} source
|
|
201
|
+
* @param {{ manifest?: { requestedScopes?: string[] } }} [options] — when a
|
|
202
|
+
* manifest is supplied, scope-aware rules also fire (see
|
|
203
|
+
* `scope-required-for-user-mutation`).
|
|
89
204
|
* @returns {{ ok: boolean, findings: Array<{ rule: string, label: string, line: number, snippet: string }> }}
|
|
90
205
|
*/
|
|
91
|
-
export function lintSource(source) {
|
|
206
|
+
export function lintSource(source, options) {
|
|
92
207
|
if (typeof source !== "string") {
|
|
93
208
|
return {
|
|
94
209
|
ok: false,
|
|
@@ -112,5 +227,9 @@ export function lintSource(source) {
|
|
|
112
227
|
}
|
|
113
228
|
}
|
|
114
229
|
}
|
|
230
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
|
|
231
|
+
// line-by-line scan so banned-identifier findings stay first in the
|
|
232
|
+
// output.
|
|
233
|
+
findings.push(..._scopeRules(source, options && options.manifest));
|
|
115
234
|
return { ok: findings.length === 0, findings };
|
|
116
235
|
}
|
package/dist/property-schema.js
CHANGED
|
@@ -6,6 +6,12 @@ const VALID_TYPES = new Set([
|
|
|
6
6
|
"color", "icon", "image",
|
|
7
7
|
"select", "multiselect",
|
|
8
8
|
"tableRef", "columnRef", "recordBinding",
|
|
9
|
+
// REQ-USERMGMT M4 / §4.8: `groupRef` is a Group picker that emits a bare
|
|
10
|
+
// AppUserGroup UUID into the page JSON. Renders via `GroupSelector` in
|
|
11
|
+
// the Studio Properties Panel. REQ-GEN-07 compliance: no typed UUIDs —
|
|
12
|
+
// value is the raw uuid string so the tenant-copy walker can remap it
|
|
13
|
+
// through `_remapJson` without per-prop hooks.
|
|
14
|
+
"groupRef",
|
|
9
15
|
"expression", "eventBinding",
|
|
10
16
|
"object", "array",
|
|
11
17
|
]);
|
|
@@ -87,6 +93,7 @@ function coerceLeaf(def, value, path, errors) {
|
|
|
87
93
|
case "tableRef":
|
|
88
94
|
case "columnRef":
|
|
89
95
|
case "recordBinding":
|
|
96
|
+
case "groupRef":
|
|
90
97
|
case "expression":
|
|
91
98
|
case "eventBinding":
|
|
92
99
|
if (typeof value !== "string") errors.push(`${path}: expected string`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "node scripts/build.js",
|
|
38
|
-
"test": "node --test src/__tests__/contract.test.js"
|
|
38
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/linter-users-scope.test.js"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
41
41
|
"node": ">=18"
|