@colixsystems/widget-sdk 0.193.0 → 0.195.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 CHANGED
@@ -60,9 +60,9 @@ The data layer lives in **four separate domain-client packages**, each instantia
60
60
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
61
61
  | **FILES** | `useAssetsByTag(tag, { type })` (optional: the whole argument, and `type` within it) | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
62
62
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
63
- | **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` plus the `users.read` (or `users.write`) capability, else `FORBIDDEN` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
63
+ | **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset, update }` | `directory.users.*` — `users.read:*` plus the `users.read` (or `users.write`) capability, else `FORBIDDEN` (edits, incl. `sendPasswordReset()` and `update()`, also `users.write:*`; `remove()` also `users.delete:*`) |
64
64
  | **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
65
- | **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, q?, app_user_id?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| not_sent \| open \| all` (endpoint default `all`; `open` = `pending`, `not_sent` or `expired`); `q` matches the invite's name or email, `app_user_id` returns the invites reserving that user id. `resend(inviteId)` sends the invitation — the first send of a `not_sent` one included. |
65
+ | **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, q?, app_user_id?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| open \| all` (endpoint default `all`; `open` = `pending` or `expired`); `q` matches the invite's name or email, `app_user_id` returns the invites reserving that user id. |
66
66
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
67
67
  | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: spaceType, folderId) | `{ openPdf, opening, canOpenPdf, exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.renderPdf` + `ctx.device.pdfViewer`, `ctx.filestore.files.exportPdf` — `files.write:*`. `openPdf(html, { fileName? })` renders the HTML to a PDF server-side and OPENS it (web: PDF tab; native: print / share sheet) without saving. `exportToPdf(html, { fileName?, folderId? })` renders it and saves it as a file (`application/pdf`). Same server-side renderer on web + native. |
68
68
  | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
@@ -77,7 +77,59 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
77
77
 
78
78
  ## Status
79
79
 
80
- `v0.193.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**.
80
+ `v0.195.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**.
81
+
82
+ ### What's new in 0.195.0 (contract 1.156.0)
83
+
84
+ **An admin can correct a member's name or email — `useUsers().update(userId, { name, email })`.** Send either key or both; it resolves `{ id, name, email }`. Call `refetch()` afterwards to refresh the list.
85
+
86
+ ```jsx
87
+ import { useUsers } from "@colixsystems/widget-sdk";
88
+
89
+ // Manifest: requestedScopes: ["users.read:*", "users.write:*"]
90
+ function ChangeMemberEmail({ user }) {
91
+ const { update, refetch } = useUsers();
92
+ const save = async (email) => {
93
+ try {
94
+ await update(user.id, { email });
95
+ await refetch();
96
+ } catch (err) {
97
+ if (err.reason === "EMAIL_TAKEN") { /* mark the email field */ }
98
+ // err.message is a sentence written for the person reading it.
99
+ }
100
+ };
101
+ return null;
102
+ }
103
+ ```
104
+
105
+ - **It needs `users.write:*`**, the same scope as `invite` and `deactivate`, plus the workspace's `users.write` capability for the signed-in person. **That scope now also covers changing a member's name and sign-in email**, which is a sensitive power: request it only for a surface meant for the people who administer the app. The linter asks for the scope when a source that calls `useUsers(` also calls `.update(`. If that `.update(` belongs to something else, end the line with `// @appstudio-skip-scope-check`.
106
+ - **An email change is announced to the user at their previous address**, so a change they did not expect never goes unnoticed. You do not send that message yourself.
107
+ - **Refusals name their reason.** `DirectoryError` gains `reason`, the host's specific refusal code, or `null` when it gave none. `code` stays the broad category.
108
+
109
+ | `err.code` | `err.reason` | Meaning |
110
+ | --- | --- | --- |
111
+ | `VALIDATION` | `INVALID_UPDATE` | Neither `name` nor `email` was sent |
112
+ | `VALIDATION` | `INVALID_NAME` / `INVALID_EMAIL` | The value is empty, too long or not an address |
113
+ | `VALIDATION` | `EMAIL_TAKEN` | The address is not available |
114
+ | `VALIDATION` | `EMAIL_RESERVED` | A reserved address |
115
+ | `VALIDATION` | `SCIM_MANAGED_NAME` / `SCIM_MANAGED_EMAIL` | The profile is synced from an identity provider; change it there |
116
+ | `VALIDATION` | `SSO_LINKED_EMAIL` | The user signs in through an identity provider; change the email there |
117
+ | `VALIDATION` | `SERVICE_ACCOUNT_PROTECTED` | A service account's email cannot be changed |
118
+ | `VALIDATION` | `SEED_USER_PROTECTED` / `REVIEW_USER_PROTECTED` | The app's preview or store-review account: its email address cannot be changed |
119
+ | `VALIDATION` | `OWN_EMAIL_CHANGE_REFUSED` | The signed-in user cannot change their own email here; they use their account screen |
120
+ | `NOT_FOUND` | | No such user in this workspace |
121
+ | `FORBIDDEN` | | The signed-in person may not edit users |
122
+ | `INTERNAL` (`err.status === 429`) | | Too many email changes in a short time; `err.retryable` is `true` |
123
+
124
+ Every refusal from `EMAIL_TAKEN` down to `OWN_EMAIL_CHANGE_REFUSED`, except the `SCIM_MANAGED_*` pair, concerns the email only: a rename sent as `{ name }` alone still succeeds. `useUsers()` lists service accounts, so expect `SERVICE_ACCOUNT_PROTECTED`; better, do not offer an email field on a row whose `role` is `INTEGRATION`.
125
+
126
+ Email changes are rate-limited per caller. The call is never retried automatically, so a 429 reaches you at once: ask the person to try again later rather than looping.
127
+
128
+ `reason` is set on every `DirectoryError` the host explains, so `sendPasswordReset()` refusals read `err.reason === "USER_INACTIVE"` or `"NO_PASSWORD_CREDENTIAL"`. Additive: a host that does not offer `update` yet rejects the call with an `INTERNAL` `DirectoryError`.
129
+
130
+ ### What's new in 0.194.0 (contract 1.155.0)
131
+
132
+ **Send-later invitations are withdrawn.** 0.183.0 let `useUsers().invite()` take `send_invitation: false` to store an invitation without emailing it. That argument, the `not_sent` invite status and the `sent_at` field are removed: `invite()` emails the invitation at once, as it did before, and `useInvites().resend(inviteId)` re-sends a pending one. A widget that wants to line people up first keeps its own list and calls `invite()` when it is ready.
81
133
 
82
134
  ### What's new in 0.193.0 (contract 1.154.0)
83
135
 
@@ -250,24 +302,7 @@ An installed widget is not rebuilt or republished for this. The web Player hands
250
302
 
251
303
  ### What's new in 0.183.0 (contract 1.144.0)
252
304
 
