@colixsystems/widget-sdk 0.76.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -3
- package/dist/contract.cjs +79 -1
- package/dist/contract.js +79 -1
- package/dist/hooks.js +303 -0
- package/dist/index.d.ts +139 -0
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
22
22
|
| **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
|
|
23
23
|
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
|
|
24
24
|
| **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
|
|
25
|
+
| **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
|
|
25
26
|
| **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
|
|
26
27
|
| **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
|
|
27
28
|
| **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
|
|
@@ -45,6 +46,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
45
46
|
| **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId? })` | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
|
|
46
47
|
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser` |
|
|
47
48
|
| **NOTIFICATIONS** (`ctx.notifications`) | `useSendNotification()` | `{ send, sending, error }` | `ctx.notifications.send` — `notifications.send:appUser`. `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace; call from an event handler (never render); rejects with `NotificationError`. |
|
|
49
|
+
| **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider?, purpose?, pollIntervalMs? })` | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |
|
|
48
50
|
|
|
49
51
|
All list calls return the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you. There is no `useWorkspace()` or `useLogger()` hook — read the theme via `useTheme()` and the locale via `useI18n()`; the host logger lives on `ctx.logger` (`{ debug, info, warn, error }`).
|
|
50
52
|
|
|
@@ -54,7 +56,32 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
54
56
|
|
|
55
57
|
## Status
|
|
56
58
|
|
|
57
|
-
`v0.
|
|
59
|
+
`v0.77.0` — 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**.
|
|
60
|
+
|
|
61
|
+
### What's new in 0.78.0
|
|
62
|
+
|
|
63
|
+
**New `useIdentification()` hook — identify a visitor who is NOT signed in (REQ-IDENT, sc-4313).** A new IDENTIFICATION hook reading a newly-injected `ctx.identification` slice (the new `@colixsystems/identification-client`, constructed by both the web Player and the native Expo export). Returns `{ available, availabilityLoading, status, qr, autoStartToken, message, identity, identificationId, loading, error, start, refresh, cancel, reset }`.
|
|
64
|
+
|
|
65
|
+
It exists to **prove presence and keep the result** — an attestation on a record, a consent line, an identity check before a submit. It creates **no account and no session**: to sign someone *in* use the app's login, to attach BankID to an existing account use `useBankIdLink()`, and to e-sign a file's bytes use `useFileSignature()`.
|
|
66
|
+
|
|
67
|
+
**BankID is the first provider**, and the API is provider-abstracted — a future provider becomes available without a widget change (`options.provider` defaults to `"bankid"`).
|
|
68
|
+
|
|
69
|
+
Gate the UI on `available`: when it is `false` the provider is not configured on the deployment and no QR can ever complete, so render nothing rather than a dead button. `start()` opens an order and **the hook polls it to completion for you** (`pollIntervalMs`, default `1000`; pass `0` to drive `refresh()` yourself), clearing its timer on unmount — a widget renders state instead of owning a loop. Render `qr` with the `Image` primitive and show `message` (a display-ready instruction); `autoStartToken` opens the provider app on the same device.
|
|
70
|
+
|
|
71
|
+
**No raw personal number is reachable from a widget.** On completion `identity` is `{ provider, name, given_name, surname, personal_number_masked, subject_hash, identified_at }` — `personal_number_masked` is `"19900101-****"` and `subject_hash` is stable for the same person, so a returning visitor is recognisable without the number. The full value stays server-side behind a studio-admin endpoint, so it can never end up in page JSON or a datastore column by accident. Write the masked string (and `identificationId`, to trace the proof) into your column.
|
|
72
|
+
|
|
73
|
+
`options.purpose` is a short audit label ("attest", "age_check"), capped at 120 characters. Orders expire five minutes after `start()`. It needs **no manifest scope** and **no `requestedScopes` entry** — requiring one would defeat a flow whose whole point is an anonymous visitor. Rejections surface as a structured `IdentificationError` (new named export) with a stable `.code` (`NOT_CONFIGURED` / `UNKNOWN_PROVIDER` / `NOT_FOUND` / `RATE_LIMITED` / `UNAVAILABLE` / `INTERNAL`). `CONTRACT.version` → `1.52.0`. Additive — one new hook, one new context slice, one new error class, one new client package; no existing export changed signature.
|
|
74
|
+
|
|
75
|
+
### What's new in 0.77.0
|
|
76
|
+
|
|
77
|
+
**`ui.group` is a layout hint, not a visibility rule (sc-4176).** 0.75.0 gave `"Basics"` a reserved meaning: Agent Mode's in-preview edit panel rendered only that group and pointed the author at the Builder for the rest. That withheld styling from an author already editing the widget in front of them, so the reserved behaviour is **retired**.
|
|
78
|
+
|
|
79
|
+
- **Both Style surfaces now render your whole `styleSchema`** — the Builder's Properties panel and the in-preview edit panel show the same fields, as one labelled fieldset per group.
|
|
80
|
+
- **Keep grouping.** It is what makes a 10–12 field Style section readable: "Basics / Card / Title" is three scannable decisions where a flat list is a wall of inputs. Group all your fields or none, name each group after an element the author can see, and keep `"Basics"` for the three whole-widget fields so they read first.
|
|
81
|
+
- **No group name is special any more.** Nothing you put in — or leave out of — a group changes whether an author can reach a field. Do not try to hide an advanced knob by grouping it.
|
|
82
|
+
- **Nothing else changes.** `themeDefault` / `default` placeholders (0.76.0) apply to every field in every group, and an ungrouped `styleSchema` renders exactly as it always has.
|
|
83
|
+
|
|
84
|
+
Docs-only correction of documented host behaviour: no export, type, runtime, or `CONTRACT` field changed; `CONTRACT.version` stays `1.51.0`.
|
|
58
85
|
|
|
59
86
|
### What's new in 0.76.0
|
|
60
87
|
|
|
@@ -72,7 +99,7 @@ Types-only addition: no export, runtime behaviour, or `CONTRACT` field changed;
|
|
|
72
99
|
**`ui.group: "Basics"` marks a widget's quick style knobs (sc-4100).** A `styleSchema` field has always accepted the `propertySchema` `ui.group` key; the Studio now reads one reserved group name from it, so a widget with several styleable elements can expose per-element controls without burying the two or three an author reaches for first.
|
|
73
100
|
|
|
74
101
|
- **Group every style field, or none of them.** In a grouped `styleSchema`, put the whole-widget basics (`background`, `textColor`, `align`) under the exact group `"Basics"` and each per-element field under a group named for the element it styles (`"Card"`, `"Title"`, `"Chip"`, …). Keep the total under about 12 fields.
|
|
75
|
-
- **Where each surface renders.** The Builder's Properties panel renders **every** group. Agent Mode's in-preview edit panel — a quick-tweak surface — renders **only** `"Basics"` and points the author at the Builder for the rest.
|
|
102
|
+
- **Where each surface renders.** ~~The Builder's Properties panel renders **every** group. Agent Mode's in-preview edit panel — a quick-tweak surface — renders **only** `"Basics"` and points the author at the Builder for the rest.~~ **Superseded in 0.77.0:** both surfaces render every group. `ui.group` is a layout hint, not a visibility rule.
|
|
76
103
|
- **Nothing changes for an ungrouped `styleSchema`.** A flat schema (no `ui.group` anywhere) renders in full in both surfaces exactly as before, so every already-published widget is unaffected. A grouped schema that declares no `"Basics"` group also renders in full — the trim needs a group to trim *to*.
|
|
77
104
|
|
|
78
105
|
No export, type, hook, or `CONTRACT` field changed; `CONTRACT.version` stays `1.51.0`. This is an additive host convention over an existing manifest key.
|
|
@@ -511,6 +538,7 @@ The "split-implementation + vetted package list" pivot.
|
|
|
511
538
|
### What's new in 0.11.0
|
|
512
539
|
|
|
513
540
|
- **`useNavigation()` is wired.** Returns the host-provided navigation surface `{ goTo, goBack, push, replace, back, currentRoute }` for internal page-to-page navigation. Missing methods degrade to no-ops on the Studio canvas preview. Additive.
|
|
541
|
+
- **`usePageContext()` reads the page's DECLARED parameters.** When a page declares parameters (Page Settings → Parameters), the host resolves them ONCE before any widget renders and fetches each `record` parameter's row for the whole page. `params` holds the values coerced to their declared types (a `number` parameter is a number, not the string `useRouteParams()` returns); `records` maps each `record` parameter to its already-loaded row — read it rather than issuing the same request from every widget. A required parameter that is absent, malformed, or whose record does not resolve never reaches the widget: the host renders one page-level state instead. Both bags are empty on a page that declares nothing, so fall back to `useRouteParams()` for a page you do not control. Additive (v0.77.0).
|
|
514
542
|
- **`useRouteParams()` reads the nav params.** Returns `currentRoute.params` — the bag a `goTo(pageId, params)` carried to this page. The flat accessor for master→detail: navigate with `goTo(detailPageId, { recordId: row.id })`, then read `const { recordId } = useRouteParams()`. It is an OBJECT — read a param off it, never call it. Empty object when the page was opened without params. Additive (v0.61.0).
|
|
515
543
|
- **`Linking` primitive re-exported.** `Linking.openURL(url)` opens an external URL with the OS handler — web (`react-native-web`) maps to `window.open` / `location.href`; native hands off to the system. Use this for external URLs; use `useNavigation().goTo(pageId)` for internal pages.
|
|
516
544
|
|
|
@@ -584,7 +612,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
584
612
|
|
|
585
613
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
586
614
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
587
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `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`. `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `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)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(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).
|
|
615
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `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`. `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `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)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(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).
|
|
588
616
|
- `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.
|
|
589
617
|
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
|
|
590
618
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
package/dist/contract.cjs
CHANGED
|
@@ -382,6 +382,18 @@ const HOOKS = [
|
|
|
382
382
|
requiredContextSlice: ["navigation"],
|
|
383
383
|
scopes: null,
|
|
384
384
|
},
|
|
385
|
+
{
|
|
386
|
+
name: "usePageContext",
|
|
387
|
+
signature: "usePageContext()",
|
|
388
|
+
returnShape: {
|
|
389
|
+
params:
|
|
390
|
+
"the page's DECLARED parameters, coerced to their declared types (a number param is a number here, not the string useRouteParams() returns). Empty when the page declares none.",
|
|
391
|
+
records:
|
|
392
|
+
"<paramName> → the row the HOST already fetched for a `record` parameter. Read it instead of fetching the same record again; empty when the page declares none.",
|
|
393
|
+
},
|
|
394
|
+
requiredContextSlice: ["pageContext"],
|
|
395
|
+
scopes: null,
|
|
396
|
+
},
|
|
385
397
|
{
|
|
386
398
|
name: "useDatastoreRecord",
|
|
387
399
|
signature: "useDatastoreRecord(tableId, recordId)",
|
|
@@ -834,6 +846,55 @@ const HOOKS = [
|
|
|
834
846
|
requiredContextSlice: ["directory.bankid"],
|
|
835
847
|
scopes: null,
|
|
836
848
|
},
|
|
849
|
+
// REQ-IDENT (sc-4313) — identify a visitor who is NOT signed in and keep the
|
|
850
|
+
// result. Anonymous by design (no widget scope, no session). Mirror of contract.js.
|
|
851
|
+
{
|
|
852
|
+
name: "useIdentification",
|
|
853
|
+
signature: "useIdentification(options?)",
|
|
854
|
+
description:
|
|
855
|
+
"Identify a visitor who is NOT signed in via the injected " +
|
|
856
|
+
"identification-client at ctx.identification.{available,start,get,cancel}. " +
|
|
857
|
+
"Returns { available, availabilityLoading, status, qr, autoStartToken, " +
|
|
858
|
+
"message, identity, identificationId, loading, error, start, refresh, " +
|
|
859
|
+
"cancel, reset }. USE IT TO PROVE PRESENCE AND KEEP THE RESULT — an " +
|
|
860
|
+
"attestation on a record, a consent line, an identity check before a " +
|
|
861
|
+
"submit. It creates NO account and NO session: to sign someone IN use the " +
|
|
862
|
+
"app's login, to attach BankID to an existing account use useBankIdLink(), " +
|
|
863
|
+
"to e-sign a file use useFileSignature(). On mount it reads provider " +
|
|
864
|
+
"availability; `available` is false when identification can't be used here " +
|
|
865
|
+
"(provider not configured) — render nothing rather than a dead button. " +
|
|
866
|
+
"start() opens an order (status 'pending' + qr, a PNG data-URL to render " +
|
|
867
|
+
"with the Image primitive) and the hook then POLLS to completion itself " +
|
|
868
|
+
"(pollIntervalMs, default 1000; pass 0 to drive refresh() yourself); " +
|
|
869
|
+
"cancel() aborts; reset() clears the flow. autoStartToken opens the " +
|
|
870
|
+
"provider app on the SAME device (bankid:///?autostarttoken=<token>" +
|
|
871
|
+
"&redirect=null). On complete, `identity` is { provider, name, given_name, " +
|
|
872
|
+
"surname, personal_number_masked, subject_hash, identified_at } — there is " +
|
|
873
|
+
"NO raw personal number by design: write personal_number_masked " +
|
|
874
|
+
"(\"19900101-****\") and, if you need to trace the proof, identificationId. " +
|
|
875
|
+
"subject_hash is stable per person so a returning visitor is recognisable " +
|
|
876
|
+
"without the number. `options.purpose` is a short audit label capped at 120 " +
|
|
877
|
+
"chars. Orders expire 5 minutes after start(). No requestedScopes entry needed.",
|
|
878
|
+
returnShape: {
|
|
879
|
+
available: "boolean // false → don't offer the flow",
|
|
880
|
+
availabilityLoading: "boolean",
|
|
881
|
+
status: "'pending' | 'complete' | 'failed' | 'cancelled' | null",
|
|
882
|
+
qr: "string | null // PNG data-URL of the animated QR",
|
|
883
|
+
autoStartToken: "string | null // same-device deeplink token",
|
|
884
|
+
message: "string | null // display-ready instruction for this step",
|
|
885
|
+
identity:
|
|
886
|
+
"{ provider, name, given_name, surname, personal_number_masked, subject_hash, identified_at } | null",
|
|
887
|
+
identificationId: "string | null // store alongside an attestation",
|
|
888
|
+
loading: "boolean",
|
|
889
|
+
error: "IdentificationError | null",
|
|
890
|
+
start: "() => Promise<{ identification_id, status, qr, auto_start_token, expires_at }>",
|
|
891
|
+
refresh: "() => Promise<void>",
|
|
892
|
+
cancel: "() => Promise<void>",
|
|
893
|
+
reset: "() => void",
|
|
894
|
+
},
|
|
895
|
+
requiredContextSlice: ["identification"],
|
|
896
|
+
scopes: null,
|
|
897
|
+
},
|
|
837
898
|
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
|
838
899
|
// management for a single record. Mirror of contract.js.
|
|
839
900
|
{
|
|
@@ -1306,6 +1367,16 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1306
1367
|
required: true,
|
|
1307
1368
|
fields: { push: "function", replace: "function", back: "function" },
|
|
1308
1369
|
},
|
|
1370
|
+
pageContext: {
|
|
1371
|
+
description:
|
|
1372
|
+
"REQ-NAV-05 — the PAGE's resolved parameters. " +
|
|
1373
|
+
"{ params: { <name>: value }, records: { <name>: Record|null } }. " +
|
|
1374
|
+
"`params` are the page's DECLARED parameters coerced to their declared types (a number param is a number, not the string useRouteParams() returns). " +
|
|
1375
|
+
"`records` holds the row the HOST already fetched for each `record` parameter — read it instead of fetching the same record again. " +
|
|
1376
|
+
"Both bags are empty on a page that declares no parameters. Backs usePageContext().",
|
|
1377
|
+
required: true,
|
|
1378
|
+
fields: { params: "object", records: "object" },
|
|
1379
|
+
},
|
|
1309
1380
|
datastore: {
|
|
1310
1381
|
description:
|
|
1311
1382
|
"Injected @colixsystems/datastore-client instance. " +
|
|
@@ -1385,6 +1456,13 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1385
1456
|
required: true,
|
|
1386
1457
|
fields: { send: "function" },
|
|
1387
1458
|
},
|
|
1459
|
+
// REQ-IDENT (sc-4313) — backs useIdentification(). Mirror of contract.js.
|
|
1460
|
+
identification: {
|
|
1461
|
+
description:
|
|
1462
|
+
"Injected @colixsystems/identification-client instance (REQ-IDENT). { available() -> Promise<{ available, providers }>, start(body) -> Promise<order>, get(id) -> Promise<state>, cancel(id) -> Promise<state> }. Backs useIdentification(); no widget scope and no signed-in user required — identifying a NOT-signed-in visitor is the point, so the endpoints are anonymous. Bodies and rows are snake_case verbatim. A completed identification exposes name + personal_number_masked + a stable subject_hash; the RAW personal number is never reachable from a widget (it lives behind a studio-admin endpoint).",
|
|
1463
|
+
required: true,
|
|
1464
|
+
fields: { available: "function", start: "function", get: "function", cancel: "function" },
|
|
1465
|
+
},
|
|
1388
1466
|
// REQ-WSDK-DOMAIN-CLIENTS — the AppUser administration, AppUserGroup
|
|
1389
1467
|
// administration, and per-record VirtualPermission facades that used to
|
|
1390
1468
|
// live here (`users`, `groups`, `recordPermissions`) were folded into the
|
|
@@ -2290,7 +2368,7 @@ const CONTRACT = deepFreeze({
|
|
|
2290
2368
|
// public endpoint instead, which skips the cache, the metering and the
|
|
2291
2369
|
// workspace's provider. Publishing the host list here keeps the linter,
|
|
2292
2370
|
// the Developer guide and the agent prompt reading one source.
|
|
2293
|
-
version: "1.
|
|
2371
|
+
version: "1.52.0",
|
|
2294
2372
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2295
2373
|
hooks: HOOKS,
|
|
2296
2374
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -382,6 +382,18 @@ const HOOKS = [
|
|
|
382
382
|
requiredContextSlice: ["navigation"],
|
|
383
383
|
scopes: null,
|
|
384
384
|
},
|
|
385
|
+
{
|
|
386
|
+
name: "usePageContext",
|
|
387
|
+
signature: "usePageContext()",
|
|
388
|
+
returnShape: {
|
|
389
|
+
params:
|
|
390
|
+
"the page's DECLARED parameters, coerced to their declared types (a number param is a number here, not the string useRouteParams() returns). Empty when the page declares none.",
|
|
391
|
+
records:
|
|
392
|
+
"<paramName> → the row the HOST already fetched for a `record` parameter. Read it instead of fetching the same record again; empty when the page declares none.",
|
|
393
|
+
},
|
|
394
|
+
requiredContextSlice: ["pageContext"],
|
|
395
|
+
scopes: null,
|
|
396
|
+
},
|
|
385
397
|
{
|
|
386
398
|
name: "useDatastoreRecord",
|
|
387
399
|
signature: "useDatastoreRecord(tableId, recordId)",
|
|
@@ -834,6 +846,55 @@ const HOOKS = [
|
|
|
834
846
|
requiredContextSlice: ["directory.bankid"],
|
|
835
847
|
scopes: null,
|
|
836
848
|
},
|
|
849
|
+
// REQ-IDENT (sc-4313) — identify a visitor who is NOT signed in and keep the
|
|
850
|
+
// result. Anonymous by design (no widget scope, no session). Mirror of contract.cjs.
|
|
851
|
+
{
|
|
852
|
+
name: "useIdentification",
|
|
853
|
+
signature: "useIdentification(options?)",
|
|
854
|
+
description:
|
|
855
|
+
"Identify a visitor who is NOT signed in via the injected " +
|
|
856
|
+
"identification-client at ctx.identification.{available,start,get,cancel}. " +
|
|
857
|
+
"Returns { available, availabilityLoading, status, qr, autoStartToken, " +
|
|
858
|
+
"message, identity, identificationId, loading, error, start, refresh, " +
|
|
859
|
+
"cancel, reset }. USE IT TO PROVE PRESENCE AND KEEP THE RESULT — an " +
|
|
860
|
+
"attestation on a record, a consent line, an identity check before a " +
|
|
861
|
+
"submit. It creates NO account and NO session: to sign someone IN use the " +
|
|
862
|
+
"app's login, to attach BankID to an existing account use useBankIdLink(), " +
|
|
863
|
+
"to e-sign a file use useFileSignature(). On mount it reads provider " +
|
|
864
|
+
"availability; `available` is false when identification can't be used here " +
|
|
865
|
+
"(provider not configured) — render nothing rather than a dead button. " +
|
|
866
|
+
"start() opens an order (status 'pending' + qr, a PNG data-URL to render " +
|
|
867
|
+
"with the Image primitive) and the hook then POLLS to completion itself " +
|
|
868
|
+
"(pollIntervalMs, default 1000; pass 0 to drive refresh() yourself); " +
|
|
869
|
+
"cancel() aborts; reset() clears the flow. autoStartToken opens the " +
|
|
870
|
+
"provider app on the SAME device (bankid:///?autostarttoken=<token>" +
|
|
871
|
+
"&redirect=null). On complete, `identity` is { provider, name, given_name, " +
|
|
872
|
+
"surname, personal_number_masked, subject_hash, identified_at } — there is " +
|
|
873
|
+
"NO raw personal number by design: write personal_number_masked " +
|
|
874
|
+
"(\"19900101-****\") and, if you need to trace the proof, identificationId. " +
|
|
875
|
+
"subject_hash is stable per person so a returning visitor is recognisable " +
|
|
876
|
+
"without the number. `options.purpose` is a short audit label capped at 120 " +
|
|
877
|
+
"chars. Orders expire 5 minutes after start(). No requestedScopes entry needed.",
|
|
878
|
+
returnShape: {
|
|
879
|
+
available: "boolean // false → don't offer the flow",
|
|
880
|
+
availabilityLoading: "boolean",
|
|
881
|
+
status: "'pending' | 'complete' | 'failed' | 'cancelled' | null",
|
|
882
|
+
qr: "string | null // PNG data-URL of the animated QR",
|
|
883
|
+
autoStartToken: "string | null // same-device deeplink token",
|
|
884
|
+
message: "string | null // display-ready instruction for this step",
|
|
885
|
+
identity:
|
|
886
|
+
"{ provider, name, given_name, surname, personal_number_masked, subject_hash, identified_at } | null",
|
|
887
|
+
identificationId: "string | null // store alongside an attestation",
|
|
888
|
+
loading: "boolean",
|
|
889
|
+
error: "IdentificationError | null",
|
|
890
|
+
start: "() => Promise<{ identification_id, status, qr, auto_start_token, expires_at }>",
|
|
891
|
+
refresh: "() => Promise<void>",
|
|
892
|
+
cancel: "() => Promise<void>",
|
|
893
|
+
reset: "() => void",
|
|
894
|
+
},
|
|
895
|
+
requiredContextSlice: ["identification"],
|
|
896
|
+
scopes: null,
|
|
897
|
+
},
|
|
837
898
|
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
|
838
899
|
// management for a single record. Mirror of contract.js.
|
|
839
900
|
{
|
|
@@ -1306,6 +1367,16 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1306
1367
|
required: true,
|
|
1307
1368
|
fields: { push: "function", replace: "function", back: "function" },
|
|
1308
1369
|
},
|
|
1370
|
+
pageContext: {
|
|
1371
|
+
description:
|
|
1372
|
+
"REQ-NAV-05 — the PAGE's resolved parameters. " +
|
|
1373
|
+
"{ params: { <name>: value }, records: { <name>: Record|null } }. " +
|
|
1374
|
+
"`params` are the page's DECLARED parameters coerced to their declared types (a number param is a number, not the string useRouteParams() returns). " +
|
|
1375
|
+
"`records` holds the row the HOST already fetched for each `record` parameter — read it instead of fetching the same record again. " +
|
|
1376
|
+
"Both bags are empty on a page that declares no parameters. Backs usePageContext().",
|
|
1377
|
+
required: true,
|
|
1378
|
+
fields: { params: "object", records: "object" },
|
|
1379
|
+
},
|
|
1309
1380
|
datastore: {
|
|
1310
1381
|
description:
|
|
1311
1382
|
"Injected @colixsystems/datastore-client instance. " +
|
|
@@ -1385,6 +1456,13 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1385
1456
|
required: true,
|
|
1386
1457
|
fields: { send: "function" },
|
|
1387
1458
|
},
|
|
1459
|
+
// REQ-IDENT (sc-4313) — backs useIdentification(). Mirror of contract.cjs.
|
|
1460
|
+
identification: {
|
|
1461
|
+
description:
|
|
1462
|
+
"Injected @colixsystems/identification-client instance (REQ-IDENT). { available() -> Promise<{ available, providers }>, start(body) -> Promise<order>, get(id) -> Promise<state>, cancel(id) -> Promise<state> }. Backs useIdentification(); no widget scope and no signed-in user required — identifying a NOT-signed-in visitor is the point, so the endpoints are anonymous. Bodies and rows are snake_case verbatim. A completed identification exposes name + personal_number_masked + a stable subject_hash; the RAW personal number is never reachable from a widget (it lives behind a studio-admin endpoint).",
|
|
1463
|
+
required: true,
|
|
1464
|
+
fields: { available: "function", start: "function", get: "function", cancel: "function" },
|
|
1465
|
+
},
|
|
1388
1466
|
// REQ-WSDK-DOMAIN-CLIENTS — the AppUser administration, AppUserGroup
|
|
1389
1467
|
// administration, and per-record VirtualPermission facades that used to
|
|
1390
1468
|
// live here (`users`, `groups`, `recordPermissions`) were folded into the
|
|
@@ -2290,7 +2368,7 @@ const CONTRACT = deepFreeze({
|
|
|
2290
2368
|
// public endpoint instead, which skips the cache, the metering and the
|
|
2291
2369
|
// workspace's provider. Publishing the host list here keeps the linter,
|
|
2292
2370
|
// the Developer guide and the agent prompt reading one source.
|
|
2293
|
-
version: "1.
|
|
2371
|
+
version: "1.52.0",
|
|
2294
2372
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2295
2373
|
hooks: HOOKS,
|
|
2296
2374
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -261,6 +261,13 @@ export function useNavigation() {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
const EMPTY_PARAMS = Object.freeze({});
|
|
264
|
+
// Stable references so `useEffect([params])` / `[records]` don't re-fire every
|
|
265
|
+
// render on a page that declares no parameters.
|
|
266
|
+
const EMPTY_PAGE_RECORDS = Object.freeze({});
|
|
267
|
+
const EMPTY_PAGE_CONTEXT = Object.freeze({
|
|
268
|
+
params: EMPTY_PARAMS,
|
|
269
|
+
records: EMPTY_PAGE_RECORDS,
|
|
270
|
+
});
|
|
264
271
|
|
|
265
272
|
/**
|
|
266
273
|
* Returns the current route's navigation params — the bag a `goTo(pageId, params)`
|
|
@@ -281,6 +288,35 @@ export function useRouteParams() {
|
|
|
281
288
|
return route.params || EMPTY_PARAMS;
|
|
282
289
|
}
|
|
283
290
|
|
|
291
|
+
/**
|
|
292
|
+
* REQ-NAV-05 — the PAGE's resolved context: `{ params, records }`.
|
|
293
|
+
*
|
|
294
|
+
* When the page declares typed parameters (Page Settings → Parameters), the
|
|
295
|
+
* host resolves them ONCE before any widget renders: `params` holds the values
|
|
296
|
+
* coerced to their declared types (a `number` param is a number here, not the
|
|
297
|
+
* "42" string `useRouteParams()` returns), and `records` maps each `record`
|
|
298
|
+
* param to the row the host already fetched. Read the row from `records`
|
|
299
|
+
* instead of fetching it again — that duplicate fetch per widget is exactly
|
|
300
|
+
* what this hook exists to remove:
|
|
301
|
+
*
|
|
302
|
+
* const { records } = usePageContext();
|
|
303
|
+
* const policy = records.policy_id; // already loaded, no request
|
|
304
|
+
*
|
|
305
|
+
* A required parameter that is absent, malformed, or whose record does not
|
|
306
|
+
* resolve never reaches the widget — the host renders one page-level state
|
|
307
|
+
* instead. Both bags are empty on a page that declares nothing and on the
|
|
308
|
+
* Studio canvas, so a widget can always read them; fall back to
|
|
309
|
+
* `useRouteParams()` for a page you do not control.
|
|
310
|
+
*/
|
|
311
|
+
export function usePageContext() {
|
|
312
|
+
const ctx = useWidgetContextOrThrow("usePageContext");
|
|
313
|
+
const page = ctx.pageContext || EMPTY_PAGE_CONTEXT;
|
|
314
|
+
return {
|
|
315
|
+
params: page.params || EMPTY_PARAMS,
|
|
316
|
+
records: page.records || EMPTY_PAGE_RECORDS,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
284
320
|
/**
|
|
285
321
|
* Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
|
|
286
322
|
* the host's translation table and falls back to `fallback ?? key` when
|
|
@@ -3025,6 +3061,273 @@ export function useBankIdLink() {
|
|
|
3025
3061
|
};
|
|
3026
3062
|
}
|
|
3027
3063
|
|
|
3064
|
+
/* ============================================================================
|
|
3065
|
+
* IDENTIFICATION CLIENT — ctx.identification
|
|
3066
|
+
* (@colixsystems/identification-client)
|
|
3067
|
+
*
|
|
3068
|
+
* available, start, get, cancel. Covers: useIdentification.
|
|
3069
|
+
* ==========================================================================*/
|
|
3070
|
+
|
|
3071
|
+
/**
|
|
3072
|
+
* Structured error thrown by `useIdentification` callbacks. Carries a stable
|
|
3073
|
+
* `code` so widgets can branch without parsing message strings.
|
|
3074
|
+
*
|
|
3075
|
+
* `code` is one of:
|
|
3076
|
+
* - "NOT_CONFIGURED" — the provider is not set up on this deployment
|
|
3077
|
+
* - "UNKNOWN_PROVIDER"— the requested provider does not exist
|
|
3078
|
+
* - "NOT_FOUND" — no such identification in this workspace
|
|
3079
|
+
* - "RATE_LIMITED" — too many starts; back off and retry
|
|
3080
|
+
* - "UNAVAILABLE" — the provider itself failed. Retryable
|
|
3081
|
+
* - "INTERNAL" — anything else
|
|
3082
|
+
*/
|
|
3083
|
+
export class IdentificationError extends Error {
|
|
3084
|
+
constructor(code, message, opts) {
|
|
3085
|
+
super(message);
|
|
3086
|
+
this.name = "IdentificationError";
|
|
3087
|
+
this.code = code;
|
|
3088
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
function toIdentificationError(err) {
|
|
3093
|
+
if (err instanceof IdentificationError) return err;
|
|
3094
|
+
// The injected client already throws typed errors carrying .code / .status;
|
|
3095
|
+
// fall back to the axios-ish shape the other hooks normalise.
|
|
3096
|
+
const status =
|
|
3097
|
+
(err && typeof err.status === "number" ? err.status : null) ??
|
|
3098
|
+
(err && err.response && typeof err.response.status === "number"
|
|
3099
|
+
? err.response.status
|
|
3100
|
+
: null);
|
|
3101
|
+
const bodyCode =
|
|
3102
|
+
(err && typeof err.code === "string" ? err.code : null) ||
|
|
3103
|
+
(err && err.response && err.response.data && err.response.data.code);
|
|
3104
|
+
let code = "INTERNAL";
|
|
3105
|
+
if (bodyCode === "IDENTIFICATION_NOT_CONFIGURED") code = "NOT_CONFIGURED";
|
|
3106
|
+
else if (bodyCode === "UNKNOWN_PROVIDER") code = "UNKNOWN_PROVIDER";
|
|
3107
|
+
else if (bodyCode === "IDENTIFICATION_UNAVAILABLE") code = "UNAVAILABLE";
|
|
3108
|
+
else if (bodyCode === "RATE_LIMITED" || status === 429) code = "RATE_LIMITED";
|
|
3109
|
+
else if (status === 404) code = "NOT_FOUND";
|
|
3110
|
+
else if (status === 409) code = "NOT_CONFIGURED";
|
|
3111
|
+
else if (status === 502) code = "UNAVAILABLE";
|
|
3112
|
+
const message =
|
|
3113
|
+
(err && typeof err.message === "string" && err.message) ||
|
|
3114
|
+
"Identification failed";
|
|
3115
|
+
return new IdentificationError(code, message, { cause: err });
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
/**
|
|
3119
|
+
* Identify a visitor who is NOT signed in (REQ-IDENT). Returns
|
|
3120
|
+
* `{ available, availabilityLoading, status, qr, autoStartToken, message,
|
|
3121
|
+
* identity, identificationId, loading, error, start, refresh, cancel, reset }`.
|
|
3122
|
+
*
|
|
3123
|
+
* `start()` opens an order and the hook then POLLS it for you until it is
|
|
3124
|
+
* terminal, clearing the timer on unmount — so a widget renders state rather
|
|
3125
|
+
* than running a loop. `refresh()` polls once by hand (set `pollIntervalMs: 0`
|
|
3126
|
+
* to own the cadence yourself); `cancel()` aborts; `reset()` clears the flow so
|
|
3127
|
+
* the visitor can start over. Reads
|
|
3128
|
+
* `ctx.identification.{available,start,get,cancel}`.
|
|
3129
|
+
*
|
|
3130
|
+
* WHAT THIS IS FOR — proving a real person holding a credential was present, so
|
|
3131
|
+
* the app can KEEP that: an attestation on a record, a consent line, an identity
|
|
3132
|
+
* check before a submit. It creates NO account and NO session. To sign someone
|
|
3133
|
+
* IN with BankID use the app's login; to attach BankID to an existing account
|
|
3134
|
+
* use `useBankIdLink()`; to e-sign a file's bytes use `useFileSignature()`.
|
|
3135
|
+
*
|
|
3136
|
+
* RENDER THE FLOW LIKE THIS. Gate on `available` first — when it is false the
|
|
3137
|
+
* provider is not configured and no QR can ever complete, so show nothing (or an
|
|
3138
|
+
* explanatory line) rather than a dead button. While `status === "pending"`,
|
|
3139
|
+
* render `qr` with the `Image` primitive and show `message` (a display-ready,
|
|
3140
|
+
* localised instruction such as "Enter your security code in the BankID app").
|
|
3141
|
+
* On the SAME device, `autoStartToken` opens the provider app directly —
|
|
3142
|
+
* `bankid:///?autostarttoken=<token>&redirect=null`.
|
|
3143
|
+
*
|
|
3144
|
+
* ON COMPLETION you get `identity`:
|
|
3145
|
+
* `{ provider, name, given_name, surname, personal_number_masked,
|
|
3146
|
+
* subject_hash, identified_at }`
|
|
3147
|
+
* There is deliberately NO raw personal number: `personal_number_masked` is
|
|
3148
|
+
* `"19900101-****"` and `subject_hash` is stable for the same person, so you can
|
|
3149
|
+
* recognise a returning visitor without ever holding the number. Write the
|
|
3150
|
+
* masked string (and `identificationId`, if you want to trace the proof) into
|
|
3151
|
+
* your datastore column — never try to reconstruct the full number.
|
|
3152
|
+
*
|
|
3153
|
+
* `purpose` is a short audit label ("attest", "age_check"), capped at 120 chars
|
|
3154
|
+
* server-side. Orders expire five minutes after `start()`.
|
|
3155
|
+
*/
|
|
3156
|
+
export function useIdentification(options) {
|
|
3157
|
+
const ctx = useWidgetContextOrThrow("useIdentification");
|
|
3158
|
+
if (
|
|
3159
|
+
!ctx.identification ||
|
|
3160
|
+
typeof ctx.identification.start !== "function"
|
|
3161
|
+
) {
|
|
3162
|
+
throw new Error(
|
|
3163
|
+
"useIdentification: host did not inject an identification client (ctx.identification)",
|
|
3164
|
+
);
|
|
3165
|
+
}
|
|
3166
|
+
const apiRef = useRef(ctx.identification);
|
|
3167
|
+
apiRef.current = ctx.identification;
|
|
3168
|
+
|
|
3169
|
+
const opts = options || {};
|
|
3170
|
+
const provider = opts.provider || "bankid";
|
|
3171
|
+
const purpose = opts.purpose;
|
|
3172
|
+
const pollIntervalMs =
|
|
3173
|
+
typeof opts.pollIntervalMs === "number" ? opts.pollIntervalMs : 1000;
|
|
3174
|
+
|
|
3175
|
+
const [available, setAvailable] = useState(false);
|
|
3176
|
+
const [availabilityLoading, setAvailabilityLoading] = useState(true);
|
|
3177
|
+
const [status, setStatus] = useState(null); // null | pending | complete | failed | cancelled
|
|
3178
|
+
const [qr, setQr] = useState(null);
|
|
3179
|
+
const [autoStartToken, setAutoStartToken] = useState(null);
|
|
3180
|
+
const [message, setMessage] = useState(null);
|
|
3181
|
+
const [identity, setIdentity] = useState(null);
|
|
3182
|
+
const [identificationId, setIdentificationId] = useState(null);
|
|
3183
|
+
const [loading, setLoading] = useState(false);
|
|
3184
|
+
const [error, setError] = useState(null);
|
|
3185
|
+
|
|
3186
|
+
const idRef = useRef(null);
|
|
3187
|
+
// Guards every async setState: a flow that resolves after unmount (or after a
|
|
3188
|
+
// reset) must not write into a dead render.
|
|
3189
|
+
const aliveRef = useRef(true);
|
|
3190
|
+
useEffect(() => {
|
|
3191
|
+
aliveRef.current = true;
|
|
3192
|
+
return () => {
|
|
3193
|
+
aliveRef.current = false;
|
|
3194
|
+
};
|
|
3195
|
+
}, []);
|
|
3196
|
+
|
|
3197
|
+
useEffect(() => {
|
|
3198
|
+
let cancelled = false;
|
|
3199
|
+
(async () => {
|
|
3200
|
+
try {
|
|
3201
|
+
const res = await apiRef.current.available();
|
|
3202
|
+
if (cancelled) return;
|
|
3203
|
+
setAvailable(Boolean(res && res.available));
|
|
3204
|
+
} catch {
|
|
3205
|
+
if (cancelled) return;
|
|
3206
|
+
// Availability is a pre-flight nicety, not the flow — a failure here
|
|
3207
|
+
// means "don't offer it", not an error the widget must render.
|
|
3208
|
+
setAvailable(false);
|
|
3209
|
+
} finally {
|
|
3210
|
+
if (!cancelled) setAvailabilityLoading(false);
|
|
3211
|
+
}
|
|
3212
|
+
})();
|
|
3213
|
+
return () => {
|
|
3214
|
+
cancelled = true;
|
|
3215
|
+
};
|
|
3216
|
+
}, []);
|
|
3217
|
+
|
|
3218
|
+
const _applyState = useCallback((res) => {
|
|
3219
|
+
if (!res) return null;
|
|
3220
|
+
if (res.status != null) setStatus(res.status);
|
|
3221
|
+
if (res.qr !== undefined) setQr(res.qr || null);
|
|
3222
|
+
if (res.message !== undefined) setMessage(res.message || null);
|
|
3223
|
+
if (res.identity !== undefined) setIdentity(res.identity || null);
|
|
3224
|
+
return res;
|
|
3225
|
+
}, []);
|
|
3226
|
+
|
|
3227
|
+
const refresh = useCallback(async () => {
|
|
3228
|
+
const id = idRef.current;
|
|
3229
|
+
if (!id) return null;
|
|
3230
|
+
try {
|
|
3231
|
+
const res = await apiRef.current.get(id);
|
|
3232
|
+
if (!aliveRef.current || idRef.current !== id) return res;
|
|
3233
|
+
return _applyState(res);
|
|
3234
|
+
} catch (err) {
|
|
3235
|
+
if (!aliveRef.current || idRef.current !== id) return null;
|
|
3236
|
+
const e = toIdentificationError(err);
|
|
3237
|
+
// A transient provider blip is already retried inside the client, so an
|
|
3238
|
+
// error surfacing here ends the flow rather than spinning forever.
|
|
3239
|
+
setError(e);
|
|
3240
|
+
setStatus("failed");
|
|
3241
|
+
setMessage(e.message);
|
|
3242
|
+
return null;
|
|
3243
|
+
}
|
|
3244
|
+
}, [_applyState]);
|
|
3245
|
+
|
|
3246
|
+
// Poll while the order is pending. Cleared on terminal state and on unmount.
|
|
3247
|
+
useEffect(() => {
|
|
3248
|
+
if (status !== "pending" || pollIntervalMs <= 0) return undefined;
|
|
3249
|
+
const timer = setInterval(() => {
|
|
3250
|
+
refresh();
|
|
3251
|
+
}, pollIntervalMs);
|
|
3252
|
+
return () => clearInterval(timer);
|
|
3253
|
+
}, [status, pollIntervalMs, refresh]);
|
|
3254
|
+
|
|
3255
|
+
const start = useCallback(async () => {
|
|
3256
|
+
setLoading(true);
|
|
3257
|
+
setError(null);
|
|
3258
|
+
setIdentity(null);
|
|
3259
|
+
try {
|
|
3260
|
+
const res = await apiRef.current.start({ provider, purpose });
|
|
3261
|
+
const newId = res && res.identification_id ? res.identification_id : null;
|
|
3262
|
+
idRef.current = newId;
|
|
3263
|
+
if (!aliveRef.current) return res;
|
|
3264
|
+
setIdentificationId(newId);
|
|
3265
|
+
setStatus(res && res.status ? res.status : "pending");
|
|
3266
|
+
setQr(res && res.qr ? res.qr : null);
|
|
3267
|
+
setAutoStartToken(res && res.auto_start_token ? res.auto_start_token : null);
|
|
3268
|
+
setMessage(null);
|
|
3269
|
+
setLoading(false);
|
|
3270
|
+
return res;
|
|
3271
|
+
} catch (err) {
|
|
3272
|
+
const e = toIdentificationError(err);
|
|
3273
|
+
if (aliveRef.current) {
|
|
3274
|
+
setError(e);
|
|
3275
|
+
setStatus("failed");
|
|
3276
|
+
setMessage(e.message);
|
|
3277
|
+
setLoading(false);
|
|
3278
|
+
}
|
|
3279
|
+
throw e;
|
|
3280
|
+
}
|
|
3281
|
+
}, [provider, purpose]);
|
|
3282
|
+
|
|
3283
|
+
const cancel = useCallback(async () => {
|
|
3284
|
+
const id = idRef.current;
|
|
3285
|
+
idRef.current = null;
|
|
3286
|
+
if (aliveRef.current) {
|
|
3287
|
+
setStatus(null);
|
|
3288
|
+
setQr(null);
|
|
3289
|
+
setAutoStartToken(null);
|
|
3290
|
+
setMessage(null);
|
|
3291
|
+
}
|
|
3292
|
+
if (id) {
|
|
3293
|
+
try {
|
|
3294
|
+
await apiRef.current.cancel(id);
|
|
3295
|
+
} catch {
|
|
3296
|
+
// Best-effort — the order may have already expired or completed.
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
}, []);
|
|
3300
|
+
|
|
3301
|
+
const reset = useCallback(() => {
|
|
3302
|
+
idRef.current = null;
|
|
3303
|
+
setStatus(null);
|
|
3304
|
+
setQr(null);
|
|
3305
|
+
setAutoStartToken(null);
|
|
3306
|
+
setMessage(null);
|
|
3307
|
+
setIdentity(null);
|
|
3308
|
+
setIdentificationId(null);
|
|
3309
|
+
setError(null);
|
|
3310
|
+
setLoading(false);
|
|
3311
|
+
}, []);
|
|
3312
|
+
|
|
3313
|
+
return {
|
|
3314
|
+
available,
|
|
3315
|
+
availabilityLoading,
|
|
3316
|
+
status,
|
|
3317
|
+
qr,
|
|
3318
|
+
autoStartToken,
|
|
3319
|
+
message,
|
|
3320
|
+
identity,
|
|
3321
|
+
identificationId,
|
|
3322
|
+
loading,
|
|
3323
|
+
error,
|
|
3324
|
+
start,
|
|
3325
|
+
refresh,
|
|
3326
|
+
cancel,
|
|
3327
|
+
reset,
|
|
3328
|
+
};
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3028
3331
|
/* ============================================================================
|
|
3029
3332
|
* PAYMENTS CLIENT — ctx.payments (@colixsystems/payments-client)
|
|
3030
3333
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -445,6 +445,59 @@ export interface PaymentsClient {
|
|
|
445
445
|
getPayment(paymentId: string): Promise<PaymentResult>;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
+
/**
|
|
449
|
+
* A verified person a completed identification resolved to (REQ-IDENT).
|
|
450
|
+
*
|
|
451
|
+
* There is deliberately NO raw personal number: the full value stays
|
|
452
|
+
* server-side. `personal_number_masked` is e.g. `"19900101-****"`, and
|
|
453
|
+
* `subject_hash` is stable for the same person so a returning visitor can be
|
|
454
|
+
* recognised without it.
|
|
455
|
+
*/
|
|
456
|
+
export interface Identity {
|
|
457
|
+
provider: string;
|
|
458
|
+
name: string | null;
|
|
459
|
+
given_name: string | null;
|
|
460
|
+
surname: string | null;
|
|
461
|
+
personal_number_masked: string | null;
|
|
462
|
+
subject_hash: string | null;
|
|
463
|
+
identified_at: string | null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Structural shape of the injected `@colixsystems/identification-client`
|
|
468
|
+
* (`ctx.identification`, REQ-IDENT). Backs `useIdentification`. Identifies a
|
|
469
|
+
* visitor who is NOT signed in; creates no account and no session.
|
|
470
|
+
*/
|
|
471
|
+
export interface IdentificationClient {
|
|
472
|
+
available(): Promise<{
|
|
473
|
+
available: boolean;
|
|
474
|
+
providers: Array<{ provider: string; available: boolean }>;
|
|
475
|
+
}>;
|
|
476
|
+
start(body?: { provider?: string; purpose?: string }): Promise<{
|
|
477
|
+
identification_id: string;
|
|
478
|
+
provider: string;
|
|
479
|
+
purpose: string | null;
|
|
480
|
+
status: "pending";
|
|
481
|
+
auto_start_token: string | null;
|
|
482
|
+
qr: string | null;
|
|
483
|
+
expires_at: string;
|
|
484
|
+
}>;
|
|
485
|
+
get(identificationId: string): Promise<{
|
|
486
|
+
identification_id: string;
|
|
487
|
+
provider: string;
|
|
488
|
+
purpose: string | null;
|
|
489
|
+
status: "pending" | "complete" | "failed" | "cancelled";
|
|
490
|
+
hint_code?: string | null;
|
|
491
|
+
message?: string | null;
|
|
492
|
+
qr?: string | null;
|
|
493
|
+
identity?: Identity;
|
|
494
|
+
}>;
|
|
495
|
+
cancel(identificationId: string): Promise<{
|
|
496
|
+
identification_id: string;
|
|
497
|
+
status: "pending" | "complete" | "failed" | "cancelled";
|
|
498
|
+
}>;
|
|
499
|
+
}
|
|
500
|
+
|
|
448
501
|
/**
|
|
449
502
|
* Structural shape of the injected `@colixsystems/notifications-client`
|
|
450
503
|
* (`ctx.notifications`, sc-890). Backs `useSendNotification`. `send` POSTs the
|
|
@@ -497,6 +550,8 @@ export interface WidgetContext<TProps = unknown> {
|
|
|
497
550
|
payments: PaymentsClient;
|
|
498
551
|
/** Injected @colixsystems/notifications-client; backs useSendNotification. */
|
|
499
552
|
notifications: NotificationsClient;
|
|
553
|
+
/** Injected @colixsystems/identification-client; backs useIdentification. */
|
|
554
|
+
identification: IdentificationClient;
|
|
500
555
|
/** Host child-node renderer; backs WidgetTree / useChildRenderer. */
|
|
501
556
|
renderer: { renderNode(node: unknown): unknown };
|
|
502
557
|
events: { emit(eventName: string, payload?: unknown): void };
|
|
@@ -1001,6 +1056,20 @@ export function useNavigation(): {
|
|
|
1001
1056
|
*/
|
|
1002
1057
|
export function useRouteParams(): Record<string, unknown>;
|
|
1003
1058
|
|
|
1059
|
+
/**
|
|
1060
|
+
* REQ-NAV-05 — the PAGE's resolved context. When the page declares typed
|
|
1061
|
+
* parameters, the host resolves them ONCE before any widget renders: `params`
|
|
1062
|
+
* holds the values coerced to their declared types (a `number` parameter is a
|
|
1063
|
+
* number here, not the string `useRouteParams()` returns), and `records` maps
|
|
1064
|
+
* each `record` parameter to the row the host already fetched — read it instead
|
|
1065
|
+
* of fetching the same record again. Both bags are empty on a page that
|
|
1066
|
+
* declares nothing.
|
|
1067
|
+
*/
|
|
1068
|
+
export function usePageContext(): {
|
|
1069
|
+
params: Record<string, unknown>;
|
|
1070
|
+
records: Record<string, Record<string, unknown> | null>;
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1004
1073
|
/**
|
|
1005
1074
|
* Static API for external URLs. `openURL(url)` opens a URL with the OS
|
|
1006
1075
|
* handler (web: react-native-web maps to `window.open` / `location.href`;
|
|
@@ -1299,6 +1368,76 @@ export interface BankIdLinkApi {
|
|
|
1299
1368
|
*/
|
|
1300
1369
|
export function useBankIdLink(): BankIdLinkApi;
|
|
1301
1370
|
|
|
1371
|
+
// ----------------------------------------------------- useIdentification
|
|
1372
|
+
//
|
|
1373
|
+
// REQ-IDENT — identify a visitor who is NOT signed in and keep the result.
|
|
1374
|
+
// Reads the injected identification-client at `ctx.identification`. Anonymous by
|
|
1375
|
+
// design — no requestedScopes entry and no session needed.
|
|
1376
|
+
|
|
1377
|
+
/** Stable machine codes on an IdentificationError. */
|
|
1378
|
+
export type IdentificationErrorCode =
|
|
1379
|
+
| "NOT_CONFIGURED"
|
|
1380
|
+
| "UNKNOWN_PROVIDER"
|
|
1381
|
+
| "NOT_FOUND"
|
|
1382
|
+
| "RATE_LIMITED"
|
|
1383
|
+
| "UNAVAILABLE"
|
|
1384
|
+
| "INTERNAL";
|
|
1385
|
+
|
|
1386
|
+
export class IdentificationError extends Error {
|
|
1387
|
+
code: IdentificationErrorCode;
|
|
1388
|
+
cause?: unknown;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
export interface UseIdentificationOptions {
|
|
1392
|
+
/** Provider to identify with. Defaults to "bankid". */
|
|
1393
|
+
provider?: string;
|
|
1394
|
+
/** Short audit label ("attest", "age_check"). Capped at 120 chars server-side. */
|
|
1395
|
+
purpose?: string;
|
|
1396
|
+
/** Poll cadence while pending, in ms. Defaults to 1000; 0 disables auto-polling. */
|
|
1397
|
+
pollIntervalMs?: number;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
export interface IdentificationApi {
|
|
1401
|
+
/** Whether identification can be used here (provider configured + enabled). Gate the UI on this. */
|
|
1402
|
+
available: boolean;
|
|
1403
|
+
availabilityLoading: boolean;
|
|
1404
|
+
/** The active order's state, or null when no order is in flight. */
|
|
1405
|
+
status: "pending" | "complete" | "failed" | "cancelled" | null;
|
|
1406
|
+
/** PNG data-URL of the animated QR while pending (render with the Image primitive). */
|
|
1407
|
+
qr: string | null;
|
|
1408
|
+
/** Same-device deeplink token: `bankid:///?autostarttoken=<token>&redirect=null`. */
|
|
1409
|
+
autoStartToken: string | null;
|
|
1410
|
+
/** Display-ready instruction for the current step. */
|
|
1411
|
+
message: string | null;
|
|
1412
|
+
/** The verified person, set once status is "complete". */
|
|
1413
|
+
identity: Identity | null;
|
|
1414
|
+
/** The order id — store it alongside an attestation to trace back to the proof. */
|
|
1415
|
+
identificationId: string | null;
|
|
1416
|
+
loading: boolean;
|
|
1417
|
+
error: IdentificationError | null;
|
|
1418
|
+
/** Open an order -> sets status "pending" + qr, then polls to completion. */
|
|
1419
|
+
start(): Promise<unknown>;
|
|
1420
|
+
/** Poll the open order once by hand (for pollIntervalMs: 0). */
|
|
1421
|
+
refresh(): Promise<unknown>;
|
|
1422
|
+
/** Abort the in-flight order. */
|
|
1423
|
+
cancel(): Promise<void>;
|
|
1424
|
+
/** Clear the flow so the visitor can start over. */
|
|
1425
|
+
reset(): void;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Identify a visitor who is NOT signed in, so the app can keep the result (an
|
|
1430
|
+
* attestation on a record, a consent line, a pre-submit identity check). Polls
|
|
1431
|
+
* the order for you while pending and clears the timer on unmount.
|
|
1432
|
+
*
|
|
1433
|
+
* Creates no account and no session — to sign someone IN use the app's login, to
|
|
1434
|
+
* attach BankID to an existing account use `useBankIdLink()`, and to e-sign a
|
|
1435
|
+
* file use `useFileSignature()`. Hide the affordance when `available` is false.
|
|
1436
|
+
*/
|
|
1437
|
+
export function useIdentification(
|
|
1438
|
+
options?: UseIdentificationOptions,
|
|
1439
|
+
): IdentificationApi;
|
|
1440
|
+
|
|
1302
1441
|
// ----------------------------------------------------- useRecordPermissions
|
|
1303
1442
|
//
|
|
1304
1443
|
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,8 @@ export {
|
|
|
31
31
|
useUsers,
|
|
32
32
|
useGroups,
|
|
33
33
|
useBankIdLink,
|
|
34
|
+
useIdentification,
|
|
35
|
+
IdentificationError,
|
|
34
36
|
useRecordPermissions,
|
|
35
37
|
useDatastoreSubscription,
|
|
36
38
|
useWidgetEvent,
|
|
@@ -45,6 +47,7 @@ export {
|
|
|
45
47
|
useFill,
|
|
46
48
|
useNavigation,
|
|
47
49
|
useRouteParams,
|
|
50
|
+
usePageContext,
|
|
48
51
|
useChildRenderer,
|
|
49
52
|
useRefresh,
|
|
50
53
|
useGeolocation,
|
package/dist/index.native.js
CHANGED
|
@@ -31,6 +31,8 @@ export {
|
|
|
31
31
|
useUsers,
|
|
32
32
|
useGroups,
|
|
33
33
|
useBankIdLink,
|
|
34
|
+
useIdentification,
|
|
35
|
+
IdentificationError,
|
|
34
36
|
useRecordPermissions,
|
|
35
37
|
useDatastoreSubscription,
|
|
36
38
|
useWidgetEvent,
|
|
@@ -45,6 +47,7 @@ export {
|
|
|
45
47
|
useFill,
|
|
46
48
|
useNavigation,
|
|
47
49
|
useRouteParams,
|
|
50
|
+
usePageContext,
|
|
48
51
|
useChildRenderer,
|
|
49
52
|
useRefresh,
|
|
50
53
|
useGeolocation,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.0",
|
|
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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node scripts/build.js",
|
|
51
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
|
|
51
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|