@colixsystems/widget-sdk 0.194.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,7 +60,7 @@ 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
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) |
@@ -77,7 +77,55 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
77
77
 
78
78
  ## Status
79
79
 
80
- `v0.194.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`.
81
129
 
82
130
  ### What's new in 0.194.0 (contract 1.155.0)
83
131
 
@@ -2236,7 +2284,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
2236
2284
 
2237
2285
  ## Managing app users from a widget
2238
2286
 
2239
- `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.
2240
2288
 
2241
2289
  ```js
2242
2290
  import { Text, View, Pressable, useUsers, useGroups } from "@colixsystems/widget-sdk";
package/dist/contract.cjs CHANGED
@@ -1746,7 +1746,7 @@ 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
1752
  // call accepts `{ email, name, groupIds? }` and returns the resulting
@@ -1760,26 +1760,52 @@ const HOOKS = [
1760
1760
  optionalArgs: ["query"],
1761
1761
  description:
1762
1762
  "AppUser administration via the injected directory-client at " +
1763
- "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
1763
+ "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}. " +
1764
1764
  "Returns { users, loading, error, refetch, invite, deactivate, " +
1765
- "reactivate, remove, sendPasswordReset }. " +
1765
+ "reactivate, remove, sendPasswordReset, update }. " +
1766
1766
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
1767
1767
  "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
1768
1768
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1769
- "sendPasswordReset) need users.write:*, and the destructive remove() " +
1769
+ "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
1770
1770
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
1771
1771
  "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
1772
1772
  "(the email is sent by the host). sendPasswordReset(userId) mails the " +
1773
1773
  "standard self-serve reset link to that user's own registered address " +
1774
1774
  "and resolves { sent, email_masked } — never the token or the link, so " +
1775
1775
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
1776
- "coded USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL (an " +
1777
- "INTEGRATION service account); surface those as an explanation rather " +
1778
- "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.",
1779
1805
  returnShape: {
1780
1806
  users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
1781
1807
  loading: "boolean",
1782
- error: "DirectoryError | null // { code, message, retryable }",
1808
+ error: "DirectoryError | null // { code, reason, message, retryable }",
1783
1809
  refetch: "() => Promise<void>",
1784
1810
  invite:
1785
1811
  "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
@@ -1788,6 +1814,8 @@ const HOOKS = [
1788
1814
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
1789
1815
  sendPasswordReset:
1790
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)",
1791
1819
  },
1792
1820
  requiredContextSlice: ["directory.users"],
1793
1821
  scopes: ["users.read:*"],
@@ -5067,7 +5095,11 @@ const CONTRACT = deepFreeze({
5067
5095
  // 1.155.0: narrowing (sc-10566) — withdraws 1.144.0: `useUsers().invite`
5068
5096
  // takes no `send_invitation` and always emails, invite rows carry no
5069
5097
  // `sent_at`, and `not_sent` is no longer a status.
5070
- version: "1.155.0",
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",
5071
5103
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5072
5104
  hooks: HOOKS,
5073
5105
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1746,7 +1746,7 @@ 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
1752
  // call accepts `{ email, name, groupIds? }` and returns the resulting
@@ -1760,26 +1760,52 @@ const HOOKS = [
1760
1760
  optionalArgs: ["query"],
1761
1761
  description:
1762
1762
  "AppUser administration via the injected directory-client at " +
1763
- "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
1763
+ "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}. " +
1764
1764
  "Returns { users, loading, error, refetch, invite, deactivate, " +
1765
- "reactivate, remove, sendPasswordReset }. " +
1765
+ "reactivate, remove, sendPasswordReset, update }. " +
1766
1766
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
1767
1767
  "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
1768
1768
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1769
- "sendPasswordReset) need users.write:*, and the destructive remove() " +
1769
+ "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
1770
1770
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
1771
1771
  "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
1772
1772
  "(the email is sent by the host). sendPasswordReset(userId) mails the " +
1773
1773
  "standard self-serve reset link to that user's own registered address " +
1774
1774
  "and resolves { sent, email_masked } — never the token or the link, so " +
1775
1775
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
1776
- "coded USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL (an " +
1777
- "INTEGRATION service account); surface those as an explanation rather " +
1778
- "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.",
1779
1805
  returnShape: {
1780
1806
  users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
1781
1807
  loading: "boolean",
1782
- error: "DirectoryError | null // { code, message, retryable }",
1808
+ error: "DirectoryError | null // { code, reason, message, retryable }",
1783
1809
  refetch: "() => Promise<void>",
1784
1810
  invite:
1785
1811
  "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
@@ -1788,6 +1814,8 @@ const HOOKS = [
1788
1814
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
1789
1815
  sendPasswordReset:
1790
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)",
1791
1819
  },
1792
1820
  requiredContextSlice: ["directory.users"],
1793
1821
  scopes: ["users.read:*"],
@@ -5063,7 +5091,11 @@ const CONTRACT = deepFreeze({
5063
5091
  // 1.155.0: narrowing (sc-10566) — withdraws 1.144.0: `useUsers().invite`
5064
5092
  // takes no `send_invitation` and always emails, invite rows carry no
5065
5093
  // `sent_at`, and `not_sent` is no longer a status.
5066
- version: "1.155.0",
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",
5067
5099
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5068
5100
  hooks: HOOKS,
5069
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,9 +5545,9 @@ 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
5553
  * (e.g. `{ email, name, group_ids? }`) pass through verbatim. `query` is an
@@ -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
 
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
  );
@@ -2472,6 +2477,17 @@ export interface AppUserInviteRow {
2472
2477
  invited_by_studio_user_id?: string | null;
2473
2478
  }
2474
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
+
2475
2491
  export interface UsersApi {
2476
2492
  users: AppUserRow[];
2477
2493
  loading: boolean;
@@ -2481,11 +2497,29 @@ export interface UsersApi {
2481
2497
  deactivate(userId: string): Promise<AppUserRow>;
2482
2498
  reactivate(userId: string): Promise<AppUserRow>;
2483
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>;
2484
2518
  }
2485
2519
 
2486
2520
  /**
2487
2521
  * AppUser administration. Reads through the injected directory-client at
2488
- * `ctx.directory.users.{list,get,invite,deactivate,reactivate}`. Reads
2522
+ * `ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}`. Reads
2489
2523
  * require the `users.read:*` scope in the manifest; mutations additionally
2490
2524
  * require `users.write:*`. Widgets that declare the scopes but whose calling
2491
2525
  * APP_USER lacks the corresponding SystemAcl `users.*` capability grant get a
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.194.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,7 +1746,7 @@ 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
1752
  // call accepts `{ email, name, groupIds? }` and returns the resulting
@@ -1760,26 +1760,52 @@ const HOOKS = [
1760
1760
  optionalArgs: ["query"],
1761
1761
  description:
1762
1762
  "AppUser administration via the injected directory-client at " +
1763
- "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
1763
+ "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}. " +
1764
1764
  "Returns { users, loading, error, refetch, invite, deactivate, " +
1765
- "reactivate, remove, sendPasswordReset }. " +
1765
+ "reactivate, remove, sendPasswordReset, update }. " +
1766
1766
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
1767
1767
  "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
1768
1768
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1769
- "sendPasswordReset) need users.write:*, and the destructive remove() " +
1769
+ "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
1770
1770
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
1771
1771
  "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
1772
1772
  "(the email is sent by the host). sendPasswordReset(userId) mails the " +
1773
1773
  "standard self-serve reset link to that user's own registered address " +
1774
1774
  "and resolves { sent, email_masked } — never the token or the link, so " +
1775
1775
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
1776
- "coded USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL (an " +
1777
- "INTEGRATION service account); surface those as an explanation rather " +
1778
- "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.",
1779
1805
  returnShape: {
1780
1806
  users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
1781
1807
  loading: "boolean",
1782
- error: "DirectoryError | null // { code, message, retryable }",
1808
+ error: "DirectoryError | null // { code, reason, message, retryable }",
1783
1809
  refetch: "() => Promise<void>",
1784
1810
  invite:
1785
1811
  "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
@@ -1788,6 +1814,8 @@ const HOOKS = [
1788
1814
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
1789
1815
  sendPasswordReset:
1790
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)",
1791
1819
  },
1792
1820
  requiredContextSlice: ["directory.users"],
1793
1821
  scopes: ["users.read:*"],
@@ -5063,7 +5091,11 @@ const CONTRACT = deepFreeze({
5063
5091
  // 1.155.0: narrowing (sc-10566) — withdraws 1.144.0: `useUsers().invite`
5064
5092
  // takes no `send_invitation` and always emails, invite rows carry no
5065
5093
  // `sent_at`, and `not_sent` is no longer a status.
5066
- version: "1.155.0",
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",
5067
5099
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5068
5100
  hooks: HOOKS,
5069
5101
  primitives: PRIMITIVES,