253
- **A widget can prepare an invitation now and send it later.** `useUsers().invite()` always emailed the invitation the moment it was created, so a widget could not line people up ahead of a launch day or let a second person check the list first. `invite()` now takes an optional `send_invitation`:
254
-
255
- ```js
256
- const { invite } = useUsers();
257
- const { invites, resend } = useInvites({ status: "not_sent" });
258
-
259
- const prepared = await invite({ email: "bo@example.com", name: "Bo", send_invitation: false });
260
- // prepared.status === "not_sent", prepared.sent_at === null — nobody was emailed.
261
-
262
- await resend(prepared.id); // emails it; the row is now "pending" with a fresh 7-day expiry
263
- ```
264
-
265
- - **`send_invitation`** defaults to `true`, so an existing `invite({ email, name, group_ids })` call emails at once exactly as before. `false` stores the invitation and emails nobody. A value that is not a boolean rejects with a `DirectoryError` coded `VALIDATION`.
266
- - **`not_sent` joins the invite `status` values**, and every invite row carries **`sent_at`** (`null` until the first send). A `not_sent` invitation cannot be accepted yet and its `expires_at` means nothing until it is sent, so show no expiry for it. `useInvites({ status: "not_sent" })` lists just the prepared ones. Where several states apply the first of accepted, revoked, not_sent, expired, pending wins.
267
- - **`useInvites().resend(inviteId)` is the one call that sends**, whether or not the invitation was sent before. There is no separate `send` method: on a `not_sent` row it is the first send, on a `pending` or `expired` one it sends again, and either way it stamps `sent_at`, restarts the expiry and resolves the row as `pending`. Label the button to match the row — "Send invitation" when `status === "not_sent"`, "Resend" otherwise.
268
- - **A widget that switches on `status` should handle the new value.** A `not_sent` row falls through an `if (status === "pending")` check, so a list that offers its actions only to pending rows would leave a prepared invitation with no way to send it.
269
-
270
- The built-in User Management widget offers the choice on its invite form ("Send the invitation later", off by default) and a "Send invitation" action on each row that has not been sent. No new scope: both calls sit under `users.write:*`, as before.
305
+ Send-later invitations (`send_invitation`, the `not_sent` status, `sent_at`). Withdrawn in 0.194.0.
271
306
 
272
307
  ### What's new in 0.182.0 (contract 1.143.0)
273
308
 
@@ -2249,7 +2284,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
2249
2284
 
2250
2285
  ## Managing app users from a widget
2251
2286
 
