@colixsystems/widget-sdk 0.18.0 → 0.21.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 +129 -29
- package/dist/contract.cjs +228 -113
- package/dist/contract.js +238 -122
- package/dist/hooks.js +781 -570
- package/dist/index.d.ts +257 -65
- package/dist/index.js +1 -0
- package/dist/index.native.js +1 -0
- package/dist/linter.cjs +56 -0
- package/dist/linter.js +57 -0
- package/dist/manifest.cjs +157 -2
- package/dist/manifest.js +157 -2
- package/package.json +2 -2
package/dist/hooks.js
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
// exported app) own the runtime semantics — this file is the SDK surface
|
|
9
9
|
// widgets call.
|
|
10
10
|
//
|
|
11
|
+
// The hooks below are GROUPED by the domain client they read, so the
|
|
12
|
+
// four-client mapping is obvious at a glance:
|
|
13
|
+
// - CORE — WidgetContext + non-data hooks (no domain client)
|
|
14
|
+
// - DATASTORE — ctx.datastore (@colixsystems/datastore-client)
|
|
15
|
+
// - FILES — ctx.files (@colixsystems/files-client)
|
|
16
|
+
// - DIRECTORY — ctx.directory (@colixsystems/directory-client)
|
|
17
|
+
// - PAYMENTS — ctx.payments (@colixsystems/payments-client)
|
|
18
|
+
//
|
|
11
19
|
// This file avoids JSX so it can ship as a plain .js without a transform.
|
|
12
20
|
|
|
13
21
|
import React, {
|
|
@@ -18,10 +26,224 @@ import React, {
|
|
|
18
26
|
useRef,
|
|
19
27
|
useState,
|
|
20
28
|
} from "react";
|
|
29
|
+
// REQ-L10N-WIDGET: the per-widget translation key format lives in ONE place
|
|
30
|
+
// (contract.js). useI18n (lookup) and the backend seeder (write) both call it
|
|
31
|
+
// so the namespaced key can never drift between the two sides.
|
|
32
|
+
import { widgetTranslationKey } from "./contract.js";
|
|
21
33
|
|
|
22
34
|
/** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
|
|
23
35
|
const HostWidgetContext = createContext(null);
|
|
24
36
|
|
|
37
|
+
/* ============================================================================
|
|
38
|
+
* CORE — WidgetContext + non-data hooks (no domain client)
|
|
39
|
+
*
|
|
40
|
+
* The provider, the context accessor, and the hooks that read host-provided
|
|
41
|
+
* state directly off the WidgetContext (events, theme, user, renderer,
|
|
42
|
+
* navigation, i18n) rather than through one of the four domain clients.
|
|
43
|
+
* ==========================================================================*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Wraps children with the host-provided WidgetContext.
|
|
47
|
+
* The host (Studio/Player/native shell) builds the value and renders this provider.
|
|
48
|
+
*/
|
|
49
|
+
export function WidgetContextProvider({ value, children }) {
|
|
50
|
+
return React.createElement(HostWidgetContext.Provider, { value }, children);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Exported for ./toast.js + future hook modules outside hooks.js. Internal
|
|
54
|
+
// utility — widget code should not import this directly.
|
|
55
|
+
export function useWidgetContextOrThrow(hookName) {
|
|
56
|
+
const ctx = useContext(HostWidgetContext);
|
|
57
|
+
if (ctx == null) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`${hookName} must be used inside a WidgetContextProvider. ` +
|
|
60
|
+
`The host (Studio, Player, or exported app) is responsible for mounting it.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return ctx;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Emit a named widget event through ctx.events.emit. Page-level event
|
|
68
|
+
* bindings (subscribed by the host) decide what happens next.
|
|
69
|
+
*/
|
|
70
|
+
export function useWidgetEvent(name) {
|
|
71
|
+
const ctx = useWidgetContextOrThrow("useWidgetEvent");
|
|
72
|
+
return useCallback((payload) => ctx.events.emit(name, payload), [ctx, name]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns the host-provided theme tokens. The host guarantees every field
|
|
77
|
+
* documented in CONTRACT.themeTokens is present (defaults merged with
|
|
78
|
+
* tenant overrides), so widgets read `theme.colors.primary` without
|
|
79
|
+
* optional chaining.
|
|
80
|
+
*/
|
|
81
|
+
export function useTheme() {
|
|
82
|
+
const ctx = useWidgetContextOrThrow("useTheme");
|
|
83
|
+
return ctx.workspace.theme;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Returns the active end-user identity VERBATIM from the host, e.g.
|
|
88
|
+
* `{ id, email, display_name, roles, group_ids }` (snake_case fields).
|
|
89
|
+
*
|
|
90
|
+
* `id` is `null` for anonymous visitors (and on the Studio canvas preview,
|
|
91
|
+
* which renders widgets as if signed-out so the public branch shows). The
|
|
92
|
+
* host (`buildHostWidgetContext` + the native `WidgetHost`) populates the
|
|
93
|
+
* fields; widgets read them directly. The current principal is host-provided
|
|
94
|
+
* — this hook does NOT read a data-client.
|
|
95
|
+
*
|
|
96
|
+
* Use this to render the signed-in user's name in a header, branch on
|
|
97
|
+
* roles, or stamp a created-by field.
|
|
98
|
+
*/
|
|
99
|
+
export function useUser() {
|
|
100
|
+
const ctx = useWidgetContextOrThrow("useUser");
|
|
101
|
+
return ctx.user;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Returns `true` when the host has sized this widget to fill the available
|
|
106
|
+
* height of its layout slot — today that means a page-grid tile whose author
|
|
107
|
+
* chose "Fill tile height" (or a widget type that fills by default, like
|
|
108
|
+
* containers and media). When `true`, a widget that has a meaningful filled
|
|
109
|
+
* form (Image, Chart, Map, Video, …) should switch from its intrinsic height
|
|
110
|
+
* to a stretch style (`flex: 1` / `height: "100%"`); widgets with no useful
|
|
111
|
+
* filled form may ignore it. Defaults to `false` everywhere the host has not
|
|
112
|
+
* opted the widget into filling, so calling it is always safe.
|
|
113
|
+
*
|
|
114
|
+
* The SAME value is injected by the web Player host and the native export
|
|
115
|
+
* host (CLAUDE.md §3), so a widget's fill behaviour is identical on both
|
|
116
|
+
* platforms — there is one source file and one `fill` flag driving it.
|
|
117
|
+
*/
|
|
118
|
+
export function useFill() {
|
|
119
|
+
const ctx = useWidgetContextOrThrow("useFill");
|
|
120
|
+
return ctx.fill === true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Returns the host's child-node renderer:
|
|
125
|
+
* { renderNode(node) }.
|
|
126
|
+
*
|
|
127
|
+
* Widgets like Tabs / Card / a custom container call `renderNode(child)`
|
|
128
|
+
* to render an author-authored page-tree node nested inside themselves.
|
|
129
|
+
* The renderer closes over the surrounding render context (breakpoint,
|
|
130
|
+
* page ctx, parent) that the host already knows, so the widget doesn't
|
|
131
|
+
* need to plumb any of that through.
|
|
132
|
+
*
|
|
133
|
+
* Prefer the `WidgetTree` component for the common case
|
|
134
|
+
* (`<WidgetTree node={child} />`); reach for the hook when you need to
|
|
135
|
+
* branch on the node's shape before rendering.
|
|
136
|
+
*/
|
|
137
|
+
export function useChildRenderer() {
|
|
138
|
+
const ctx = useWidgetContextOrThrow("useChildRenderer");
|
|
139
|
+
if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
|
|
140
|
+
throw new Error(
|
|
141
|
+
"useChildRenderer: host did not inject a child-node renderer",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return ctx.renderer;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Renders an author-authored page-tree node through the host's child
|
|
149
|
+
* renderer. The widget hands off a node (or null) and gets back a React
|
|
150
|
+
* element rendered with the same dispatch the top-level page uses, so
|
|
151
|
+
* Tabs / Card / custom container widgets can host arbitrary child
|
|
152
|
+
* widgets without importing the host.
|
|
153
|
+
*/
|
|
154
|
+
export function WidgetTree({ node }) {
|
|
155
|
+
const ctx = useWidgetContextOrThrow("WidgetTree");
|
|
156
|
+
if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
if (!node) return null;
|
|
160
|
+
return ctx.renderer.renderNode(node);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Returns the host-provided navigation surface:
|
|
165
|
+
* `{ goTo, goBack, push, replace, back, currentRoute }`.
|
|
166
|
+
*
|
|
167
|
+
* `goTo(pageId, params?)` navigates to an internal app page. `goBack()`
|
|
168
|
+
* pops the stack. `push` / `replace` / `back` are aliases that map to
|
|
169
|
+
* the platform's native navigator (react-router on web, react-navigation
|
|
170
|
+
* on native). Missing methods degrade to no-ops on the Studio canvas
|
|
171
|
+
* preview, where there is no live router.
|
|
172
|
+
*
|
|
173
|
+
* For EXTERNAL URLs use the `Linking` primitive — `Linking.openURL(url)`
|
|
174
|
+
* works on both platforms.
|
|
175
|
+
*/
|
|
176
|
+
export function useNavigation() {
|
|
177
|
+
const ctx = useWidgetContextOrThrow("useNavigation");
|
|
178
|
+
return ctx.navigation;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
|
|
183
|
+
* the host's translation table and falls back to `fallback ?? key` when
|
|
184
|
+
* the key is missing. The host's i18n.t may or may not honour the two-arg
|
|
185
|
+
* form; we degrade gracefully either way.
|
|
186
|
+
*/
|
|
187
|
+
export function useI18n() {
|
|
188
|
+
const ctx = useWidgetContextOrThrow("useI18n");
|
|
189
|
+
const i18n = ctx.i18n || {};
|
|
190
|
+
const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
|
|
191
|
+
const hostT = typeof i18n.t === "function" ? i18n.t : null;
|
|
192
|
+
// REQ-L10N-WIDGET: the widget's own manifest translations are stored in the
|
|
193
|
+
// tenant dictionary under `widget.<id>.<key>`. Derive the namespace from the
|
|
194
|
+
// host-provided widget id so the author calls `t("greeting")` and never
|
|
195
|
+
// types the prefix — and so the SAME hook gives this behaviour on web and in
|
|
196
|
+
// the exported app (both hosts set ctx.widget.id).
|
|
197
|
+
const widgetId =
|
|
198
|
+
ctx.widget && typeof ctx.widget.id === "string" && ctx.widget.id
|
|
199
|
+
? ctx.widget.id
|
|
200
|
+
: null;
|
|
201
|
+
const t = useCallback(
|
|
202
|
+
(key, fallback) => {
|
|
203
|
+
if (typeof key !== "string" || !key) {
|
|
204
|
+
return typeof fallback === "string" ? fallback : "";
|
|
205
|
+
}
|
|
206
|
+
if (hostT) {
|
|
207
|
+
// 1) Try the widget-namespaced key first. A genuine hit is a string
|
|
208
|
+
// that is neither the bare key nor a `{{t:…}}` placeholder (the
|
|
209
|
+
// miss form both hosts emit). No fallback is passed here so a miss
|
|
210
|
+
// is unambiguous and we can fall through to the raw key.
|
|
211
|
+
if (widgetId) {
|
|
212
|
+
const namespaced = widgetTranslationKey(widgetId, key);
|
|
213
|
+
const scoped = hostT(namespaced);
|
|
214
|
+
if (
|
|
215
|
+
typeof scoped === "string" &&
|
|
216
|
+
scoped.length > 0 &&
|
|
217
|
+
scoped !== namespaced &&
|
|
218
|
+
!scoped.startsWith("{{t:")
|
|
219
|
+
) {
|
|
220
|
+
return scoped;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// 2) Fall back to the raw key (shared app keys + widgets with no
|
|
224
|
+
// manifest translations — unchanged from pre-1.10 behaviour).
|
|
225
|
+
const out = hostT(key, fallback);
|
|
226
|
+
if (typeof out === "string" && out.length > 0 && out !== key) {
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
// Host returned the key (unresolved) or nothing usable.
|
|
230
|
+
return typeof fallback === "string" ? fallback : key;
|
|
231
|
+
}
|
|
232
|
+
return typeof fallback === "string" ? fallback : key;
|
|
233
|
+
},
|
|
234
|
+
[hostT, widgetId],
|
|
235
|
+
);
|
|
236
|
+
return { t, locale };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* ============================================================================
|
|
240
|
+
* DATASTORE CLIENT — ctx.datastore (@colixsystems/datastore-client)
|
|
241
|
+
*
|
|
242
|
+
* Records, schema, aggregate, and record-level permissions. Covers:
|
|
243
|
+
* useDatastoreQuery, useDatastoreRecord, useDatastoreSchema,
|
|
244
|
+
* useDatastoreMutation, useRecordPermissions.
|
|
245
|
+
* ==========================================================================*/
|
|
246
|
+
|
|
25
247
|
/**
|
|
26
248
|
* Structured error thrown by `useDatastoreMutation` callbacks (and surfaced
|
|
27
249
|
* by `useDatastoreQuery` in its `error` slot). Carries a stable `code` so
|
|
@@ -53,29 +275,6 @@ export class DatastoreError extends Error {
|
|
|
53
275
|
}
|
|
54
276
|
}
|
|
55
277
|
|
|
56
|
-
/**
|
|
57
|
-
* Wraps children with the host-provided WidgetContext.
|
|
58
|
-
* The host (Studio/Player/native shell) builds the value and renders this provider.
|
|
59
|
-
*/
|
|
60
|
-
export function WidgetContextProvider({ value, children }) {
|
|
61
|
-
return React.createElement(HostWidgetContext.Provider, { value }, children);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Exported for ./toast.js + future hook modules outside hooks.js. Internal
|
|
65
|
-
// utility — widget code should not import this directly.
|
|
66
|
-
export function useWidgetContextOrThrow(hookName) {
|
|
67
|
-
const ctx = useContext(HostWidgetContext);
|
|
68
|
-
if (ctx == null) {
|
|
69
|
-
throw new Error(
|
|
70
|
-
`${hookName} must be used inside a WidgetContextProvider. ` +
|
|
71
|
-
`The host (Studio, Player, or exported app) is responsible for mounting it.`,
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
return ctx;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// --------------------------------------------------------------- helpers
|
|
78
|
-
|
|
79
278
|
/**
|
|
80
279
|
* Coerce an arbitrary thrown value (axios error, plain Error, string) into
|
|
81
280
|
* a DatastoreError with a stable `.code`. Reads `error.response.status` if
|
|
@@ -127,15 +326,16 @@ function toDatastoreError(err) {
|
|
|
127
326
|
});
|
|
128
327
|
}
|
|
129
328
|
|
|
130
|
-
// --------------------------------------------------------------- hooks
|
|
131
|
-
|
|
132
329
|
/**
|
|
133
330
|
* Stateful datastore query hook. Returns { data, loading, error, refetch }.
|
|
134
331
|
*
|
|
135
|
-
* The host
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
332
|
+
* The host injects a `@colixsystems/datastore-client` instance at
|
|
333
|
+
* `ctx.datastore`. We call `ctx.datastore.records(table).list(query)`, which
|
|
334
|
+
* resolves to the `{ data, meta }` list envelope VERBATIM (the client no
|
|
335
|
+
* longer unwraps to a bare array), so we read `res.data` (defaulting to `[]`)
|
|
336
|
+
* into component state. Author column values inside each row keep whatever
|
|
337
|
+
* the author named them. We re-fetch when [table, JSON.stringify(query)]
|
|
338
|
+
* changes. `refetch` re-runs the same call on demand.
|
|
139
339
|
*
|
|
140
340
|
* When `table` is falsy (e.g. the user hasn't bound a `tableRef` property
|
|
141
341
|
* yet), the hook resolves to { data: [], loading: false, error: null,
|
|
@@ -189,10 +389,13 @@ export function useDatastoreQuery(table, query) {
|
|
|
189
389
|
setError(null);
|
|
190
390
|
try {
|
|
191
391
|
const ns = recordsRef.current(t);
|
|
192
|
-
const
|
|
392
|
+
const res = await ns.list(queryRef.current);
|
|
393
|
+
// The datastore-client returns the { data, meta } list envelope
|
|
394
|
+
// verbatim — unwrap to the rows array (default []).
|
|
395
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
193
396
|
// Discard the result if a newer fetch has started since we kicked off.
|
|
194
397
|
if (runRef.current !== myRun) return;
|
|
195
|
-
setData(
|
|
398
|
+
setData(rows);
|
|
196
399
|
setLoading(false);
|
|
197
400
|
} catch (err) {
|
|
198
401
|
if (runRef.current !== myRun) return;
|
|
@@ -225,10 +428,12 @@ export function useDatastoreQuery(table, query) {
|
|
|
225
428
|
* Stateful single-record query hook. Returns { data, loading, error, refetch }
|
|
226
429
|
* where `data` is one row (`null` until loaded, never an array).
|
|
227
430
|
*
|
|
228
|
-
* The host
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
431
|
+
* The host injects a `@colixsystems/datastore-client` instance at
|
|
432
|
+
* `ctx.datastore`. We call `ctx.datastore.records(table).get(id)`, which
|
|
433
|
+
* resolves to a single record object (no `{ data, meta }` envelope — `get`
|
|
434
|
+
* returns the row directly). Author column values keep whatever the author
|
|
435
|
+
* named them. We hold the result in component state and re-fetch when
|
|
436
|
+
* [table, id] changes. `refetch` re-runs the call on demand.
|
|
232
437
|
*
|
|
233
438
|
* When `table` OR `recordId` is falsy (e.g. the author hasn't bound the
|
|
234
439
|
* record id yet), the hook resolves to { data: null, loading: false,
|
|
@@ -372,82 +577,27 @@ export function useDatastoreSchema(tableId) {
|
|
|
372
577
|
return { schema, loading, error, refetch };
|
|
373
578
|
}
|
|
374
579
|
|
|
375
|
-
/**
|
|
376
|
-
* Stateful file-asset resolver hook. Returns { url, file, loading, error,
|
|
377
|
-
* refetch }.
|
|
378
|
-
*
|
|
379
|
-
* The host's file client exposes `files.get(fileId)` which resolves to
|
|
380
|
-
* `{ url, ...meta }` — the absolute URL is composed against the host's
|
|
381
|
-
* API base so the widget can drop it straight into an `<Image source>`
|
|
382
|
-
* without knowing where the API lives. A missing/soft-deleted asset
|
|
383
|
-
* surfaces as `{ url: null, error: <DatastoreError NOT_FOUND> }`.
|
|
384
|
-
*
|
|
385
|
-
* When `fileId` is falsy the hook collapses to { url: null, file: null,
|
|
386
|
-
* loading: false, error: null, refetch } without a network round-trip,
|
|
387
|
-
* so a widget rendering before the author has bound an asset stays
|
|
388
|
-
* loop-free.
|
|
389
|
-
*/
|
|
390
|
-
export function useFile(fileId) {
|
|
391
|
-
const ctx = useWidgetContextOrThrow("useFile");
|
|
392
|
-
if (!ctx.files || typeof ctx.files.get !== "function") {
|
|
393
|
-
throw new Error("useFile: host did not inject a files client");
|
|
394
|
-
}
|
|
395
|
-
const ready = Boolean(fileId);
|
|
396
|
-
const [file, setFile] = useState(null);
|
|
397
|
-
const [loading, setLoading] = useState(ready);
|
|
398
|
-
const [error, setError] = useState(null);
|
|
399
|
-
|
|
400
|
-
const fileIdRef = useRef(fileId);
|
|
401
|
-
const getRef = useRef(ctx.files.get);
|
|
402
|
-
fileIdRef.current = fileId;
|
|
403
|
-
getRef.current = ctx.files.get;
|
|
404
|
-
|
|
405
|
-
const runRef = useRef(0);
|
|
406
|
-
|
|
407
|
-
const doFetch = useCallback(async () => {
|
|
408
|
-
const myRun = ++runRef.current;
|
|
409
|
-
const id = fileIdRef.current;
|
|
410
|
-
if (!id) {
|
|
411
|
-
setLoading(false);
|
|
412
|
-
setError(null);
|
|
413
|
-
setFile(null);
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
setLoading(true);
|
|
417
|
-
setError(null);
|
|
418
|
-
try {
|
|
419
|
-
const f = await getRef.current(id);
|
|
420
|
-
if (runRef.current !== myRun) return;
|
|
421
|
-
setFile(f || null);
|
|
422
|
-
setLoading(false);
|
|
423
|
-
} catch (err) {
|
|
424
|
-
if (runRef.current !== myRun) return;
|
|
425
|
-
setError(toDatastoreError(err));
|
|
426
|
-
setLoading(false);
|
|
427
|
-
}
|
|
428
|
-
}, []);
|
|
429
|
-
|
|
430
|
-
useEffect(() => {
|
|
431
|
-
doFetch();
|
|
432
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
433
|
-
}, [fileId]);
|
|
434
|
-
|
|
435
|
-
const refetch = useCallback(async () => {
|
|
436
|
-
await doFetch();
|
|
437
|
-
}, [doFetch]);
|
|
438
|
-
|
|
439
|
-
const url =
|
|
440
|
-
file && typeof file.url === "string" && file.url.length > 0
|
|
441
|
-
? file.url
|
|
442
|
-
: null;
|
|
443
|
-
return { url, file, loading, error, refetch };
|
|
444
|
-
}
|
|
445
|
-
|
|
446
580
|
/**
|
|
447
581
|
* Datastore mutation hook. Returns { create, update, delete }, each method
|
|
448
|
-
* returning a Promise.
|
|
449
|
-
*
|
|
450
|
-
* `
|
|
582
|
+
* returning a Promise. Routes through the injected
|
|
583
|
+
* `@colixsystems/datastore-client` at `ctx.datastore`:
|
|
584
|
+
* `ctx.datastore.records(table).{create,update,delete}`. `update` is a
|
|
585
|
+
* partial PATCH (the client issues PATCH), so callers pass only the changed
|
|
586
|
+
* fields. Values pass through verbatim (snake_case / author-named columns).
|
|
587
|
+
* Rejected promises throw a DatastoreError carrying a stable `.code`
|
|
588
|
+
* (`VALIDATION`, `CONSTRAINT_VIOLATION`, `FORBIDDEN`, `NOT_FOUND`,
|
|
589
|
+
* `INTERNAL`) plus an optional `fieldErrors` map.
|
|
590
|
+
*
|
|
591
|
+
* When `table` is falsy (the author hasn't bound a `tableRef` yet) the hook
|
|
592
|
+
* does NOT throw at render — it returns callbacks that reject with a
|
|
593
|
+
* `DatastoreError { code: "VALIDATION" }` only if actually invoked. This
|
|
594
|
+
* mirrors the guarded posture of `useDatastoreQuery` / `useDatastoreRecord`:
|
|
595
|
+
* a widget calls this hook unconditionally at the top of its body (Rules of
|
|
596
|
+
* Hooks) and renders its unbound empty state without crashing. The
|
|
597
|
+
* `records(table)` namespace is resolved lazily inside each callback — the
|
|
598
|
+
* client throws on an empty table, so building it eagerly at render would
|
|
599
|
+
* crash any widget mounted before its table is bound (the failure the Form
|
|
600
|
+
* Input widget hit).
|
|
451
601
|
*/
|
|
452
602
|
export function useDatastoreMutation(table) {
|
|
453
603
|
const ctx = useWidgetContextOrThrow("useDatastoreMutation");
|
|
@@ -456,314 +606,406 @@ export function useDatastoreMutation(table) {
|
|
|
456
606
|
"useDatastoreMutation: host did not inject a datastore client",
|
|
457
607
|
);
|
|
458
608
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
609
|
+
|
|
610
|
+
// Same ref discipline as useDatastoreQuery — `ctx` is a fresh object
|
|
611
|
+
// identity on every host render, so we hold the live table + records
|
|
612
|
+
// factory in refs to keep the returned callbacks stable. We deliberately
|
|
613
|
+
// do NOT call `records(table)` here at render.
|
|
614
|
+
const tableRef = useRef(table);
|
|
615
|
+
const recordsRef = useRef(ctx.datastore.records);
|
|
616
|
+
tableRef.current = table;
|
|
617
|
+
recordsRef.current = ctx.datastore.records;
|
|
618
|
+
|
|
619
|
+
// Resolve records(table) lazily. Returns null when no table is bound so
|
|
620
|
+
// the callbacks reject with a clear VALIDATION error rather than letting
|
|
621
|
+
// the client's raw TypeError surface (or crashing at render).
|
|
622
|
+
const resolveNs = useCallback(() => {
|
|
623
|
+
const t = tableRef.current;
|
|
624
|
+
if (!t) return null;
|
|
625
|
+
return recordsRef.current(t);
|
|
626
|
+
}, []);
|
|
627
|
+
|
|
628
|
+
const create = useCallback(
|
|
629
|
+
async (values) => {
|
|
630
|
+
const ns = resolveNs();
|
|
631
|
+
if (!ns) {
|
|
632
|
+
throw new DatastoreError(
|
|
633
|
+
"VALIDATION",
|
|
634
|
+
"useDatastoreMutation: no table bound — set a source table before creating a record.",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
462
637
|
try {
|
|
463
638
|
return await ns.create(values);
|
|
464
639
|
} catch (err) {
|
|
465
640
|
throw toDatastoreError(err);
|
|
466
641
|
}
|
|
467
642
|
},
|
|
468
|
-
|
|
643
|
+
[resolveNs],
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
const update = useCallback(
|
|
647
|
+
async (id, values) => {
|
|
648
|
+
const ns = resolveNs();
|
|
649
|
+
if (!ns) {
|
|
650
|
+
throw new DatastoreError(
|
|
651
|
+
"VALIDATION",
|
|
652
|
+
"useDatastoreMutation: no table bound — set a source table before updating a record.",
|
|
653
|
+
);
|
|
654
|
+
}
|
|
469
655
|
try {
|
|
470
656
|
return await ns.update(id, values);
|
|
471
657
|
} catch (err) {
|
|
472
658
|
throw toDatastoreError(err);
|
|
473
659
|
}
|
|
474
660
|
},
|
|
475
|
-
|
|
661
|
+
[resolveNs],
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
const del = useCallback(
|
|
665
|
+
async (id) => {
|
|
666
|
+
const ns = resolveNs();
|
|
667
|
+
if (!ns) {
|
|
668
|
+
throw new DatastoreError(
|
|
669
|
+
"VALIDATION",
|
|
670
|
+
"useDatastoreMutation: no table bound — set a source table before deleting a record.",
|
|
671
|
+
);
|
|
672
|
+
}
|
|
476
673
|
try {
|
|
477
674
|
return await ns.delete(id);
|
|
478
675
|
} catch (err) {
|
|
479
676
|
throw toDatastoreError(err);
|
|
480
677
|
}
|
|
481
678
|
},
|
|
482
|
-
|
|
679
|
+
[resolveNs],
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
return { create, update, delete: del };
|
|
483
683
|
}
|
|
484
684
|
|
|
485
685
|
/**
|
|
486
|
-
*
|
|
487
|
-
*
|
|
686
|
+
* REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — structured error thrown by
|
|
687
|
+
* `useRecordPermissions` mutation callbacks (and surfaced by the hook's
|
|
688
|
+
* `error` slot when the initial list fetch fails). Carries a stable
|
|
689
|
+
* `code` so widgets can branch on the error class without parsing
|
|
690
|
+
* message strings; mirrors the shape of `DirectoryError`.
|
|
488
691
|
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
692
|
+
* `code` is one of:
|
|
693
|
+
* - "FORBIDDEN" — 403 from the host (caller lacks canGrant on the record).
|
|
694
|
+
* - "VALIDATION" — 400 / 422 (missing principal, malformed body).
|
|
695
|
+
* - "NOT_FOUND" — 404 (record absent, cross-tenant, or permission row
|
|
696
|
+
* not found on revoke/update).
|
|
697
|
+
* - "CONFLICT" — 409 (e.g. trying to edit a template-derived row).
|
|
698
|
+
* - "INTERNAL" — anything else (network, 5xx).
|
|
699
|
+
*/
|
|
700
|
+
export class PermissionError extends Error {
|
|
701
|
+
constructor(code, message, opts) {
|
|
702
|
+
super(message);
|
|
703
|
+
this.name = "PermissionError";
|
|
704
|
+
this.code = code;
|
|
705
|
+
if (opts && opts.status !== undefined) this.status = opts.status;
|
|
706
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function toPermissionError(err) {
|
|
711
|
+
if (err instanceof PermissionError) return err;
|
|
712
|
+
const status =
|
|
713
|
+
err && err.response && typeof err.response.status === "number"
|
|
714
|
+
? err.response.status
|
|
715
|
+
: null;
|
|
716
|
+
const bodyCode =
|
|
717
|
+
err && err.response && err.response.data && err.response.data.code;
|
|
718
|
+
const bodyMessage =
|
|
719
|
+
err && err.response && err.response.data && err.response.data.error;
|
|
720
|
+
let code = "INTERNAL";
|
|
721
|
+
if (status === 403) code = "FORBIDDEN";
|
|
722
|
+
else if (status === 404) code = "NOT_FOUND";
|
|
723
|
+
else if (status === 409) code = "CONFLICT";
|
|
724
|
+
else if (status === 400 || status === 422) code = "VALIDATION";
|
|
725
|
+
if (typeof bodyCode === "string" && bodyCode) {
|
|
726
|
+
// Preserve the server's stable code over the status-derived one when
|
|
727
|
+
// the server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of
|
|
728
|
+
// an inherit/template row).
|
|
729
|
+
code = bodyCode;
|
|
730
|
+
}
|
|
731
|
+
const message =
|
|
732
|
+
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
733
|
+
(err && typeof err.message === "string"
|
|
734
|
+
? err.message
|
|
735
|
+
: "Record permission call failed");
|
|
736
|
+
return new PermissionError(code, message, { status, cause: err });
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const _NOOP_PERMISSIONS_RESULT = Object.freeze({
|
|
740
|
+
permissions: [],
|
|
741
|
+
loading: false,
|
|
742
|
+
error: null,
|
|
743
|
+
// Mutation no-ops resolve with null so a widget that does
|
|
744
|
+
// `await grant(...)` doesn't crash when called against an unbound
|
|
745
|
+
// (tableId / recordId null) hook.
|
|
746
|
+
grant: async () => null,
|
|
747
|
+
revoke: async () => undefined,
|
|
748
|
+
update: async () => null,
|
|
749
|
+
refetch: async () => undefined,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — manage per-record VirtualPermission
|
|
754
|
+
* grants on a single record. Returns
|
|
755
|
+
* `{ permissions, loading, error, grant, revoke, update, refetch }`.
|
|
495
756
|
*
|
|
496
|
-
* `
|
|
497
|
-
*
|
|
498
|
-
* (default) or `"INTEGRATION"` or `"ALL"`. The hook re-fetches whenever
|
|
499
|
-
* `JSON.stringify(query)` changes and exposes `refetch` for on-demand
|
|
500
|
-
* reloads (e.g. a chat roster refresh).
|
|
757
|
+
* `permissions` is an array of snake_case rows VERBATIM, e.g.
|
|
758
|
+
* `{ id, user_id, group_id, can_read, can_write, can_delete, can_grant, ... }`.
|
|
501
759
|
*
|
|
502
|
-
*
|
|
503
|
-
* `
|
|
760
|
+
* Reads through the injected `@colixsystems/datastore-client` at
|
|
761
|
+
* `ctx.datastore.records(tableId).permissions(recordId)`, which exposes:
|
|
762
|
+
* - `list()` → `Promise<{ data, meta }>` (envelope; we unwrap `res.data`)
|
|
763
|
+
* - `grant(body)` → `Promise<row>`
|
|
764
|
+
* - `update(permissionId, patch)` → `Promise<row>`
|
|
765
|
+
* - `revoke(permissionId)` → `Promise<void>`
|
|
766
|
+
*
|
|
767
|
+
* Grant / update bodies are snake_case verbatim
|
|
768
|
+
* (`{ user_id | group_id, can_read, can_write, can_delete, can_grant }`) —
|
|
769
|
+
* the SDK does NOT transform them. Rows come back snake_case too; widgets
|
|
770
|
+
* read those fields directly.
|
|
771
|
+
*
|
|
772
|
+
* When `tableId` OR `recordId` is null / undefined / empty (e.g. the
|
|
773
|
+
* widget hasn't picked an active channel yet), the hook collapses to a
|
|
774
|
+
* stable empty result without a network round-trip. Mutation methods
|
|
775
|
+
* are safe no-ops in that state so a widget can call them
|
|
776
|
+
* unconditionally.
|
|
777
|
+
*
|
|
778
|
+
* Requires the `acl.write:records` scope in the widget manifest's
|
|
779
|
+
* `requestedScopes`. The underlying REST endpoint gates the call on
|
|
780
|
+
* `can_grant` for the target record (Studio owners short-circuit;
|
|
781
|
+
* APP_USER actors must hold `can_grant` via REQ-ACL-05 / REQ-ACL-06).
|
|
782
|
+
* A widget that declares the scope but whose caller lacks the grant
|
|
783
|
+
* receives `PermissionError { code: "FORBIDDEN" }`.
|
|
504
784
|
*/
|
|
505
|
-
export function
|
|
506
|
-
const ctx = useWidgetContextOrThrow("
|
|
507
|
-
if (!ctx.
|
|
508
|
-
throw new Error(
|
|
785
|
+
export function useRecordPermissions(tableId, recordId) {
|
|
786
|
+
const ctx = useWidgetContextOrThrow("useRecordPermissions");
|
|
787
|
+
if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
|
|
788
|
+
throw new Error(
|
|
789
|
+
"useRecordPermissions: host did not inject a datastore client (ctx.datastore.records)",
|
|
790
|
+
);
|
|
509
791
|
}
|
|
510
|
-
const
|
|
511
|
-
const [
|
|
792
|
+
const ready = Boolean(tableId && recordId);
|
|
793
|
+
const [permissions, setPermissions] = useState([]);
|
|
794
|
+
const [loading, setLoading] = useState(ready);
|
|
512
795
|
const [error, setError] = useState(null);
|
|
513
796
|
|
|
514
|
-
// Same ref discipline as useDatastoreQuery
|
|
515
|
-
// WidgetContext value every render, so we capture the
|
|
516
|
-
// client in refs to keep
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
797
|
+
// Same ref discipline as useDirectory / useDatastoreQuery — the host
|
|
798
|
+
// rebuilds the WidgetContext value every render, so we capture the
|
|
799
|
+
// live ids + client in refs to keep refetch + grant + revoke + update
|
|
800
|
+
// stable callback identities.
|
|
801
|
+
const tableIdRef = useRef(tableId);
|
|
802
|
+
const recordIdRef = useRef(recordId);
|
|
803
|
+
const recordsRef = useRef(ctx.datastore.records);
|
|
804
|
+
tableIdRef.current = tableId;
|
|
805
|
+
recordIdRef.current = recordId;
|
|
806
|
+
recordsRef.current = ctx.datastore.records;
|
|
807
|
+
|
|
808
|
+
// Resolve the per-record permissions namespace
|
|
809
|
+
// (ctx.datastore.records(tableId).permissions(recordId)) for the bound
|
|
810
|
+
// ids. Returns null when either id is missing.
|
|
811
|
+
const resolveNs = useCallback(() => {
|
|
812
|
+
const t = tableIdRef.current;
|
|
813
|
+
const r = recordIdRef.current;
|
|
814
|
+
if (!t || !r) return null;
|
|
815
|
+
const recordsNs = recordsRef.current(t);
|
|
816
|
+
if (!recordsNs || typeof recordsNs.permissions !== "function") {
|
|
817
|
+
throw new Error(
|
|
818
|
+
"useRecordPermissions: datastore client does not expose records(table).permissions(recordId)",
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
return recordsNs.permissions(r);
|
|
822
|
+
}, []);
|
|
521
823
|
|
|
522
824
|
const runRef = useRef(0);
|
|
523
825
|
|
|
524
826
|
const doFetch = useCallback(async () => {
|
|
525
827
|
const myRun = ++runRef.current;
|
|
828
|
+
const ns = resolveNs();
|
|
829
|
+
if (!ns) {
|
|
830
|
+
// Collapse to a stable empty result without a network round-trip
|
|
831
|
+
// so a widget rendering before an active record is picked stays
|
|
832
|
+
// loop-free.
|
|
833
|
+
setLoading(false);
|
|
834
|
+
setError(null);
|
|
835
|
+
setPermissions([]);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
526
838
|
setLoading(true);
|
|
527
839
|
setError(null);
|
|
528
840
|
try {
|
|
529
|
-
const
|
|
841
|
+
const res = await ns.list();
|
|
842
|
+
// permissions().list() returns the { data, meta } envelope verbatim.
|
|
843
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
530
844
|
if (runRef.current !== myRun) return;
|
|
531
|
-
|
|
845
|
+
setPermissions(rows);
|
|
532
846
|
setLoading(false);
|
|
533
847
|
} catch (err) {
|
|
534
848
|
if (runRef.current !== myRun) return;
|
|
535
|
-
setError(
|
|
849
|
+
setError(toPermissionError(err));
|
|
536
850
|
setLoading(false);
|
|
537
851
|
}
|
|
538
|
-
}, []);
|
|
852
|
+
}, [resolveNs]);
|
|
539
853
|
|
|
540
|
-
const queryKey = (() => {
|
|
541
|
-
try {
|
|
542
|
-
return JSON.stringify(query);
|
|
543
|
-
} catch (_e) {
|
|
544
|
-
return null;
|
|
545
|
-
}
|
|
546
|
-
})();
|
|
547
854
|
useEffect(() => {
|
|
855
|
+
if (!ready) {
|
|
856
|
+
// Reset the slot when the widget unbinds (e.g. user navigates
|
|
857
|
+
// back to the picker) so the next render doesn't show stale
|
|
858
|
+
// permissions from a previous record.
|
|
859
|
+
setPermissions([]);
|
|
860
|
+
setLoading(false);
|
|
861
|
+
setError(null);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
548
864
|
doFetch();
|
|
549
865
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
550
|
-
}, [
|
|
866
|
+
}, [tableId, recordId]);
|
|
551
867
|
|
|
552
868
|
const refetch = useCallback(async () => {
|
|
553
869
|
await doFetch();
|
|
554
870
|
}, [doFetch]);
|
|
555
871
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
872
|
+
const grant = useCallback(async (body) => {
|
|
873
|
+
const ns = resolveNs();
|
|
874
|
+
if (!ns) return null;
|
|
875
|
+
try {
|
|
876
|
+
// body is snake_case verbatim ({ user_id | group_id, can_read,
|
|
877
|
+
// can_write, can_delete, can_grant }) — passed straight through.
|
|
878
|
+
const row = await ns.grant(body);
|
|
879
|
+
// Refresh after the mutation so subsequent reads observe the
|
|
880
|
+
// grant. The client returns the created row too, which we hand
|
|
881
|
+
// back to the caller for inline use.
|
|
882
|
+
await doFetch();
|
|
883
|
+
return row;
|
|
884
|
+
} catch (err) {
|
|
885
|
+
throw toPermissionError(err);
|
|
886
|
+
}
|
|
887
|
+
}, [resolveNs, doFetch]);
|
|
567
888
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
889
|
+
const revoke = useCallback(async (permissionId) => {
|
|
890
|
+
const ns = resolveNs();
|
|
891
|
+
if (!ns) return undefined;
|
|
892
|
+
try {
|
|
893
|
+
await ns.revoke(permissionId);
|
|
894
|
+
await doFetch();
|
|
895
|
+
} catch (err) {
|
|
896
|
+
throw toPermissionError(err);
|
|
897
|
+
}
|
|
898
|
+
}, [resolveNs, doFetch]);
|
|
578
899
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
*/
|
|
592
|
-
export function useUser() {
|
|
593
|
-
const ctx = useWidgetContextOrThrow("useUser");
|
|
594
|
-
return ctx.user;
|
|
595
|
-
}
|
|
900
|
+
const update = useCallback(async (permissionId, body) => {
|
|
901
|
+
const ns = resolveNs();
|
|
902
|
+
if (!ns) return null;
|
|
903
|
+
try {
|
|
904
|
+
// body (the patch) is snake_case verbatim.
|
|
905
|
+
const row = await ns.update(permissionId, body);
|
|
906
|
+
await doFetch();
|
|
907
|
+
return row;
|
|
908
|
+
} catch (err) {
|
|
909
|
+
throw toPermissionError(err);
|
|
910
|
+
}
|
|
911
|
+
}, [resolveNs, doFetch]);
|
|
596
912
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
* The renderer closes over the surrounding render context (breakpoint,
|
|
604
|
-
* page ctx, parent) that the host already knows, so the widget doesn't
|
|
605
|
-
* need to plumb any of that through.
|
|
606
|
-
*
|
|
607
|
-
* Prefer the `WidgetTree` component for the common case
|
|
608
|
-
* (`<WidgetTree node={child} />`); reach for the hook when you need to
|
|
609
|
-
* branch on the node's shape before rendering.
|
|
610
|
-
*/
|
|
611
|
-
export function useChildRenderer() {
|
|
612
|
-
const ctx = useWidgetContextOrThrow("useChildRenderer");
|
|
613
|
-
if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
|
|
614
|
-
throw new Error(
|
|
615
|
-
"useChildRenderer: host did not inject a child-node renderer",
|
|
616
|
-
);
|
|
913
|
+
if (!ready) {
|
|
914
|
+
// Mutation no-ops are safe: a widget that does
|
|
915
|
+
// `await grant({...})` against an unbound hook resolves to null
|
|
916
|
+
// rather than throwing. Same shape as the populated case so the
|
|
917
|
+
// caller can branch on `tableId/recordId` once, at the top.
|
|
918
|
+
return _NOOP_PERMISSIONS_RESULT;
|
|
617
919
|
}
|
|
618
|
-
return ctx.renderer;
|
|
619
|
-
}
|
|
620
920
|
|
|
621
|
-
|
|
622
|
-
* Renders an author-authored page-tree node through the host's child
|
|
623
|
-
* renderer. The widget hands off a node (or null) and gets back a React
|
|
624
|
-
* element rendered with the same dispatch the top-level page uses, so
|
|
625
|
-
* Tabs / Card / custom container widgets can host arbitrary child
|
|
626
|
-
* widgets without importing the host.
|
|
627
|
-
*/
|
|
628
|
-
export function WidgetTree({ node }) {
|
|
629
|
-
const ctx = useWidgetContextOrThrow("WidgetTree");
|
|
630
|
-
if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
|
|
631
|
-
return null;
|
|
632
|
-
}
|
|
633
|
-
if (!node) return null;
|
|
634
|
-
return ctx.renderer.renderNode(node);
|
|
921
|
+
return { permissions, loading, error, grant, revoke, update, refetch };
|
|
635
922
|
}
|
|
636
923
|
|
|
637
|
-
|
|
638
|
-
*
|
|
639
|
-
* `{ goTo, goBack, push, replace, back, currentRoute }`.
|
|
640
|
-
*
|
|
641
|
-
* `goTo(pageId, params?)` navigates to an internal app page. `goBack()`
|
|
642
|
-
* pops the stack. `push` / `replace` / `back` are aliases that map to
|
|
643
|
-
* the platform's native navigator (react-router on web, react-navigation
|
|
644
|
-
* on native). Missing methods degrade to no-ops on the Studio canvas
|
|
645
|
-
* preview, where there is no live router.
|
|
924
|
+
/* ============================================================================
|
|
925
|
+
* FILES CLIENT — ctx.files (@colixsystems/files-client)
|
|
646
926
|
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*/
|
|
650
|
-
export function useNavigation() {
|
|
651
|
-
const ctx = useWidgetContextOrThrow("useNavigation");
|
|
652
|
-
return ctx.navigation;
|
|
653
|
-
}
|
|
927
|
+
* Files, folders, shares. Covers: useFile.
|
|
928
|
+
* ==========================================================================*/
|
|
654
929
|
|
|
655
930
|
/**
|
|
656
|
-
*
|
|
657
|
-
*
|
|
931
|
+
* Stateful file-asset resolver hook. Returns { url, file, loading, error,
|
|
932
|
+
* refetch }.
|
|
658
933
|
*
|
|
659
|
-
* `
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
934
|
+
* The host injects a `@colixsystems/files-client` instance at `ctx.files`,
|
|
935
|
+
* FLATTENED so file ops are top-level. We call `ctx.files.get(fileId)`,
|
|
936
|
+
* which resolves to `{ url, ...meta }` — the returned file already carries
|
|
937
|
+
* an absolute URL so the widget can drop it straight into an `<Image
|
|
938
|
+
* source>` without knowing where the API lives. A missing/soft-deleted
|
|
939
|
+
* asset surfaces as `{ url: null, error: <DatastoreError NOT_FOUND> }`.
|
|
940
|
+
*
|
|
941
|
+
* When `fileId` is falsy the hook collapses to { url: null, file: null,
|
|
942
|
+
* loading: false, error: null, refetch } without a network round-trip,
|
|
943
|
+
* so a widget rendering before the author has bound an asset stays
|
|
944
|
+
* loop-free.
|
|
666
945
|
*/
|
|
667
|
-
export
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
this.code = code;
|
|
672
|
-
if (opts && opts.cause) this.cause = opts.cause;
|
|
946
|
+
export function useFile(fileId) {
|
|
947
|
+
const ctx = useWidgetContextOrThrow("useFile");
|
|
948
|
+
if (!ctx.files || typeof ctx.files.get !== "function") {
|
|
949
|
+
throw new Error("useFile: host did not inject a files client");
|
|
673
950
|
}
|
|
674
|
-
|
|
951
|
+
const ready = Boolean(fileId);
|
|
952
|
+
const [file, setFile] = useState(null);
|
|
953
|
+
const [loading, setLoading] = useState(ready);
|
|
954
|
+
const [error, setError] = useState(null);
|
|
675
955
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
? err.response.status
|
|
681
|
-
: null;
|
|
682
|
-
const bodyCode =
|
|
683
|
-
err && err.response && err.response.data && err.response.data.code;
|
|
684
|
-
const bodyMessage =
|
|
685
|
-
err && err.response && err.response.data && err.response.data.error;
|
|
686
|
-
let code = "INTERNAL";
|
|
687
|
-
if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
|
|
688
|
-
else if (status === 401) code = "AUTH_REQUIRED";
|
|
689
|
-
else if (status === 402) code = "DECLINED";
|
|
690
|
-
else if (status === 403) code = "FORBIDDEN";
|
|
691
|
-
else if (status === 400) code = "VALIDATION";
|
|
692
|
-
const message =
|
|
693
|
-
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
694
|
-
(err && typeof err.message === "string"
|
|
695
|
-
? err.message
|
|
696
|
-
: "Payment request failed");
|
|
697
|
-
return new PaymentError(code, message, { cause: err });
|
|
698
|
-
}
|
|
956
|
+
const fileIdRef = useRef(fileId);
|
|
957
|
+
const getRef = useRef(ctx.files.get);
|
|
958
|
+
fileIdRef.current = fileId;
|
|
959
|
+
getRef.current = ctx.files.get;
|
|
699
960
|
|
|
700
|
-
|
|
701
|
-
* Incoming app-user payments (REQ-BILL-07-WIDGETPAY). Returns
|
|
702
|
-
* `{ requestPayment, getPayment }`.
|
|
703
|
-
*
|
|
704
|
-
* requestPayment({ amountCents, currency?, description, metadata? })
|
|
705
|
-
* → Promise<{ id, status, checkoutUrl?, ... }>. The host either
|
|
706
|
-
* auto-confirms (mock provider, `status: "PAID"`, no redirect) or
|
|
707
|
-
* returns a hosted-Checkout `checkoutUrl` the widget should open
|
|
708
|
-
* (Stripe provider, `status: "PENDING"`). Rejects with a
|
|
709
|
-
* `PaymentError`.
|
|
710
|
-
* getPayment(paymentId) → Promise<payment> — poll the terminal status.
|
|
711
|
-
*
|
|
712
|
-
* Requires the `payments.charge:appUser` scope in the manifest's
|
|
713
|
-
* `requestedScopes`. The charge settles to the workspace owner; the app
|
|
714
|
-
* user confirms the amount in hosted Checkout. No card data touches the
|
|
715
|
-
* widget — never collect card fields yourself.
|
|
716
|
-
*/
|
|
717
|
-
export function usePayments() {
|
|
718
|
-
const ctx = useWidgetContextOrThrow("usePayments");
|
|
719
|
-
if (!ctx.payments || typeof ctx.payments.requestPayment !== "function") {
|
|
720
|
-
throw new Error(
|
|
721
|
-
"usePayments: host did not inject a payments client. The widget must " +
|
|
722
|
-
"declare the payments.charge:appUser scope and be installed in a " +
|
|
723
|
-
"workspace whose host supports payments.",
|
|
724
|
-
);
|
|
725
|
-
}
|
|
726
|
-
const requestRef = useRef(ctx.payments.requestPayment);
|
|
727
|
-
const getRef = useRef(
|
|
728
|
-
typeof ctx.payments.getPayment === "function"
|
|
729
|
-
? ctx.payments.getPayment
|
|
730
|
-
: null,
|
|
731
|
-
);
|
|
732
|
-
requestRef.current = ctx.payments.requestPayment;
|
|
733
|
-
getRef.current =
|
|
734
|
-
typeof ctx.payments.getPayment === "function"
|
|
735
|
-
? ctx.payments.getPayment
|
|
736
|
-
: null;
|
|
961
|
+
const runRef = useRef(0);
|
|
737
962
|
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
if (!getRef.current) {
|
|
747
|
-
throw new PaymentError(
|
|
748
|
-
"INTERNAL",
|
|
749
|
-
"getPayment is not supported by this host.",
|
|
750
|
-
);
|
|
963
|
+
const doFetch = useCallback(async () => {
|
|
964
|
+
const myRun = ++runRef.current;
|
|
965
|
+
const id = fileIdRef.current;
|
|
966
|
+
if (!id) {
|
|
967
|
+
setLoading(false);
|
|
968
|
+
setError(null);
|
|
969
|
+
setFile(null);
|
|
970
|
+
return;
|
|
751
971
|
}
|
|
972
|
+
setLoading(true);
|
|
973
|
+
setError(null);
|
|
752
974
|
try {
|
|
753
|
-
|
|
975
|
+
const f = await getRef.current(id);
|
|
976
|
+
if (runRef.current !== myRun) return;
|
|
977
|
+
setFile(f || null);
|
|
978
|
+
setLoading(false);
|
|
754
979
|
} catch (err) {
|
|
755
|
-
|
|
980
|
+
if (runRef.current !== myRun) return;
|
|
981
|
+
setError(toDatastoreError(err));
|
|
982
|
+
setLoading(false);
|
|
756
983
|
}
|
|
757
984
|
}, []);
|
|
758
|
-
|
|
985
|
+
|
|
986
|
+
useEffect(() => {
|
|
987
|
+
doFetch();
|
|
988
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
989
|
+
}, [fileId]);
|
|
990
|
+
|
|
991
|
+
const refetch = useCallback(async () => {
|
|
992
|
+
await doFetch();
|
|
993
|
+
}, [doFetch]);
|
|
994
|
+
|
|
995
|
+
const url =
|
|
996
|
+
file && typeof file.url === "string" && file.url.length > 0
|
|
997
|
+
? file.url
|
|
998
|
+
: null;
|
|
999
|
+
return { url, file, loading, error, refetch };
|
|
759
1000
|
}
|
|
760
1001
|
|
|
761
|
-
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
|
|
1002
|
+
/* ============================================================================
|
|
1003
|
+
* DIRECTORY CLIENT — ctx.directory (@colixsystems/directory-client)
|
|
1004
|
+
*
|
|
1005
|
+
* Me, users, groups, invites. Covers:
|
|
1006
|
+
* useDirectory, useUsers, useGroups.
|
|
1007
|
+
* ==========================================================================*/
|
|
1008
|
+
|
|
767
1009
|
/**
|
|
768
1010
|
* REQ-USERMGMT / REQ-ACL-SYS M3 — structured error thrown by `useUsers` /
|
|
769
1011
|
* `useGroups` callbacks. Carries a stable `code` so widgets can branch
|
|
@@ -814,14 +1056,103 @@ function toDirectoryError(err) {
|
|
|
814
1056
|
return new DirectoryError(code, message, { cause: err });
|
|
815
1057
|
}
|
|
816
1058
|
|
|
1059
|
+
/**
|
|
1060
|
+
* Stateful user-directory query hook. Returns { users, loading, error,
|
|
1061
|
+
* refetch }.
|
|
1062
|
+
*
|
|
1063
|
+
* The host injects a `@colixsystems/directory-client` instance at
|
|
1064
|
+
* `ctx.directory`. We call `ctx.directory.users.list(query)`, which resolves
|
|
1065
|
+
* to the `{ data, meta }` list envelope VERBATIM; we unwrap `res.data`
|
|
1066
|
+
* (default `[]`) into the `users` slot. Rows are snake_case (`id`, `name`,
|
|
1067
|
+
* `role`, …) — the privacy-reduced directory projection the backend hands to
|
|
1068
|
+
* non-Studio (Player) callers. Use it to build a chat people-list, an
|
|
1069
|
+
* @-mention picker, or to resolve an author id to a display name. Mutating
|
|
1070
|
+
* users is NOT part of this surface; the directory is read-only here (see
|
|
1071
|
+
* useUsers for administration).
|
|
1072
|
+
*
|
|
1073
|
+
* `query` is an optional `{ q?, role?, is_active?, limit?, offset? }`
|
|
1074
|
+
* object passed through verbatim. The hook re-fetches whenever
|
|
1075
|
+
* `JSON.stringify(query)` changes and exposes `refetch` for on-demand
|
|
1076
|
+
* reloads (e.g. a chat roster refresh).
|
|
1077
|
+
*
|
|
1078
|
+
* Requires the `directory.read:users` scope in the widget manifest's
|
|
1079
|
+
* `requestedScopes`.
|
|
1080
|
+
*/
|
|
1081
|
+
export function useDirectory(query) {
|
|
1082
|
+
const ctx = useWidgetContextOrThrow("useDirectory");
|
|
1083
|
+
if (
|
|
1084
|
+
!ctx.directory ||
|
|
1085
|
+
!ctx.directory.users ||
|
|
1086
|
+
typeof ctx.directory.users.list !== "function"
|
|
1087
|
+
) {
|
|
1088
|
+
throw new Error(
|
|
1089
|
+
"useDirectory: host did not inject a directory client (ctx.directory.users.list)",
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
const [users, setUsers] = useState([]);
|
|
1093
|
+
const [loading, setLoading] = useState(true);
|
|
1094
|
+
const [error, setError] = useState(null);
|
|
1095
|
+
|
|
1096
|
+
// Same ref discipline as useDatastoreQuery: the host rebuilds the
|
|
1097
|
+
// WidgetContext value every render, so we capture the live query +
|
|
1098
|
+
// client in refs to keep `refetch` a stable identity.
|
|
1099
|
+
const queryRef = useRef(query);
|
|
1100
|
+
const usersNsRef = useRef(ctx.directory.users);
|
|
1101
|
+
queryRef.current = query;
|
|
1102
|
+
usersNsRef.current = ctx.directory.users;
|
|
1103
|
+
|
|
1104
|
+
const runRef = useRef(0);
|
|
1105
|
+
|
|
1106
|
+
const doFetch = useCallback(async () => {
|
|
1107
|
+
const myRun = ++runRef.current;
|
|
1108
|
+
setLoading(true);
|
|
1109
|
+
setError(null);
|
|
1110
|
+
try {
|
|
1111
|
+
const res = await usersNsRef.current.list(queryRef.current);
|
|
1112
|
+
// Directory list returns the { data, meta } envelope verbatim.
|
|
1113
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
1114
|
+
if (runRef.current !== myRun) return;
|
|
1115
|
+
setUsers(rows);
|
|
1116
|
+
setLoading(false);
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
if (runRef.current !== myRun) return;
|
|
1119
|
+
setError(toDatastoreError(err));
|
|
1120
|
+
setLoading(false);
|
|
1121
|
+
}
|
|
1122
|
+
}, []);
|
|
1123
|
+
|
|
1124
|
+
const queryKey = (() => {
|
|
1125
|
+
try {
|
|
1126
|
+
return JSON.stringify(query);
|
|
1127
|
+
} catch (_e) {
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
})();
|
|
1131
|
+
useEffect(() => {
|
|
1132
|
+
doFetch();
|
|
1133
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1134
|
+
}, [queryKey]);
|
|
1135
|
+
|
|
1136
|
+
const refetch = useCallback(async () => {
|
|
1137
|
+
await doFetch();
|
|
1138
|
+
}, [doFetch]);
|
|
1139
|
+
|
|
1140
|
+
return { users, loading, error, refetch };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
817
1143
|
/**
|
|
818
1144
|
* REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
|
|
819
1145
|
*
|
|
820
1146
|
* Returns `{ users, loading, error, refetch, invite, deactivate,
|
|
821
|
-
* reactivate, remove }`.
|
|
822
|
-
* `
|
|
823
|
-
* `
|
|
824
|
-
*
|
|
1147
|
+
* reactivate, remove }`. Reads through the injected
|
|
1148
|
+
* `@colixsystems/directory-client` at `ctx.directory.users.{list, get,
|
|
1149
|
+
* invite, deactivate, reactivate}` — `list` resolves to the `{ data, meta }`
|
|
1150
|
+
* envelope VERBATIM, so we unwrap `res.data` (default `[]`). User rows are
|
|
1151
|
+
* snake_case (`id`, `name`, `email`, `role`, `is_active`, …) and bodies
|
|
1152
|
+
* (e.g. `{ email, name, group_ids? }`) pass through verbatim. The list
|
|
1153
|
+
* refetches whenever `JSON.stringify(query)` changes; the imperative methods
|
|
1154
|
+
* reject with a `DirectoryError`. Reads require the `users.read:*` scope;
|
|
1155
|
+
* mutations additionally require `users.write:*`. The host's signed
|
|
825
1156
|
* `X-Widget-Scopes` header + a tenant-scoped SystemAcl `users.read` /
|
|
826
1157
|
* `users.write` capability grant gate the underlying endpoint
|
|
827
1158
|
* (REQ-ACL-SYS M3 §4.3) — a widget that declares the scopes but whose
|
|
@@ -829,17 +1160,23 @@ function toDirectoryError(err) {
|
|
|
829
1160
|
*/
|
|
830
1161
|
export function useUsers(query) {
|
|
831
1162
|
const ctx = useWidgetContextOrThrow("useUsers");
|
|
832
|
-
if (
|
|
833
|
-
|
|
1163
|
+
if (
|
|
1164
|
+
!ctx.directory ||
|
|
1165
|
+
!ctx.directory.users ||
|
|
1166
|
+
typeof ctx.directory.users.list !== "function"
|
|
1167
|
+
) {
|
|
1168
|
+
throw new Error(
|
|
1169
|
+
"useUsers: host did not inject a directory client (ctx.directory.users)",
|
|
1170
|
+
);
|
|
834
1171
|
}
|
|
835
1172
|
const [users, setUsers] = useState([]);
|
|
836
1173
|
const [loading, setLoading] = useState(true);
|
|
837
1174
|
const [error, setError] = useState(null);
|
|
838
1175
|
|
|
839
1176
|
const queryRef = useRef(query);
|
|
840
|
-
const usersRef = useRef(ctx.users);
|
|
1177
|
+
const usersRef = useRef(ctx.directory.users);
|
|
841
1178
|
queryRef.current = query;
|
|
842
|
-
usersRef.current = ctx.users;
|
|
1179
|
+
usersRef.current = ctx.directory.users;
|
|
843
1180
|
|
|
844
1181
|
const runRef = useRef(0);
|
|
845
1182
|
|
|
@@ -848,9 +1185,11 @@ export function useUsers(query) {
|
|
|
848
1185
|
setLoading(true);
|
|
849
1186
|
setError(null);
|
|
850
1187
|
try {
|
|
851
|
-
const
|
|
1188
|
+
const res = await usersRef.current.list(queryRef.current);
|
|
1189
|
+
// Directory users.list returns the { data, meta } envelope verbatim.
|
|
1190
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
852
1191
|
if (runRef.current !== myRun) return;
|
|
853
|
-
setUsers(
|
|
1192
|
+
setUsers(rows);
|
|
854
1193
|
setLoading(false);
|
|
855
1194
|
} catch (err) {
|
|
856
1195
|
if (runRef.current !== myRun) return;
|
|
@@ -911,22 +1250,33 @@ export function useUsers(query) {
|
|
|
911
1250
|
* REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration hook.
|
|
912
1251
|
*
|
|
913
1252
|
* Returns `{ groups, loading, error, refetch, create, remove, addMember,
|
|
914
|
-
* removeMember }`. Reads
|
|
915
|
-
* `groups.
|
|
1253
|
+
* removeMember }`. Reads through the injected
|
|
1254
|
+
* `@colixsystems/directory-client` at `ctx.directory.groups.{list, create,
|
|
1255
|
+
* remove, addMember, removeMember, listMine}` — `list` resolves to the
|
|
1256
|
+
* `{ data, meta }` envelope VERBATIM, so we unwrap `res.data` (default `[]`).
|
|
1257
|
+
* Group rows are snake_case and bodies pass through verbatim. Reads require
|
|
1258
|
+
* `groups.read:*`; mutations require `groups.write:*`. Same X-Widget-Scopes +
|
|
1259
|
+
* SystemAcl gating as `useUsers`.
|
|
916
1260
|
*/
|
|
917
1261
|
export function useGroups(query) {
|
|
918
1262
|
const ctx = useWidgetContextOrThrow("useGroups");
|
|
919
|
-
if (
|
|
920
|
-
|
|
1263
|
+
if (
|
|
1264
|
+
!ctx.directory ||
|
|
1265
|
+
!ctx.directory.groups ||
|
|
1266
|
+
typeof ctx.directory.groups.list !== "function"
|
|
1267
|
+
) {
|
|
1268
|
+
throw new Error(
|
|
1269
|
+
"useGroups: host did not inject a directory client (ctx.directory.groups)",
|
|
1270
|
+
);
|
|
921
1271
|
}
|
|
922
1272
|
const [groups, setGroups] = useState([]);
|
|
923
1273
|
const [loading, setLoading] = useState(true);
|
|
924
1274
|
const [error, setError] = useState(null);
|
|
925
1275
|
|
|
926
1276
|
const queryRef = useRef(query);
|
|
927
|
-
const groupsRef = useRef(ctx.groups);
|
|
1277
|
+
const groupsRef = useRef(ctx.directory.groups);
|
|
928
1278
|
queryRef.current = query;
|
|
929
|
-
groupsRef.current = ctx.groups;
|
|
1279
|
+
groupsRef.current = ctx.directory.groups;
|
|
930
1280
|
|
|
931
1281
|
const runRef = useRef(0);
|
|
932
1282
|
|
|
@@ -935,9 +1285,11 @@ export function useGroups(query) {
|
|
|
935
1285
|
setLoading(true);
|
|
936
1286
|
setError(null);
|
|
937
1287
|
try {
|
|
938
|
-
const
|
|
1288
|
+
const res = await groupsRef.current.list(queryRef.current);
|
|
1289
|
+
// Directory groups.list returns the { data, meta } envelope verbatim.
|
|
1290
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
939
1291
|
if (runRef.current !== myRun) return;
|
|
940
|
-
setGroups(
|
|
1292
|
+
setGroups(rows);
|
|
941
1293
|
setLoading(false);
|
|
942
1294
|
} catch (err) {
|
|
943
1295
|
if (runRef.current !== myRun) return;
|
|
@@ -994,33 +1346,35 @@ export function useGroups(query) {
|
|
|
994
1346
|
return { groups, loading, error, refetch, create, remove, addMember, removeMember };
|
|
995
1347
|
}
|
|
996
1348
|
|
|
1349
|
+
/* ============================================================================
|
|
1350
|
+
* PAYMENTS CLIENT — ctx.payments (@colixsystems/payments-client)
|
|
1351
|
+
*
|
|
1352
|
+
* requestPayment, getPayment. Covers: usePayments.
|
|
1353
|
+
* ==========================================================================*/
|
|
1354
|
+
|
|
997
1355
|
/**
|
|
998
|
-
*
|
|
999
|
-
* `
|
|
1000
|
-
* `error` slot when the initial list fetch fails). Carries a stable
|
|
1001
|
-
* `code` so widgets can branch on the error class without parsing
|
|
1002
|
-
* message strings; mirrors the shape of `DirectoryError`.
|
|
1356
|
+
* Structured error thrown by `usePayments` callbacks. Carries a stable
|
|
1357
|
+
* `code` so widgets can branch without parsing message strings.
|
|
1003
1358
|
*
|
|
1004
1359
|
* `code` is one of:
|
|
1005
|
-
* - "
|
|
1006
|
-
* - "
|
|
1007
|
-
* - "
|
|
1008
|
-
*
|
|
1009
|
-
* - "
|
|
1010
|
-
* - "INTERNAL"
|
|
1360
|
+
* - "AUTH_REQUIRED" — no signed-in app user
|
|
1361
|
+
* - "PAYMENTS_SCOPE_NOT_GRANTED"— widget lacks payments.charge:appUser
|
|
1362
|
+
* - "INVALID_AMOUNT" / "AMOUNT_TOO_LARGE" / "VALIDATION" — bad request
|
|
1363
|
+
* - "CONNECT_NOT_READY" / "PAYMENTS_DISABLED" — provider not ready
|
|
1364
|
+
* - "DECLINED" — the charge was declined
|
|
1365
|
+
* - "INTERNAL" — anything else
|
|
1011
1366
|
*/
|
|
1012
|
-
export class
|
|
1367
|
+
export class PaymentError extends Error {
|
|
1013
1368
|
constructor(code, message, opts) {
|
|
1014
1369
|
super(message);
|
|
1015
|
-
this.name = "
|
|
1370
|
+
this.name = "PaymentError";
|
|
1016
1371
|
this.code = code;
|
|
1017
|
-
if (opts && opts.status !== undefined) this.status = opts.status;
|
|
1018
1372
|
if (opts && opts.cause) this.cause = opts.cause;
|
|
1019
1373
|
}
|
|
1020
1374
|
}
|
|
1021
1375
|
|
|
1022
|
-
function
|
|
1023
|
-
if (err instanceof
|
|
1376
|
+
function toPaymentError(err) {
|
|
1377
|
+
if (err instanceof PaymentError) return err;
|
|
1024
1378
|
const status =
|
|
1025
1379
|
err && err.response && typeof err.response.status === "number"
|
|
1026
1380
|
? err.response.status
|
|
@@ -1030,219 +1384,76 @@ function toPermissionError(err) {
|
|
|
1030
1384
|
const bodyMessage =
|
|
1031
1385
|
err && err.response && err.response.data && err.response.data.error;
|
|
1032
1386
|
let code = "INTERNAL";
|
|
1033
|
-
if (
|
|
1034
|
-
else if (status ===
|
|
1035
|
-
else if (status ===
|
|
1036
|
-
else if (status ===
|
|
1037
|
-
if (
|
|
1038
|
-
// Preserve the server's stable code over the status-derived one when
|
|
1039
|
-
// the server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of
|
|
1040
|
-
// an inherit/template row).
|
|
1041
|
-
code = bodyCode;
|
|
1042
|
-
}
|
|
1387
|
+
if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
|
|
1388
|
+
else if (status === 401) code = "AUTH_REQUIRED";
|
|
1389
|
+
else if (status === 402) code = "DECLINED";
|
|
1390
|
+
else if (status === 403) code = "FORBIDDEN";
|
|
1391
|
+
else if (status === 400) code = "VALIDATION";
|
|
1043
1392
|
const message =
|
|
1044
1393
|
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
1045
1394
|
(err && typeof err.message === "string"
|
|
1046
1395
|
? err.message
|
|
1047
|
-
: "
|
|
1048
|
-
return new
|
|
1396
|
+
: "Payment request failed");
|
|
1397
|
+
return new PaymentError(code, message, { cause: err });
|
|
1049
1398
|
}
|
|
1050
1399
|
|
|
1051
|
-
const _NOOP_PERMISSIONS_RESULT = Object.freeze({
|
|
1052
|
-
permissions: [],
|
|
1053
|
-
loading: false,
|
|
1054
|
-
error: null,
|
|
1055
|
-
// Mutation no-ops resolve with null so a widget that does
|
|
1056
|
-
// `await grant(...)` doesn't crash when called against an unbound
|
|
1057
|
-
// (tableId / recordId null) hook.
|
|
1058
|
-
grant: async () => null,
|
|
1059
|
-
revoke: async () => undefined,
|
|
1060
|
-
update: async () => null,
|
|
1061
|
-
refetch: async () => undefined,
|
|
1062
|
-
});
|
|
1063
|
-
|
|
1064
1400
|
/**
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1067
|
-
* `{ permissions, loading, error, grant, revoke, update, refetch }`.
|
|
1068
|
-
*
|
|
1069
|
-
* `permissions` is an array of rows:
|
|
1070
|
-
* `{ id, principalType: "USER" | "GROUP" | "PUBLIC", principalId, canRead, canWrite, canDelete, canGrant }`.
|
|
1071
|
-
*
|
|
1072
|
-
* The host's `ctx.recordPermissions` facade exposes:
|
|
1073
|
-
* - `list(tableId, recordId)` → `Promise<row[]>`
|
|
1074
|
-
* - `grant(tableId, recordId, body)` → `Promise<row>`
|
|
1075
|
-
* - `revoke(tableId, recordId, permissionId)` → `Promise<void>`
|
|
1076
|
-
* - `update(tableId, recordId, permissionId, body)` → `Promise<row>`
|
|
1077
|
-
*
|
|
1078
|
-
* The hook normalises the wire shape (`userId` / `groupId` / both-null =
|
|
1079
|
-
* public) into a single `{ principalType, principalId }` pair that
|
|
1080
|
-
* widgets can branch on without knowing the backend's column names.
|
|
1401
|
+
* Incoming app-user payments (REQ-BILL-07-WIDGETPAY). Returns
|
|
1402
|
+
* `{ requestPayment, getPayment }`.
|
|
1081
1403
|
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
*
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
1404
|
+
* requestPayment({ amountCents, currency?, description, metadata? })
|
|
1405
|
+
* → Promise<{ id, status, checkoutUrl?, ... }>. The host either
|
|
1406
|
+
* auto-confirms (mock provider, `status: "PAID"`, no redirect) or
|
|
1407
|
+
* returns a hosted-Checkout `checkoutUrl` the widget should open
|
|
1408
|
+
* (Stripe provider, `status: "PENDING"`). Rejects with a
|
|
1409
|
+
* `PaymentError`.
|
|
1410
|
+
* getPayment(paymentId) → Promise<payment> — poll the terminal status.
|
|
1087
1411
|
*
|
|
1088
|
-
* Requires the `
|
|
1089
|
-
* `requestedScopes`. The
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
* A widget that declares the scope but whose caller lacks the grant
|
|
1093
|
-
* receives `PermissionError { code: "FORBIDDEN" }`.
|
|
1412
|
+
* Requires the `payments.charge:appUser` scope in the manifest's
|
|
1413
|
+
* `requestedScopes`. The charge settles to the workspace owner; the app
|
|
1414
|
+
* user confirms the amount in hosted Checkout. No card data touches the
|
|
1415
|
+
* widget — never collect card fields yourself.
|
|
1094
1416
|
*/
|
|
1095
|
-
export function
|
|
1096
|
-
const ctx = useWidgetContextOrThrow("
|
|
1097
|
-
if (
|
|
1098
|
-
!ctx.recordPermissions ||
|
|
1099
|
-
typeof ctx.recordPermissions.list !== "function" ||
|
|
1100
|
-
typeof ctx.recordPermissions.grant !== "function" ||
|
|
1101
|
-
typeof ctx.recordPermissions.revoke !== "function" ||
|
|
1102
|
-
typeof ctx.recordPermissions.update !== "function"
|
|
1103
|
-
) {
|
|
1417
|
+
export function usePayments() {
|
|
1418
|
+
const ctx = useWidgetContextOrThrow("usePayments");
|
|
1419
|
+
if (!ctx.payments || typeof ctx.payments.requestPayment !== "function") {
|
|
1104
1420
|
throw new Error(
|
|
1105
|
-
"
|
|
1421
|
+
"usePayments: host did not inject a payments client. The widget must " +
|
|
1422
|
+
"declare the payments.charge:appUser scope and be installed in a " +
|
|
1423
|
+
"workspace whose host supports payments.",
|
|
1106
1424
|
);
|
|
1107
1425
|
}
|
|
1108
|
-
const
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
const clientRef = useRef(ctx.recordPermissions);
|
|
1120
|
-
tableIdRef.current = tableId;
|
|
1121
|
-
recordIdRef.current = recordId;
|
|
1122
|
-
clientRef.current = ctx.recordPermissions;
|
|
1123
|
-
|
|
1124
|
-
const runRef = useRef(0);
|
|
1426
|
+
const requestRef = useRef(ctx.payments.requestPayment);
|
|
1427
|
+
const getRef = useRef(
|
|
1428
|
+
typeof ctx.payments.getPayment === "function"
|
|
1429
|
+
? ctx.payments.getPayment
|
|
1430
|
+
: null,
|
|
1431
|
+
);
|
|
1432
|
+
requestRef.current = ctx.payments.requestPayment;
|
|
1433
|
+
getRef.current =
|
|
1434
|
+
typeof ctx.payments.getPayment === "function"
|
|
1435
|
+
? ctx.payments.getPayment
|
|
1436
|
+
: null;
|
|
1125
1437
|
|
|
1126
|
-
const
|
|
1127
|
-
const myRun = ++runRef.current;
|
|
1128
|
-
const t = tableIdRef.current;
|
|
1129
|
-
const r = recordIdRef.current;
|
|
1130
|
-
if (!t || !r) {
|
|
1131
|
-
// Collapse to a stable empty result without a network round-trip
|
|
1132
|
-
// so a widget rendering before an active record is picked stays
|
|
1133
|
-
// loop-free.
|
|
1134
|
-
setLoading(false);
|
|
1135
|
-
setError(null);
|
|
1136
|
-
setPermissions([]);
|
|
1137
|
-
return;
|
|
1138
|
-
}
|
|
1139
|
-
setLoading(true);
|
|
1140
|
-
setError(null);
|
|
1438
|
+
const requestPayment = useCallback(async (args) => {
|
|
1141
1439
|
try {
|
|
1142
|
-
|
|
1143
|
-
if (runRef.current !== myRun) return;
|
|
1144
|
-
setPermissions(Array.isArray(rows) ? rows : []);
|
|
1145
|
-
setLoading(false);
|
|
1440
|
+
return await requestRef.current(args);
|
|
1146
1441
|
} catch (err) {
|
|
1147
|
-
|
|
1148
|
-
setError(toPermissionError(err));
|
|
1149
|
-
setLoading(false);
|
|
1442
|
+
throw toPaymentError(err);
|
|
1150
1443
|
}
|
|
1151
1444
|
}, []);
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
setPermissions([]);
|
|
1159
|
-
setLoading(false);
|
|
1160
|
-
setError(null);
|
|
1161
|
-
return;
|
|
1162
|
-
}
|
|
1163
|
-
doFetch();
|
|
1164
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1165
|
-
}, [tableId, recordId]);
|
|
1166
|
-
|
|
1167
|
-
const refetch = useCallback(async () => {
|
|
1168
|
-
await doFetch();
|
|
1169
|
-
}, [doFetch]);
|
|
1170
|
-
|
|
1171
|
-
const grant = useCallback(async (body) => {
|
|
1172
|
-
const t = tableIdRef.current;
|
|
1173
|
-
const r = recordIdRef.current;
|
|
1174
|
-
if (!t || !r) return null;
|
|
1175
|
-
try {
|
|
1176
|
-
const row = await clientRef.current.grant(t, r, body);
|
|
1177
|
-
// Refresh after the mutation so subsequent reads observe the
|
|
1178
|
-
// grant. The host's facade returns the created row too, which we
|
|
1179
|
-
// hand back to the caller for inline use.
|
|
1180
|
-
await doFetch();
|
|
1181
|
-
return row;
|
|
1182
|
-
} catch (err) {
|
|
1183
|
-
throw toPermissionError(err);
|
|
1184
|
-
}
|
|
1185
|
-
}, [doFetch]);
|
|
1186
|
-
|
|
1187
|
-
const revoke = useCallback(async (permissionId) => {
|
|
1188
|
-
const t = tableIdRef.current;
|
|
1189
|
-
const r = recordIdRef.current;
|
|
1190
|
-
if (!t || !r) return undefined;
|
|
1191
|
-
try {
|
|
1192
|
-
await clientRef.current.revoke(t, r, permissionId);
|
|
1193
|
-
await doFetch();
|
|
1194
|
-
} catch (err) {
|
|
1195
|
-
throw toPermissionError(err);
|
|
1445
|
+
const getPayment = useCallback(async (paymentId) => {
|
|
1446
|
+
if (!getRef.current) {
|
|
1447
|
+
throw new PaymentError(
|
|
1448
|
+
"INTERNAL",
|
|
1449
|
+
"getPayment is not supported by this host.",
|
|
1450
|
+
);
|
|
1196
1451
|
}
|
|
1197
|
-
}, [doFetch]);
|
|
1198
|
-
|
|
1199
|
-
const update = useCallback(async (permissionId, body) => {
|
|
1200
|
-
const t = tableIdRef.current;
|
|
1201
|
-
const r = recordIdRef.current;
|
|
1202
|
-
if (!t || !r) return null;
|
|
1203
1452
|
try {
|
|
1204
|
-
|
|
1205
|
-
await doFetch();
|
|
1206
|
-
return row;
|
|
1453
|
+
return await getRef.current(paymentId);
|
|
1207
1454
|
} catch (err) {
|
|
1208
|
-
throw
|
|
1455
|
+
throw toPaymentError(err);
|
|
1209
1456
|
}
|
|
1210
|
-
}, [
|
|
1211
|
-
|
|
1212
|
-
if (!ready) {
|
|
1213
|
-
// Mutation no-ops are safe: a widget that does
|
|
1214
|
-
// `await grant({...})` against an unbound hook resolves to null
|
|
1215
|
-
// rather than throwing. Same shape as the populated case so the
|
|
1216
|
-
// caller can branch on `tableId/recordId` once, at the top.
|
|
1217
|
-
return _NOOP_PERMISSIONS_RESULT;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
return { permissions, loading, error, grant, revoke, update, refetch };
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
export function useI18n() {
|
|
1224
|
-
const ctx = useWidgetContextOrThrow("useI18n");
|
|
1225
|
-
const i18n = ctx.i18n || {};
|
|
1226
|
-
const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
|
|
1227
|
-
const hostT = typeof i18n.t === "function" ? i18n.t : null;
|
|
1228
|
-
const t = useCallback(
|
|
1229
|
-
(key, fallback) => {
|
|
1230
|
-
if (typeof key !== "string" || !key) {
|
|
1231
|
-
return typeof fallback === "string" ? fallback : "";
|
|
1232
|
-
}
|
|
1233
|
-
if (hostT) {
|
|
1234
|
-
// Try the two-arg form first; if the host's `t` ignores `fallback`
|
|
1235
|
-
// and returns the bare key on a miss, swap in the fallback.
|
|
1236
|
-
const out = hostT(key, fallback);
|
|
1237
|
-
if (typeof out === "string" && out.length > 0 && out !== key) {
|
|
1238
|
-
return out;
|
|
1239
|
-
}
|
|
1240
|
-
// Host returned the key (unresolved) or nothing usable.
|
|
1241
|
-
return typeof fallback === "string" ? fallback : key;
|
|
1242
|
-
}
|
|
1243
|
-
return typeof fallback === "string" ? fallback : key;
|
|
1244
|
-
},
|
|
1245
|
-
[hostT],
|
|
1246
|
-
);
|
|
1247
|
-
return { t, locale };
|
|
1457
|
+
}, []);
|
|
1458
|
+
return { requestPayment, getPayment };
|
|
1248
1459
|
}
|