2252
- `useUsers()` and `useGroups()` let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. Two gates apply: the manifest must declare the scope (the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller lacks the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that as a "you do not have permission" message.
2287
+ `useUsers()` and `useGroups()` let a widget invite, deactivate, reactivate, and remove members, change a member's name or sign-in email, plus create / delete groups and add / remove their members. `users.write:*` covers every edit, the name and sign-in email included (the member is told at their previous address when the email changes), so request it only for a surface meant for the people who administer the app. Two gates apply: the manifest must declare the scope (the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller lacks the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that as a "you do not have permission" message.
2253
2288
 
2254
2289
  ```js
2255
2290
  import { Text, View, Pressable, useUsers, useGroups } from "@colixsystems/widget-sdk";
@@ -2294,13 +2329,11 @@ The matching manifest declares the scopes:
2294
2329
  }
2295
2330
  ```
2296
2331
 
2297
- `invite()` emails the invitation at once. Pass `send_invitation: false` to store it without emailing anyone — the row comes back with `status: "not_sent"` and `sent_at: null` — and send it later with `useInvites().resend(inviteId)`.
2298
-
2299
2332
  The host rejects calls whose scope is not declared in the manifest (the SDK linter catches this statically too). Declaring a write scope is also a consent prompt the Studio admin sees at install time — the wider the scope set, the more careful the admin is about granting the install.
2300
2333
 
2301
2334
  ## Listing pending invitations from a widget
2302
2335
 
2303
- `useInvites(query?)` lists the workspace's invites and sends / revokes them:
2336
+ `useInvites(query?)` lists the workspace's invites and resends / revokes them:
2304
2337
 
2305
2338
  ```jsx
2306
2339
  import { useInvites, View, Text, Pressable } from '@colixsystems/widget-sdk';
@@ -2315,9 +2348,7 @@ function PendingInvites() {
2315
2348
  {invites.map((i) => (
2316
2349
  <View key={i.id}>
2317
2350
  <Text>{i.email} · {i.status}</Text>
2318
- <Pressable onPress={() => resend(i.id).catch(() => {})}>
2319
- <Text>{i.status === 'not_sent' ? 'Send invitation' : 'Resend'}</Text>
2320
- </Pressable>
2351
+ <Pressable onPress={() => resend(i.id).catch(() => {})}><Text>Resend</Text></Pressable>
2321
2352
  <Pressable onPress={() => revoke(i.id).catch(() => {})}><Text>Revoke</Text></Pressable>
2322
2353
  </View>
2323
2354
  ))}
@@ -2326,16 +2357,9 @@ function PendingInvites() {
2326
2357
  }
2327
2358
  ```
2328
2359
 
2329
- Rows are snake_case: `{ id, app_user_id, email, name, group_ids, status, sent_at,
2330
- expires_at, accepted_at, revoked_at, created_at }`. `status` is one of `pending`,
2331
- `accepted`, `revoked`, `expired` or `not_sent`; trust the server-computed value rather
2332
- than comparing `expires_at` against the device clock.
2333
-
2334
- A `not_sent` row is an invitation created with `send_invitation: false`: nobody has
2335
- been emailed and `sent_at` is `null`. `resend(inviteId)` is the one call that sends
2336
- an invitation, a first send and a repeat send alike — it emails the invitee, stamps
2337
- `sent_at`, restarts the 7-day expiry and resolves the row as `pending`. Pass
2338
- `{ status: 'not_sent' }` to list only the prepared ones.
2360
+ Rows are snake_case: `{ id, app_user_id, email, name, group_ids, status, expires_at,
2361
+ accepted_at, revoked_at, created_at }`. Trust the server-computed `status` rather than
2362
+ comparing `expires_at` against the device clock.
2339
2363
 
2340
2364
  `query` also takes `q` (a case-insensitive substring match on the invite's name or
2341
2365
  email) and `app_user_id`. Each row's `app_user_id` is the id the invitee's user account
@@ -2343,7 +2367,7 @@ will have once they accept. A `USER` column may hold that id before the person h
2343
2367
  joined — it then reads back as `{ id, name, invited: true }`, with no `email` — and
2344
2368
  `useInvites({ app_user_id: value.id, status: 'open', limit: 1 })` returns the invite
2345
2369
  behind it. `status: 'open'` is every invite that is neither accepted nor revoked
2346
- (`pending`, `not_sent` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
2370
+ (`pending` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
2347
2371
 
2348
2372
  **The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
2349
2373
  someone who is not a member yet, so the backend gates the entire invite surface —
package/dist/contract.cjs CHANGED
@@ -1746,12 +1746,11 @@ const HOOKS = [
1746
1746
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
1747
1747
  // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
1748
1748
  // Reads need `users.read:*` scope; edit-style mutations (invite /
1749
- // deactivate / reactivate / sendPasswordReset) additionally need
1749
+ // deactivate / reactivate / sendPasswordReset / update) additionally need
1750
1750
  // `users.write:*`, and the
1751
1751
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
1752
- // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
1753
- // the resulting AppUserInvite row (the host sends the email, unless
1754
- // `send_invitation` is false). Mutating users from
1752
+ // call accepts `{ email, name, groupIds? }` and returns the resulting
1753
+ // AppUserInvite row (the email is sent by the host). Mutating users from
1755
1754
  // a widget requires the corresponding SystemAcl capability grant in the
1756
1755
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
1757
1756
  // that only call read methods need only `users.read:*`.
@@ -1761,39 +1760,62 @@ const HOOKS = [
1761
1760
  optionalArgs: ["query"],
1762
1761
  description:
1763
1762
  "AppUser administration via the injected directory-client at " +
1764
- "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
1763
+ "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}. " +
1765
1764
  "Returns { users, loading, error, refetch, invite, deactivate, " +
1766
- "reactivate, remove, sendPasswordReset }. " +
1765
+ "reactivate, remove, sendPasswordReset, update }. " +
1767
1766
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
1768
1767
  "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
1769
1768
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1770
- "sendPasswordReset) need users.write:*, and the destructive remove() " +
1769
+ "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
1771
1770
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
1772
- "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
1773
- "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
1774
- "The host emails the invitation at once unless send_invitation is " +
1775
- "false: then nothing is emailed, the row comes back with sent_at null " +
1776
- "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
1777
- "later. A send_invitation that is not a boolean rejects with " +
1778
- "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
1771
+ "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
1772
+ "(the email is sent by the host). sendPasswordReset(userId) mails the " +
1779
1773
  "standard self-serve reset link to that user's own registered address " +
1780
1774
  "and resolves { sent, email_masked } — never the token or the link, so " +
1781
1775
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
1782
- "coded USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL (an " +
1783
- "INTEGRATION service account); surface those as an explanation rather " +
1784
- "than a generic failure.",
1776
+ "whose reason is USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL " +
1777
+ "(an INTEGRATION service account); surface those as an explanation " +
1778
+ "rather than a generic failure. update(userId, { name?, email? }) " +
1779
+ "changes a user's name, email, or both (at least one key) and resolves " +
1780
+ "{ id, name, email }; refetch() to refresh the list. An email change " +
1781
+ "notifies the user at their previous address. users.write:* therefore " +
1782
+ "lets a widget change a member's name and sign-in email: request it " +
1783
+ "only for a surface meant for the people who administer the app. It " +
1784
+ "rejects with a DirectoryError; code is the broad category and reason " +
1785
+ "the specific refusal. code VALIDATION comes with reason " +
1786
+ "INVALID_UPDATE (neither key), INVALID_NAME, INVALID_EMAIL, " +
1787
+ "EMAIL_TAKEN (the address is not available), EMAIL_RESERVED (a " +
1788
+ "reserved address), SCIM_MANAGED_NAME / SCIM_MANAGED_EMAIL (the " +
1789
+ "profile is synced from an identity provider, so it is changed there), " +
1790
+ "SSO_LINKED_EMAIL (the user signs in through an identity provider, so " +
1791
+ "the email is changed there), SERVICE_ACCOUNT_PROTECTED (a service " +
1792
+ "account's email cannot be changed), SEED_USER_PROTECTED / " +
1793
+ "REVIEW_USER_PROTECTED (the app's preview or store-review account: " +
1794
+ "its email address cannot be changed) or OWN_EMAIL_CHANGE_REFUSED " +
1795
+ "(the signed-in user cannot change their own email here; they use " +
1796
+ "their account screen). Every refusal from EMAIL_TAKEN on, except " +
1797
+ "the SCIM_MANAGED pair, concerns the email only: a rename sent as " +
1798
+ "{ name } alone still succeeds. code NOT_FOUND is an unknown user; " +
1799
+ "code FORBIDDEN a caller without the users.write capability. Email " +
1800
+ "changes are rate-limited per caller: an HTTP 429 arrives as code " +
1801
+ "INTERNAL with status 429 and retryable true, and is never retried " +
1802
+ "automatically, so ask the person to try again later. " +
1803
+ "DirectoryError.reason is the host's specific refusal code, or null " +
1804
+ "when it gave none; branch on it, and show error.message to the person.",
1785
1805
  returnShape: {
1786
1806
  users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
1787
1807
  loading: "boolean",
1788
- error: "DirectoryError | null // { code, message, retryable }",
1808
+ error: "DirectoryError | null // { code, reason, message, retryable }",
1789
1809
  refetch: "() => Promise<void>",
1790
1810
  invite:
1791
- "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
1811
+ "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
1792
1812
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
1793
1813
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
1794
1814
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
1795
1815
  sendPasswordReset:
1796
1816
  "(userId) => Promise<{ sent, email_masked }> // rejects with DirectoryError",
1817
+ update:
1818
+ "(userId, { name?, email? }) => Promise<{ id, name, email }> // at least one key; an email change notifies the user at their previous address; rejects with DirectoryError (reason EMAIL_TAKEN, SERVICE_ACCOUNT_PROTECTED, …; status 429 when rate-limited)",
1797
1819
  },
1798
1820
  requiredContextSlice: ["directory.users"],
1799
1821
  scopes: ["users.read:*"],
@@ -1833,8 +1855,7 @@ const HOOKS = [
1833
1855
  // sc-5097 — pending AppUser invite administration. Returns
1834
1856
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
1835
1857
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
1836
- // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
1837
- // the first send of a `not_sent` invite — there is no separate `send`.
1858
+ // caller's `users.write` SystemAcl capability.
1838
1859
  {
1839
1860
  name: "useInvites",
1840
1861
  signature: "useInvites(query)",
@@ -1844,23 +1865,13 @@ const HOOKS = [
1844
1865
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
1845
1866
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
1846
1867
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
1847
- "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
1848
- "and status is the server-computed pending/accepted/revoked/expired/" +
1849
- "not_sent value. not_sent is an invitation created with " +
1850
- "send_invitation: false that has not been emailed yet: its sent_at is " +
1851
- "null, it cannot be accepted until it is sent, and its expires_at " +
1852
- "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
1853
- "ONE call that sends an invitation — the first send of a not_sent one " +
1854
- "and a repeat send of a sent one alike: it emails the invitee, stamps " +
1855
- "sent_at, restarts the 7-day expiry and resolves the row with status " +
1856
- "'pending'. There is no separate send method; label the button " +
1857
- "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
1858
- "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
1868
+ "res.data; rows are snake_case (email, status, expires_at, …) and " +
1869
+ "status is the server-computed pending/accepted/revoked/expired " +
1870
+ "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
1859
1871
  "verbatim; the endpoint defaults to status 'all', so pass " +
1860
- "{ status: 'pending' } for the usual outstanding-invite list and " +
1861
- "{ status: 'not_sent' } for the prepared ones; " +
1862
- "status 'open' is every invite neither accepted nor revoked (pending, " +
1863
- "not_sent or expired). q is a " +
1872
+ "{ status: 'pending' } for the usual outstanding-invite list; " +
1873
+ "status 'open' is every invite neither accepted nor revoked (pending " +
1874
+ "or expired). q is a " +
1864
1875
  "case-insensitive substring match on the invite's name or email. " +
1865
1876
  "Each row's app_user_id is the id the invitee's user account will " +
1866
1877
  "have once they accept; a USER column may already hold it, and " +
@@ -1877,12 +1888,12 @@ const HOOKS = [
1877
1888
  "refetch on success.",
1878
1889
  returnShape: {
1879
1890
  invites:
1880
- "Array<{ id, app_user_id, email, name, group_ids, status, sent_at, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; status is pending | accepted | revoked | expired | not_sent; unwrapped from { data, meta }",
1891
+ "Array<{ id, app_user_id, email, name, group_ids, status, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; unwrapped from { data, meta }",
1881
1892
  loading: "boolean",
1882
1893
  error: "DirectoryError | null // { code, message, retryable }",
1883
1894
  refetch: "() => Promise<void>",
1884
1895
  resend:
1885
- "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
1896
+ "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
1886
1897
  revoke:
1887
1898
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
1888
1899
  },
@@ -3049,7 +3060,7 @@ const WIDGET_CONTEXT_SHAPE = {
3049
3060
  "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
3050
3061
  "invites: { list(), revoke(id), resend(id) }, " +
3051
3062
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
3052
- "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke invites — resend also sends one created with send_invitation: false — gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
3063
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
3053
3064
  required: true,
3054
3065
  fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
3055
3066
  },
@@ -5081,7 +5092,14 @@ const CONTRACT = deepFreeze({
5081
5092
  // `scope: { kind: "granted" }` (the rows the signed-in user reads through
5082
5093
  // a user or group grant) and the `onAccessGained` / `onAccessLost`
5083
5094
  // handlers that report rows entering and leaving that set.
5084
- version: "1.154.0",
5095
+ // 1.155.0: narrowing (sc-10566) — withdraws 1.144.0: `useUsers().invite`
5096
+ // takes no `send_invitation` and always emails, invite rows carry no
5097
+ // `sent_at`, and `not_sent` is no longer a status.
5098
+ // 1.156.0: additive (sc-10429) — `useUsers()` gains
5099
+ // `update(userId, { name?, email? })`, gated on `users.write:*`; the
5100
+ // linter requires that scope for a `.update(` call beside `useUsers(`.
5101
+ // `DirectoryError` gains `reason`, the host's specific refusal code.
5102
+ version: "1.156.0",
5085
5103
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5086
5104
  hooks: HOOKS,
5087
5105
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1746,12 +1746,11 @@ const HOOKS = [
1746
1746
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
1747
1747
  // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
1748
1748
  // Reads need `users.read:*` scope; edit-style mutations (invite /
1749
- // deactivate / reactivate / sendPasswordReset) additionally need
1749
+ // deactivate / reactivate / sendPasswordReset / update) additionally need
1750
1750
  // `users.write:*`, and the
1751
1751
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
1752
- // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
1753
- // the resulting AppUserInvite row (the host sends the email, unless
1754
- // `send_invitation` is false). Mutating users from
1752
+ // call accepts `{ email, name, groupIds? }` and returns the resulting
1753
+ // AppUserInvite row (the email is sent by the host). Mutating users from
1755
1754
  // a widget requires the corresponding SystemAcl capability grant in the
1756
1755
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
1757
1756
  // that only call read methods need only `users.read:*`.
@@ -1761,39 +1760,62 @@ const HOOKS = [
1761
1760
  optionalArgs: ["query"],
1762
1761
  description:
1763
1762
  "AppUser administration via the injected directory-client at " +
1764
- "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
1763
+ "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}. " +
1765
1764
  "Returns { users, loading, error, refetch, invite, deactivate, " +
1766
- "reactivate, remove, sendPasswordReset }. " +
1765
+ "reactivate, remove, sendPasswordReset, update }. " +
1767
1766
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
1768
1767
  "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
1769
1768
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1770
- "sendPasswordReset) need users.write:*, and the destructive remove() " +
1769
+ "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
1771
1770
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
1772
- "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
1773
- "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
1774
- "The host emails the invitation at once unless send_invitation is " +
1775
- "false: then nothing is emailed, the row comes back with sent_at null " +
1776
- "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
1777
- "later. A send_invitation that is not a boolean rejects with " +
1778
- "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
1771
+ "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
1772
+ "(the email is sent by the host). sendPasswordReset(userId) mails the " +
1779
1773
  "standard self-serve reset link to that user's own registered address " +
1780
1774
  "and resolves { sent, email_masked } — never the token or the link, so " +
1781
1775
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
1782
- "coded USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL (an " +
1783
- "INTEGRATION service account); surface those as an explanation rather " +
1784
- "than a generic failure.",
1776
+ "whose reason is USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL " +
1777
+ "(an INTEGRATION service account); surface those as an explanation " +
1778
+ "rather than a generic failure. update(userId, { name?, email? }) " +
1779
+ "changes a user's name, email, or both (at least one key) and resolves " +
1780
+ "{ id, name, email }; refetch() to refresh the list. An email change " +
1781
+ "notifies the user at their previous address. users.write:* therefore " +
1782
+ "lets a widget change a member's name and sign-in email: request it " +
1783
+ "only for a surface meant for the people who administer the app. It " +
1784
+ "rejects with a DirectoryError; code is the broad category and reason " +
1785
+ "the specific refusal. code VALIDATION comes with reason " +
1786
+ "INVALID_UPDATE (neither key), INVALID_NAME, INVALID_EMAIL, " +
1787
+ "EMAIL_TAKEN (the address is not available), EMAIL_RESERVED (a " +
1788
+ "reserved address), SCIM_MANAGED_NAME / SCIM_MANAGED_EMAIL (the " +
1789
+ "profile is synced from an identity provider, so it is changed there), " +
1790
+ "SSO_LINKED_EMAIL (the user signs in through an identity provider, so " +
1791
+ "the email is changed there), SERVICE_ACCOUNT_PROTECTED (a service " +
1792
+ "account's email cannot be changed), SEED_USER_PROTECTED / " +
1793
+ "REVIEW_USER_PROTECTED (the app's preview or store-review account: " +
1794
+ "its email address cannot be changed) or OWN_EMAIL_CHANGE_REFUSED " +
1795
+ "(the signed-in user cannot change their own email here; they use " +
1796
+ "their account screen). Every refusal from EMAIL_TAKEN on, except " +
1797
+ "the SCIM_MANAGED pair, concerns the email only: a rename sent as " +
1798
+ "{ name } alone still succeeds. code NOT_FOUND is an unknown user; " +
1799
+ "code FORBIDDEN a caller without the users.write capability. Email " +
1800
+ "changes are rate-limited per caller: an HTTP 429 arrives as code " +
1801
+ "INTERNAL with status 429 and retryable true, and is never retried " +
1802
+ "automatically, so ask the person to try again later. " +
1803
+ "DirectoryError.reason is the host's specific refusal code, or null " +
1804
+ "when it gave none; branch on it, and show error.message to the person.",
1785
1805
  returnShape: {
1786
1806
  users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
1787
1807
  loading: "boolean",
1788
- error: "DirectoryError | null // { code, message, retryable }",
1808
+ error: "DirectoryError | null // { code, reason, message, retryable }",
1789
1809
  refetch: "() => Promise<void>",
1790
1810
  invite:
1791
- "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
1811
+ "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
1792
1812
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
1793
1813
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
1794
1814
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
1795
1815
  sendPasswordReset:
1796
1816
  "(userId) => Promise<{ sent, email_masked }> // rejects with DirectoryError",
1817
+ update:
1818
+ "(userId, { name?, email? }) => Promise<{ id, name, email }> // at least one key; an email change notifies the user at their previous address; rejects with DirectoryError (reason EMAIL_TAKEN, SERVICE_ACCOUNT_PROTECTED, …; status 429 when rate-limited)",
1797
1819
  },
1798
1820
  requiredContextSlice: ["directory.users"],
1799
1821
  scopes: ["users.read:*"],
@@ -1833,8 +1855,7 @@ const HOOKS = [
1833
1855
  // sc-5097 — pending AppUser invite administration. Returns
1834
1856
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
1835
1857
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
1836
- // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
1837
- // the first send of a `not_sent` invite — there is no separate `send`.
1858
+ // caller's `users.write` SystemAcl capability.
1838
1859
  {
1839
1860
  name: "useInvites",
1840
1861
  signature: "useInvites(query)",
@@ -1844,23 +1865,13 @@ const HOOKS = [
1844
1865
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
1845
1866
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
1846
1867
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
1847
- "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
1848
- "and status is the server-computed pending/accepted/revoked/expired/" +
1849
- "not_sent value. not_sent is an invitation created with " +
1850
- "send_invitation: false that has not been emailed yet: its sent_at is " +
1851
- "null, it cannot be accepted until it is sent, and its expires_at " +
1852
- "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
1853
- "ONE call that sends an invitation — the first send of a not_sent one " +
1854
- "and a repeat send of a sent one alike: it emails the invitee, stamps " +
1855
- "sent_at, restarts the 7-day expiry and resolves the row with status " +
1856
- "'pending'. There is no separate send method; label the button " +
1857
- "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
1858
- "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
1868
+ "res.data; rows are snake_case (email, status, expires_at, …) and " +
1869
+ "status is the server-computed pending/accepted/revoked/expired " +
1870
+ "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
1859
1871
  "verbatim; the endpoint defaults to status 'all', so pass " +
1860
- "{ status: 'pending' } for the usual outstanding-invite list and " +
1861
- "{ status: 'not_sent' } for the prepared ones; " +
1862
- "status 'open' is every invite neither accepted nor revoked (pending, " +
1863
- "not_sent or expired). q is a " +
1872
+ "{ status: 'pending' } for the usual outstanding-invite list; " +
1873
+ "status 'open' is every invite neither accepted nor revoked (pending " +
1874
+ "or expired). q is a " +
1864
1875
  "case-insensitive substring match on the invite's name or email. " +
1865
1876
  "Each row's app_user_id is the id the invitee's user account will " +
1866
1877
  "have once they accept; a USER column may already hold it, and " +
@@ -1877,12 +1888,12 @@ const HOOKS = [
1877
1888
  "refetch on success.",
1878
1889
  returnShape: {
1879
1890
  invites:
1880
- "Array<{ id, app_user_id, email, name, group_ids, status, sent_at, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; status is pending | accepted | revoked | expired | not_sent; unwrapped from { data, meta }",
1891
+ "Array<{ id, app_user_id, email, name, group_ids, status, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; unwrapped from { data, meta }",
1881
1892
  loading: "boolean",
1882
1893
  error: "DirectoryError | null // { code, message, retryable }",
1883
1894
  refetch: "() => Promise<void>",
1884
1895
  resend:
1885
- "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
1896
+ "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
1886
1897
  revoke:
1887
1898
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
1888
1899
  },
@@ -3045,7 +3056,7 @@ const WIDGET_CONTEXT_SHAPE = {
3045
3056
  "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
3046
3057
  "invites: { list(), revoke(id), resend(id) }, " +
3047
3058
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
3048
- "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke invites — resend also sends one created with send_invitation: false — gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
3059
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
3049
3060
  required: true,
3050
3061
  fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
3051
3062
  },
@@ -5077,7 +5088,14 @@ const CONTRACT = deepFreeze({
5077
5088
  // `scope: { kind: "granted" }` (the rows the signed-in user reads through
5078
5089
  // a user or group grant) and the `onAccessGained` / `onAccessLost`
5079
5090
  // handlers that report rows entering and leaving that set.
5080
- version: "1.154.0",
5091
+ // 1.155.0: narrowing (sc-10566) — withdraws 1.144.0: `useUsers().invite`
5092
+ // takes no `send_invitation` and always emails, invite rows carry no
5093
+ // `sent_at`, and `not_sent` is no longer a status.
5094
+ // 1.156.0: additive (sc-10429) — `useUsers()` gains
5095
+ // `update(userId, { name?, email? })`, gated on `users.write:*`; the
5096
+ // linter requires that scope for a `.update(` call beside `useUsers(`.
5097
+ // `DirectoryError` gains `reason`, the host's specific refusal code.
5098
+ version: "1.156.0",
5081
5099
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5082
5100
  hooks: HOOKS,
5083
5101
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -5394,6 +5394,8 @@ export function useFolderPermissions(folderId, options = {}) {
5394
5394
  * - "INVITE_ONLY" — 409 from accept-invite-style flows where the tenant
5395
5395
  * is invite-only and the email is not on the list.
5396
5396
  * - "INTERNAL" — anything else (network, 5xx).
5397
+ *
5398
+ * `reason` is the host's own refusal code ("EMAIL_TAKEN", …) or null.
5397
5399
  */
5398
5400
  // sc-4986 — the directory refusals retrying the same call can never clear.
5399
5401
  const NON_RETRYABLE_DIRECTORY_CODES = new Set([
@@ -5415,6 +5417,10 @@ export class DirectoryError extends Error {
5415
5417
  opts && opts.retryable !== undefined
5416
5418
  ? Boolean(opts.retryable)
5417
5419
  : !NON_RETRYABLE_DIRECTORY_CODES.has(code);
5420
+ this.reason =
5421
+ opts && typeof opts.reason === "string" && opts.reason
5422
+ ? opts.reason
5423
+ : null;
5418
5424
  if (opts && opts.cause) this.cause = opts.cause;
5419
5425
  }
5420
5426
  }
@@ -5440,7 +5446,12 @@ function toDirectoryError(err) {
5440
5446
  (err && typeof err.message === "string"
5441
5447
  ? err.message
5442
5448
  : "Directory call failed"),
5443
- { cause: err, status, retryable: isRetryableDomainStatus(status) },
5449
+ {
5450
+ cause: err,
5451
+ status,
5452
+ retryable: isRetryableDomainStatus(status),
5453
+ reason: envelopeCode,
5454
+ },
5444
5455
  );
5445
5456
  }
5446
5457
 
@@ -5534,12 +5545,12 @@ export function useDirectory(query) {
5534
5545
  * REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
5535
5546
  *
5536
5547
  * Returns `{ users, loading, error, refetch, invite, deactivate,
5537
- * reactivate, remove }`. Reads through the injected
5548
+ * reactivate, remove, sendPasswordReset, update }`. Reads through the injected
5538
5549
  * `@colixsystems/directory-client` at `ctx.directory.users.{list, get,
5539
- * invite, deactivate, reactivate}` — `list` resolves to the `{ data, meta }`
5550
+ * invite, deactivate, reactivate, sendPasswordReset, update}` — `list` resolves to the `{ data, meta }`
5540
5551
  * envelope VERBATIM, so we unwrap `res.data` (default `[]`). User rows are
5541
5552
  * snake_case (`id`, `name`, `email`, `role`, `is_active`, …) and bodies
5542
- * (e.g. `{ email, name, group_ids?, send_invitation? }`) pass through verbatim. `query` is an
5553
+ * (e.g. `{ email, name, group_ids? }`) pass through verbatim. `query` is an
5543
5554
  * optional `{ q?, role?, is_active?, group_id?, limit?, offset? }`; `group_id`
5544
5555
  * (the `id` of a `useGroups()` row) narrows the list to that group's members.
5545
5556
  * The list refetches whenever `JSON.stringify(query)` changes; the imperative methods
@@ -5637,7 +5648,7 @@ export function useUsers(query) {
5637
5648
  // sc-5335: mail the self-serve reset link to a user who has locked
5638
5649
  // themselves out. Resolves `{ sent, email_masked }`; the host never returns
5639
5650
  // the token, so this cannot be used to sign in as that user. Rejects with a
5640
- // DirectoryError carrying `code` USER_INACTIVE or NO_PASSWORD_CREDENTIAL
5651
+ // DirectoryError carrying `reason` USER_INACTIVE or NO_PASSWORD_CREDENTIAL
5641
5652
  // when the target cannot receive one.
5642
5653
  const sendPasswordReset = useCallback(async (userId) => {
5643
5654
  if (typeof usersRef.current.sendPasswordReset !== "function") {
@@ -5653,6 +5664,19 @@ export function useUsers(query) {
5653
5664
  throw toDirectoryError(err);
5654
5665
  }
5655
5666
  }, []);
5667
+ // An email change notifies the user at their previous address.
5668
+ const update = useCallback(async (userId, changes) => {
5669
+ if (typeof usersRef.current.update !== "function") {
5670
+ throw toDirectoryError(
5671
+ new Error("useUsers: this host's directory client predates update"),
5672
+ );
5673
+ }
5674
+ try {
5675
+ return await usersRef.current.update(userId, changes);
5676
+ } catch (err) {
5677
+ throw toDirectoryError(err);
5678
+ }
5679
+ }, []);
5656
5680
 
5657
5681
  return {
5658
5682
  users,
@@ -5664,6 +5688,7 @@ export function useUsers(query) {
5664
5688
  reactivate,
5665
5689
  remove,
5666
5690
  sendPasswordReset,
5691
+ update,
5667
5692
  };
5668
5693
  }
5669
5694
 
@@ -5776,20 +5801,15 @@ export function useGroups(query) {
5776
5801
  * `{ data, meta }` envelope VERBATIM, so we unwrap `res.data` (default `[]`).
5777
5802
  *
5778
5803
  * Rows are snake_case exactly as the backend projects them: `{ id, tenant_id,
5779
- * email, name, group_ids, status, sent_at, expires_at, accepted_at,
5780
- * revoked_at, created_at, invited_by_studio_user_id }`. `status` is the
5781
- * server-computed lifecycle value (`"pending" | "accepted" | "revoked" |
5782
- * "expired" | "not_sent"`) — prefer it over re-deriving expiry on the client,
5783
- * whose clock may disagree.
5784
- *
5785
- * `not_sent` is an invite created with `send_invitation: false`: `sent_at` is
5786
- * null and nothing was emailed. `resend(inviteId)` is the one call that sends
5787
- * — a first send and a repeat send alike — and resolves the row as `pending`.
5804
+ * email, name, group_ids, status, expires_at, accepted_at, revoked_at,
5805
+ * created_at, invited_by_studio_user_id }`. `status` is the server-computed
5806
+ * lifecycle value (`"pending" | "accepted" | "revoked" | "expired"`) — prefer
5807
+ * it over re-deriving expiry on the client, whose clock may disagree.
5788
5808
  *
5789
5809
  * `query` is an optional `{ status?, limit?, offset? }` passed through
5790
5810
  * verbatim; `status` accepts `"pending" | "accepted" | "revoked" | "expired" |
5791
- * "not_sent" | "all"` and the endpoint defaults to `"all"`, so pass
5792
- * `{ status: "pending" }` for the usual "who hasn't accepted yet" list.
5811
+ * "all"` and the endpoint defaults to `"all"`, so pass `{ status: "pending" }`
5812
+ * for the usual "who hasn't accepted yet" list.
5793
5813
  *
5794
5814
  * The WHOLE invite surface — list included — is gated on the `users.write`
5795
5815
  * capability plus a signed `users.write:*` scope: a pending invite exposes
@@ -5856,8 +5876,7 @@ export function useInvites(query) {
5856
5876
  }, [doFetch]);
5857
5877
 
5858
5878
  // Both mutations re-list on success: revoking drops the row and resending
5859
- // moves `expires_at` (and a first send's status), so a stale list would
5860
- // show the operator the old state.
5879
+ // moves `expires_at`, so a stale list would show the operator the old state.
5861
5880
  const resend = useCallback(
5862
5881
  async (inviteId) => {
5863
5882
  try {
package/dist/index.d.ts CHANGED
@@ -521,6 +521,8 @@ export interface DirectoryClient {
521
521
  invite(body: Record<string, unknown>): Promise<unknown>;
522
522
  deactivate(userId: string): Promise<unknown>;
523
523
  reactivate(userId: string): Promise<unknown>;
524
+ sendPasswordReset?(userId: string): Promise<unknown>;
525
+ update?(userId: string, body: Record<string, unknown>): Promise<unknown>;
524
526
  };
525
527
  groups: {
526
528
  list(query?: GroupsQuery): Promise<ListEnvelope>;
@@ -2316,12 +2318,15 @@ export class DirectoryError extends Error {
2316
2318
  status: number | null;
2317
2319
  /** sc-4986 — whether retrying the SAME call could plausibly succeed. */
2318
2320
  retryable: boolean;
2321
+ /** The host's specific refusal code (`"EMAIL_TAKEN"`, …); null when none. */
2322
+ reason: string | null;
2319
2323
  constructor(
2320
2324
  code: DirectoryError["code"],
2321
2325
  message: string,
2322
2326
  opts?: {
2323
2327
  status?: number | null;
2324
2328
  retryable?: boolean;
2329
+ reason?: string | null;
2325
2330
  cause?: unknown;
2326
2331
  },
2327
2332
  );
@@ -2442,25 +2447,13 @@ export interface InviteArgs {
2442
2447
  email: string;
2443
2448
  name?: string;
2444
2449
  group_ids?: string[];
2445
- /**
2446
- * Defaults to `true`: the invitation is emailed at once. `false` stores it
2447
- * without emailing anyone (`status: "not_sent"`); send it later with
2448
- * `useInvites().resend(inviteId)`.
2449
- */
2450
- send_invitation?: boolean;
2451
2450
  }
2452
2451
 
2453
- /**
2454
- * When several apply the first of accepted, revoked, not_sent, expired,
2455
- * pending wins. `not_sent` is an invitation created with
2456
- * `send_invitation: false` that has not been emailed yet.
2457
- */
2458
2452
  export type AppUserInviteStatus =
2459
2453
  | "pending"
2460
2454
  | "accepted"
2461
2455
  | "revoked"
2462
- | "expired"
2463
- | "not_sent";
2456
+ | "expired";
2464
2457
 
2465
2458
  export interface AppUserInviteRow {
2466
2459
  id: string;
@@ -2475,9 +2468,7 @@ export interface AppUserInviteRow {
2475
2468
  status: AppUserInviteStatus;
2476
2469
  name?: string;
2477
2470
  group_ids?: string[];
2478
- /** When the invitation email last left; `null` until it is first sent. */
2479
- sent_at: string | null;
2480
- /** Meaningless while `status` is `"not_sent"` — it restarts on the send. */
2471
+ invited_at?: string;
2481
2472
  expires_at?: string;
2482
2473
  accepted_at?: string | null;
2483
2474
  revoked_at?: string | null;
@@ -2486,6 +2477,17 @@ export interface AppUserInviteRow {
2486
2477
  invited_by_studio_user_id?: string | null;
2487
2478
  }
2488
2479
 
2480
+ export interface UserProfileChanges {
2481
+ name?: string;
2482
+ email?: string;
2483
+ }
2484
+
2485
+ export interface AppUserProfile {
2486
+ id: string;
2487
+ name: string | null;
2488
+ email: string | null;
2489
+ }
2490
+
2489
2491
  export interface UsersApi {
2490
2492
  users: AppUserRow[];
2491
2493
  loading: boolean;
@@ -2495,11 +2497,29 @@ export interface UsersApi {
2495
2497
  deactivate(userId: string): Promise<AppUserRow>;
2496
2498
  reactivate(userId: string): Promise<AppUserRow>;
2497
2499
  remove(userId: string): Promise<void>;
2500
+ sendPasswordReset(
2501
+ userId: string,
2502
+ ): Promise<{ sent: boolean; email_masked: string }>;
2503
+ /**
2504
+ * Changes a user's name, sign-in email, or both; at least one key is
2505
+ * required. An email change notifies the user at their previous address.
2506
+ * Rejects with a `DirectoryError` whose `reason` names the refusal:
2507
+ * `INVALID_UPDATE`, `INVALID_NAME`, `INVALID_EMAIL`, `EMAIL_TAKEN`,
2508
+ * `EMAIL_RESERVED`, `SCIM_MANAGED_NAME`, `SCIM_MANAGED_EMAIL`,
2509
+ * `SSO_LINKED_EMAIL`, `SERVICE_ACCOUNT_PROTECTED`, `SEED_USER_PROTECTED`,
2510
+ * `REVIEW_USER_PROTECTED`, `OWN_EMAIL_CHANGE_REFUSED`. Email changes are
2511
+ * rate-limited: a 429 rejects with `status: 429`, `code: "INTERNAL"`,
2512
+ * `retryable: true`, and is never retried automatically.
2513
+ */
2514
+ update(
2515
+ userId: string,
2516
+ changes: UserProfileChanges,
2517
+ ): Promise<AppUserProfile>;
2498
2518
  }
2499
2519
 
2500
2520
  /**
2501
2521
  * AppUser administration. Reads through the injected directory-client at
2502
- * `ctx.directory.users.{list,get,invite,deactivate,reactivate}`. Reads
2522
+ * `ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}`. Reads
2503
2523
  * require the `users.read:*` scope in the manifest; mutations additionally
2504
2524
  * require `users.write:*`. Widgets that declare the scopes but whose calling
2505
2525
  * APP_USER lacks the corresponding SystemAcl `users.*` capability grant get a
@@ -2546,9 +2566,8 @@ export function useGroups(query?: GroupsQuery): GroupsApi;
2546
2566
 
2547
2567
  export interface InvitesQuery {
2548
2568
  /**
2549
- * Defaults to "all" server-side; pass "pending" for outstanding invites and
2550
- * "not_sent" for the ones prepared but not emailed yet.
2551
- * "open" is "pending", "not_sent" or "expired": neither accepted nor revoked.
2569
+ * Defaults to "all" server-side; pass "pending" for outstanding invites.
2570
+ * "open" is "pending" or "expired": neither accepted nor revoked.
2552
2571
  */
2553
2572
  status?: AppUserInviteStatus | "open" | "all";
2554
2573
  /** Case-insensitive substring match on the invite's name or email. */
@@ -2569,12 +2588,7 @@ export interface InvitesApi {
2569
2588
  loading: boolean;
2570
2589
  error: DirectoryError | null;
2571
2590
  refetch(): Promise<void>;
2572
- /**
2573
- * Email the invitation and restart its 7-day expiry. The one call that
2574
- * sends: the first send of a `not_sent` invitation and a repeat send of a
2575
- * sent one alike. Resolves the row with `status: "pending"`; refetches on
2576
- * success.
2577
- */
2591
+ /** Re-send the invitation email. Refetches on success. */
2578
2592
  resend(inviteId: string): Promise<AppUserInviteRow>;
2579
2593
  /** Cancel a pending invitation. Refetches on success. */
2580
2594
  revoke(inviteId: string): Promise<void>;
package/dist/linter.cjs CHANGED
@@ -657,6 +657,7 @@ const USER_MUTATION_METHODS = [
657
657
  // sc-5335 — mailing a reset link is edit-style, so it rides `users.write`
658
658
  // alongside deactivate rather than the destructive `users.delete`.
659
659
  "sendPasswordReset",
660
+ "update",
660
661
  ];
661
662
  // SC-902 — destructive user removal is gated on the dedicated
662
663
  // `users.delete` capability, NOT `users.write`. A widget that calls
package/dist/linter.js CHANGED
@@ -587,7 +587,7 @@ function _translationApiRules(source) {
587
587
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation.
588
588
  //
589
589
  // A widget that calls `useUsers().invite()` / `.deactivate()` /
590
- // `.reactivate()` / `.sendPasswordReset()` / `.remove()` MUST declare
590
+ // `.reactivate()` / `.sendPasswordReset()` / `.update()` / `.remove()` MUST declare
591
591
  // `users.write:*` in its
592
592
  // manifest's `requestedScopes`; similarly `useGroups()` mutation methods
593
593
  // require `groups.write:*`. The rule is enforced statically so a manifest
@@ -608,6 +608,7 @@ const USER_MUTATION_METHODS = [
608
608
  // sc-5335 — mailing a reset link is edit-style, so it rides `users.write`
609
609
  // alongside deactivate rather than the destructive `users.delete`.
610
610
  "sendPasswordReset",
611
+ "update",
611
612
  ];
612
613
  // SC-902 — destructive user removal is gated on the dedicated
613
614
  // `users.delete` capability, NOT `users.write`. A widget that calls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.193.0",
3
+ "version": "0.195.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -1746,12 +1746,11 @@ const HOOKS = [
1746
1746
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
1747
1747
  // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
1748
1748
  // Reads need `users.read:*` scope; edit-style mutations (invite /
1749
- // deactivate / reactivate / sendPasswordReset) additionally need
1749
+ // deactivate / reactivate / sendPasswordReset / update) additionally need
1750
1750
  // `users.write:*`, and the
1751
1751
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
1752
- // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
1753
- // the resulting AppUserInvite row (the host sends the email, unless
1754
- // `send_invitation` is false). Mutating users from
1752
+ // call accepts `{ email, name, groupIds? }` and returns the resulting
1753
+ // AppUserInvite row (the email is sent by the host). Mutating users from
1755
1754
  // a widget requires the corresponding SystemAcl capability grant in the
1756
1755
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
1757
1756
  // that only call read methods need only `users.read:*`.
@@ -1761,39 +1760,62 @@ const HOOKS = [
1761
1760
  optionalArgs: ["query"],
1762
1761
  description:
1763
1762
  "AppUser administration via the injected directory-client at " +
1764
- "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
1763
+ "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}. " +
1765
1764
  "Returns { users, loading, error, refetch, invite, deactivate, " +
1766
- "reactivate, remove, sendPasswordReset }. " +
1765
+ "reactivate, remove, sendPasswordReset, update }. " +
1767
1766
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
1768
1767
  "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
1769
1768
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1770
- "sendPasswordReset) need users.write:*, and the destructive remove() " +
1769
+ "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
1771
1770
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
1772
- "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
1773
- "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
1774
- "The host emails the invitation at once unless send_invitation is " +
1775
- "false: then nothing is emailed, the row comes back with sent_at null " +
1776
- "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
1777
- "later. A send_invitation that is not a boolean rejects with " +
1778
- "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
1771
+ "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
1772
+ "(the email is sent by the host). sendPasswordReset(userId) mails the " +
1779
1773
  "standard self-serve reset link to that user's own registered address " +
1780
1774
  "and resolves { sent, email_masked } — never the token or the link, so " +
1781
1775
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
1782
- "coded USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL (an " +
1783
- "INTEGRATION service account); surface those as an explanation rather " +
1784
- "than a generic failure.",
1776
+ "whose reason is USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL " +
1777
+ "(an INTEGRATION service account); surface those as an explanation " +
1778
+ "rather than a generic failure. update(userId, { name?, email? }) " +
1779
+ "changes a user's name, email, or both (at least one key) and resolves " +
1780
+ "{ id, name, email }; refetch() to refresh the list. An email change " +
1781
+ "notifies the user at their previous address. users.write:* therefore " +
1782
+ "lets a widget change a member's name and sign-in email: request it " +
1783
+ "only for a surface meant for the people who administer the app. It " +
1784
+ "rejects with a DirectoryError; code is the broad category and reason " +
1785
+ "the specific refusal. code VALIDATION comes with reason " +
1786
+ "INVALID_UPDATE (neither key), INVALID_NAME, INVALID_EMAIL, " +
1787
+ "EMAIL_TAKEN (the address is not available), EMAIL_RESERVED (a " +
1788
+ "reserved address), SCIM_MANAGED_NAME / SCIM_MANAGED_EMAIL (the " +
1789
+ "profile is synced from an identity provider, so it is changed there), " +
1790
+ "SSO_LINKED_EMAIL (the user signs in through an identity provider, so " +
1791
+ "the email is changed there), SERVICE_ACCOUNT_PROTECTED (a service " +
1792
+ "account's email cannot be changed), SEED_USER_PROTECTED / " +
1793
+ "REVIEW_USER_PROTECTED (the app's preview or store-review account: " +
1794
+ "its email address cannot be changed) or OWN_EMAIL_CHANGE_REFUSED " +
1795
+ "(the signed-in user cannot change their own email here; they use " +
1796
+ "their account screen). Every refusal from EMAIL_TAKEN on, except " +
1797
+ "the SCIM_MANAGED pair, concerns the email only: a rename sent as " +
1798
+ "{ name } alone still succeeds. code NOT_FOUND is an unknown user; " +
1799
+ "code FORBIDDEN a caller without the users.write capability. Email " +
1800
+ "changes are rate-limited per caller: an HTTP 429 arrives as code " +
1801
+ "INTERNAL with status 429 and retryable true, and is never retried " +
1802
+ "automatically, so ask the person to try again later. " +
1803
+ "DirectoryError.reason is the host's specific refusal code, or null " +
1804
+ "when it gave none; branch on it, and show error.message to the person.",
1785
1805
  returnShape: {
1786
1806
  users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
1787
1807
  loading: "boolean",
1788
- error: "DirectoryError | null // { code, message, retryable }",
1808
+ error: "DirectoryError | null // { code, reason, message, retryable }",
1789
1809
  refetch: "() => Promise<void>",
1790
1810
  invite:
1791
- "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
1811
+ "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
1792
1812
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
1793
1813
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
1794
1814
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
1795
1815
  sendPasswordReset:
1796
1816
  "(userId) => Promise<{ sent, email_masked }> // rejects with DirectoryError",
1817
+ update:
1818
+ "(userId, { name?, email? }) => Promise<{ id, name, email }> // at least one key; an email change notifies the user at their previous address; rejects with DirectoryError (reason EMAIL_TAKEN, SERVICE_ACCOUNT_PROTECTED, …; status 429 when rate-limited)",
1797
1819
  },
1798
1820
  requiredContextSlice: ["directory.users"],
1799
1821
  scopes: ["users.read:*"],
@@ -1833,8 +1855,7 @@ const HOOKS = [
1833
1855
  // sc-5097 — pending AppUser invite administration. Returns
1834
1856
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
1835
1857
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
1836
- // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
1837
- // the first send of a `not_sent` invite — there is no separate `send`.
1858
+ // caller's `users.write` SystemAcl capability.
1838
1859
  {
1839
1860
  name: "useInvites",
1840
1861
  signature: "useInvites(query)",
@@ -1844,23 +1865,13 @@ const HOOKS = [
1844
1865
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
1845
1866
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
1846
1867
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
1847
- "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
1848
- "and status is the server-computed pending/accepted/revoked/expired/" +
1849
- "not_sent value. not_sent is an invitation created with " +
1850
- "send_invitation: false that has not been emailed yet: its sent_at is " +
1851
- "null, it cannot be accepted until it is sent, and its expires_at " +
1852
- "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
1853
- "ONE call that sends an invitation — the first send of a not_sent one " +
1854
- "and a repeat send of a sent one alike: it emails the invitee, stamps " +
1855
- "sent_at, restarts the 7-day expiry and resolves the row with status " +
1856
- "'pending'. There is no separate send method; label the button " +
1857
- "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
1858
- "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
1868
+ "res.data; rows are snake_case (email, status, expires_at, …) and " +
1869
+ "status is the server-computed pending/accepted/revoked/expired " +
1870
+ "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
1859
1871
  "verbatim; the endpoint defaults to status 'all', so pass " +
1860
- "{ status: 'pending' } for the usual outstanding-invite list and " +
1861
- "{ status: 'not_sent' } for the prepared ones; " +
1862
- "status 'open' is every invite neither accepted nor revoked (pending, " +
1863
- "not_sent or expired). q is a " +
1872
+ "{ status: 'pending' } for the usual outstanding-invite list; " +
1873
+ "status 'open' is every invite neither accepted nor revoked (pending " +
1874
+ "or expired). q is a " +
1864
1875
  "case-insensitive substring match on the invite's name or email. " +
1865
1876
  "Each row's app_user_id is the id the invitee's user account will " +
1866
1877
  "have once they accept; a USER column may already hold it, and " +
@@ -1877,12 +1888,12 @@ const HOOKS = [
1877
1888
  "refetch on success.",
1878
1889
  returnShape: {
1879
1890
  invites:
1880
- "Array<{ id, app_user_id, email, name, group_ids, status, sent_at, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; status is pending | accepted | revoked | expired | not_sent; unwrapped from { data, meta }",
1891
+ "Array<{ id, app_user_id, email, name, group_ids, status, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; unwrapped from { data, meta }",
1881
1892
  loading: "boolean",
1882
1893
  error: "DirectoryError | null // { code, message, retryable }",
1883
1894
  refetch: "() => Promise<void>",
1884
1895
  resend:
1885
- "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
1896
+ "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
1886
1897
  revoke:
1887
1898
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
1888
1899
  },
@@ -3045,7 +3056,7 @@ const WIDGET_CONTEXT_SHAPE = {
3045
3056
  "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
3046
3057
  "invites: { list(), revoke(id), resend(id) }, " +
3047
3058
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
3048
- "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke invites — resend also sends one created with send_invitation: false — gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
3059
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
3049
3060
  required: true,
3050
3061
  fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
3051
3062
  },
@@ -5077,7 +5088,14 @@ const CONTRACT = deepFreeze({
5077
5088
  // `scope: { kind: "granted" }` (the rows the signed-in user reads through
5078
5089
  // a user or group grant) and the `onAccessGained` / `onAccessLost`
5079
5090
  // handlers that report rows entering and leaving that set.
5080
- version: "1.154.0",
5091
+ // 1.155.0: narrowing (sc-10566) — withdraws 1.144.0: `useUsers().invite`
5092
+ // takes no `send_invitation` and always emails, invite rows carry no
5093
+ // `sent_at`, and `not_sent` is no longer a status.
5094
+ // 1.156.0: additive (sc-10429) — `useUsers()` gains
5095
+ // `update(userId, { name?, email? })`, gated on `users.write:*`; the
5096
+ // linter requires that scope for a `.update(` call beside `useUsers(`.
5097
+ // `DirectoryError` gains `reason`, the host's specific refusal code.
5098
+ version: "1.156.0",
5081
5099
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5082
5100
  hooks: HOOKS,
5083
5101
  primitives: PRIMITIVES,