@colixsystems/widget-sdk 0.93.0 → 0.95.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
@@ -47,8 +47,9 @@ The data layer lives in **four separate domain-client packages**, each instantia
47
47
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
48
48
  | **FILES** | `useAssetsByTag(tag, { type? })` | `{ 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. |
49
49
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
50
- | **DIRECTORY** | `useUsers(query?)` | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` | `directory.users.*` — `users.read:*` (edits also `users.write:*`; `remove()` also `users.delete:*`) |
50
+ | **DIRECTORY** | `useUsers(query?)` | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
51
51
  | **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
52
+ | **DIRECTORY** | `useInvites(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?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| all` (endpoint default `all`). |
52
53
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
53
54
  | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId? })` | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
54
55
  | **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). |
@@ -65,6 +66,18 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
65
66
 
66
67
  `v0.91.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**.
67
68
 
69
+ ### What's new in 0.95.0 (contract 1.67.0)
70
+
71
+ **An admin can mail a locked-out member a password-reset link — `useUsers().sendPasswordReset(userId)` (sc-5335).** An app user who forgot their password could only recover it themselves, from the app's own login screen. The admin they actually ask — the one already able to invite, deactivate and remove them — had no way to help, and the workaround in the field was to remove and re-invite the account, which discards its group memberships and history.
72
+
73
+ `sendPasswordReset(userId)` mails the **same** self-serve link `POST /auth/app/forgot-password` sends, to that user's **own registered address**, and resolves `{ sent, email_masked }`. It deliberately returns neither the token nor the link, so it is not an account-takeover primitive: an admin can start the recovery, only the user can finish it. `email_masked` (`ad**********@example.com`) is there because a widget caller reads the roster through the privacy-reduced directory projection, which omits email — the confirmation says where the mail went without becoming a new way to read addresses.
74
+
75
+ - **Scope**: `users.write:*`, alongside `invite` / `deactivate` / `reactivate` — the same grant, since mailing someone a link they must act on is strictly less powerful than deactivating them. The `scope-required-for-user-mutation` linter rule covers the new method, so calling it without the scope fails the lint.
76
+ - **Refusals are typed, not silent.** Rejects with a `DirectoryError` coded `USER_INACTIVE` (deactivated — reactivate first) or `NO_PASSWORD_CREDENTIAL` (an INTEGRATION service account authenticates by API key and holds no password). Branch on `code` and render `err.message`; don't offer the action on those rows at all.
77
+ - Issuing a link supersedes any outstanding one for that user, and the send is rate-limited per acting admin.
78
+ - The built-in **User Management** widget gains the row action and a new `onPasswordResetSent` event.
79
+ - **`CONTRACT.version` → `1.67.0`** (additive: one hook method). No existing signature changed, and both hosts get it from the same injected `@colixsystems/directory-client`.
80
+
68
81
  ### What's new in 0.93.0 (contract 1.66.0)
69
82
 
70
83
  **BREAKING: a widget no longer declares server-side actions.** `manifest.actions` is removed from the contract and **refused** by `validateManifest` — an author who declares it now fails the publish with a message naming the replacement, rather than shipping a widget that quietly carries no automation.
@@ -876,6 +889,42 @@ The matching manifest declares the scopes:
876
889
 
877
890
  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.
878
891
 
892
+ ## Listing pending invitations from a widget
893
+
894
+ `useInvites(query?)` lists the workspace's invites and resends / revokes them:
895
+
896
+ ```jsx
897
+ import { useInvites, View, Text, Pressable } from '@colixsystems/widget-sdk';
898
+
899
+ // Manifest: requestedScopes: ['users.read:*', 'users.write:*']
900
+ function PendingInvites() {
901
+ const { invites, loading, error, resend, revoke } = useInvites({ status: 'pending' });
902
+ if (loading) return null;
903
+ if (error) return <Text>{error.message}</Text>; // code === 'FORBIDDEN' when the capability is missing
904
+ return (
905
+ <View>
906
+ {invites.map((i) => (
907
+ <View key={i.id}>
908
+ <Text>{i.email} · {i.status}</Text>
909
+ <Pressable onPress={() => resend(i.id).catch(() => {})}><Text>Resend</Text></Pressable>
910
+ <Pressable onPress={() => revoke(i.id).catch(() => {})}><Text>Revoke</Text></Pressable>
911
+ </View>
912
+ ))}
913
+ </View>
914
+ );
915
+ }
916
+ ```
917
+
918
+ Rows are snake_case: `{ id, email, name, group_ids, status, expires_at, accepted_at,
919
+ revoked_at, created_at }`. Trust the server-computed `status` rather than comparing
920
+ `expires_at` against the device clock.
921
+
922
+ **The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
923
+ someone who is not a member yet, so the backend gates the entire invite surface —
924
+ listing included — on the `users.write` capability plus a signed `users.write:*`
925
+ scope. The `invites.read:*` / `invites.write:*` scope names mint but no route
926
+ enforces them, so declaring only those yields `FORBIDDEN`. Both mutations refetch
927
+ the list on success.
879
928
  ## Managing per-record permissions from a widget
880
929
 
881
930
  `useRecordPermissions(tableId, recordId)` is the in-app surface for sharing a single record with another user or group. The chat widget uses it to invite members into a channel — the channel record's per-record grants ARE the membership list (messages inherit those grants). The hook also covers project-workspace, document-sharing, and team-roster widgets that grant access record-by-record.
package/dist/contract.cjs CHANGED
@@ -957,7 +957,8 @@ const HOOKS = [
957
957
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
958
958
  // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
959
959
  // Reads need `users.read:*` scope; edit-style mutations (invite /
960
- // deactivate / reactivate) additionally need `users.write:*`, and the
960
+ // deactivate / reactivate / sendPasswordReset) additionally need
961
+ // `users.write:*`, and the
961
962
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
962
963
  // call accepts `{ email, name, groupIds? }` and returns the resulting
963
964
  // AppUserInvite row (the email is sent by the host). Mutating users from
@@ -969,15 +970,22 @@ const HOOKS = [
969
970
  signature: "useUsers(query?)",
970
971
  description:
971
972
  "AppUser administration via the injected directory-client at " +
972
- "ctx.directory.users.{list,get,invite,deactivate,reactivate}. Returns " +
973
- "{ users, loading, error, refetch, invite, deactivate, reactivate, remove }. " +
973
+ "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
974
+ "Returns { users, loading, error, refetch, invite, deactivate, " +
975
+ "reactivate, remove, sendPasswordReset }. " +
974
976
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
975
977
  "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
976
- "scope; edit-style mutations (invite/deactivate/reactivate) need " +
977
- "users.write:*, and the destructive remove() additionally needs " +
978
- "users.delete:* (SC-902). The `invite` call accepts " +
978
+ "scope; edit-style mutations (invite/deactivate/reactivate/" +
979
+ "sendPasswordReset) need users.write:*, and the destructive remove() " +
980
+ "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
979
981
  "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
980
- "(the email is sent by the host).",
982
+ "(the email is sent by the host). sendPasswordReset(userId) mails the " +
983
+ "standard self-serve reset link to that user's own registered address " +
984
+ "and resolves { sent, email_masked } — never the token or the link, so " +
985
+ "it cannot be used to sign in as them. It rejects with a DirectoryError " +
986
+ "coded USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL (an " +
987
+ "INTEGRATION service account); surface those as an explanation rather " +
988
+ "than a generic failure.",
981
989
  returnShape: {
982
990
  users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
983
991
  loading: "boolean",
@@ -988,6 +996,8 @@ const HOOKS = [
988
996
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
989
997
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
990
998
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
999
+ sendPasswordReset:
1000
+ "(userId) => Promise<{ sent, email_masked }> // rejects with DirectoryError",
991
1001
  },
992
1002
  requiredContextSlice: ["directory.users"],
993
1003
  scopes: ["users.read:*"],
@@ -1023,6 +1033,44 @@ const HOOKS = [
1023
1033
  requiredContextSlice: ["directory.groups"],
1024
1034
  scopes: ["groups.read:*"],
1025
1035
  },
1036
+ // sc-5097 — pending AppUser invite administration. Returns
1037
+ // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
1038
+ // `invites.read:*`; resend/revoke need `invites.write:*` plus the
1039
+ // caller's `users.write` SystemAcl capability.
1040
+ {
1041
+ name: "useInvites",
1042
+ signature: "useInvites(query?)",
1043
+ description:
1044
+ "Pending AppUser invite administration via the injected " +
1045
+ "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
1046
+ "Returns { invites, loading, error, refetch, resend, revoke }. list " +
1047
+ "returns the { data, meta } envelope verbatim — the hook unwraps " +
1048
+ "res.data; rows are snake_case (email, status, expires_at, …) and " +
1049
+ "status is the server-computed pending/accepted/revoked/expired " +
1050
+ "value. query is { status?, limit?, offset? } passed verbatim; the " +
1051
+ "endpoint defaults to status 'all', so pass { status: 'pending' } " +
1052
+ "for the usual outstanding-invite list. The WHOLE invite surface — " +
1053
+ "list included — is gated on users.write:* plus the users.write " +
1054
+ "SystemAcl capability, because a pending invite exposes the email of " +
1055
+ "someone who is not a member yet; a users.read-only caller cannot see " +
1056
+ "it. (invites.read:* / invites.write:* mint but gate nothing today.) " +
1057
+ "Without the capability every call rejects with DirectoryError code " +
1058
+ "FORBIDDEN — surface that, do not hide the tab. Both mutations " +
1059
+ "refetch on success.",
1060
+ returnShape: {
1061
+ invites:
1062
+ "Array<{ id, email, name, group_ids, status, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; unwrapped from { data, meta }",
1063
+ loading: "boolean",
1064
+ error: "DirectoryError | null // { code, message, retryable }",
1065
+ refetch: "() => Promise<void>",
1066
+ resend:
1067
+ "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
1068
+ revoke:
1069
+ "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
1070
+ },
1071
+ requiredContextSlice: ["directory.invites"],
1072
+ scopes: ["users.write:*"],
1073
+ },
1026
1074
  // REQ-BANKID-AUTH — link / unlink a BankID identity to the signed-in
1027
1075
  // app-user. Self-service + JWT-gated (no widget scope). Mirror of contract.js.
1028
1076
  {
@@ -1677,9 +1725,9 @@ const WIDGET_CONTEXT_SHAPE = {
1677
1725
  "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
1678
1726
  "invites: { list(), revoke(id), resend(id) }, " +
1679
1727
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
1680
- "users backs useDirectory() + useUsers(); groups backs useGroups(); 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:*).",
1728
+ "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:*).",
1681
1729
  required: true,
1682
- fields: { users: "object", groups: "object", bankid: "object" },
1730
+ fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
1683
1731
  },
1684
1732
  assets: {
1685
1733
  description:
@@ -2884,7 +2932,14 @@ const CONTRACT = deepFreeze({
2884
2932
  // REFUSED by the validator (sc-4879). Server-side automation ships as its own
2885
2933
  // marketplace deliverable (`@colixsystems/action-sdk`), which a workspace
2886
2934
  // installs and configures separately. A widget renders; it does not automate.
2887
- version: "1.66.0",
2935
+ // 1.67.0: additive (sc-5335) — `useUsers()` gains
2936
+ // `sendPasswordReset(userId)`, which mails the standard self-serve
2937
+ // password-reset link to that user's own registered address so an
2938
+ // app-admin can unblock a locked-out member from inside the app. It
2939
+ // resolves `{ sent, email_masked }` and never returns the token or the
2940
+ // link, so it is not an account-takeover primitive; it gates on the same
2941
+ // `users.write:*` scope as invite / deactivate.
2942
+ version: "1.67.0",
2888
2943
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2889
2944
  hooks: HOOKS,
2890
2945
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -957,7 +957,8 @@ const HOOKS = [
957
957
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
958
958
  // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
959
959
  // Reads need `users.read:*` scope; edit-style mutations (invite /
960
- // deactivate / reactivate) additionally need `users.write:*`, and the
960
+ // deactivate / reactivate / sendPasswordReset) additionally need
961
+ // `users.write:*`, and the
961
962
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
962
963
  // call accepts `{ email, name, groupIds? }` and returns the resulting
963
964
  // AppUserInvite row (the email is sent by the host). Mutating users from
@@ -969,15 +970,22 @@ const HOOKS = [
969
970
  signature: "useUsers(query?)",
970
971
  description:
971
972
  "AppUser administration via the injected directory-client at " +
972
- "ctx.directory.users.{list,get,invite,deactivate,reactivate}. Returns " +
973
- "{ users, loading, error, refetch, invite, deactivate, reactivate, remove }. " +
973
+ "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
974
+ "Returns { users, loading, error, refetch, invite, deactivate, " +
975
+ "reactivate, remove, sendPasswordReset }. " +
974
976
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
975
977
  "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
976
- "scope; edit-style mutations (invite/deactivate/reactivate) need " +
977
- "users.write:*, and the destructive remove() additionally needs " +
978
- "users.delete:* (SC-902). The `invite` call accepts " +
978
+ "scope; edit-style mutations (invite/deactivate/reactivate/" +
979
+ "sendPasswordReset) need users.write:*, and the destructive remove() " +
980
+ "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
979
981
  "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
980
- "(the email is sent by the host).",
982
+ "(the email is sent by the host). sendPasswordReset(userId) mails the " +
983
+ "standard self-serve reset link to that user's own registered address " +
984
+ "and resolves { sent, email_masked } — never the token or the link, so " +
985
+ "it cannot be used to sign in as them. It rejects with a DirectoryError " +
986
+ "coded USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL (an " +
987
+ "INTEGRATION service account); surface those as an explanation rather " +
988
+ "than a generic failure.",
981
989
  returnShape: {
982
990
  users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
983
991
  loading: "boolean",
@@ -988,6 +996,8 @@ const HOOKS = [
988
996
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
989
997
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
990
998
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
999
+ sendPasswordReset:
1000
+ "(userId) => Promise<{ sent, email_masked }> // rejects with DirectoryError",
991
1001
  },
992
1002
  requiredContextSlice: ["directory.users"],
993
1003
  scopes: ["users.read:*"],
@@ -1023,6 +1033,44 @@ const HOOKS = [
1023
1033
  requiredContextSlice: ["directory.groups"],
1024
1034
  scopes: ["groups.read:*"],
1025
1035
  },
1036
+ // sc-5097 — pending AppUser invite administration. Returns
1037
+ // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
1038
+ // `invites.read:*`; resend/revoke need `invites.write:*` plus the
1039
+ // caller's `users.write` SystemAcl capability.
1040
+ {
1041
+ name: "useInvites",
1042
+ signature: "useInvites(query?)",
1043
+ description:
1044
+ "Pending AppUser invite administration via the injected " +
1045
+ "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
1046
+ "Returns { invites, loading, error, refetch, resend, revoke }. list " +
1047
+ "returns the { data, meta } envelope verbatim — the hook unwraps " +
1048
+ "res.data; rows are snake_case (email, status, expires_at, …) and " +
1049
+ "status is the server-computed pending/accepted/revoked/expired " +
1050
+ "value. query is { status?, limit?, offset? } passed verbatim; the " +
1051
+ "endpoint defaults to status 'all', so pass { status: 'pending' } " +
1052
+ "for the usual outstanding-invite list. The WHOLE invite surface — " +
1053
+ "list included — is gated on users.write:* plus the users.write " +
1054
+ "SystemAcl capability, because a pending invite exposes the email of " +
1055
+ "someone who is not a member yet; a users.read-only caller cannot see " +
1056
+ "it. (invites.read:* / invites.write:* mint but gate nothing today.) " +
1057
+ "Without the capability every call rejects with DirectoryError code " +
1058
+ "FORBIDDEN — surface that, do not hide the tab. Both mutations " +
1059
+ "refetch on success.",
1060
+ returnShape: {
1061
+ invites:
1062
+ "Array<{ id, email, name, group_ids, status, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; unwrapped from { data, meta }",
1063
+ loading: "boolean",
1064
+ error: "DirectoryError | null // { code, message, retryable }",
1065
+ refetch: "() => Promise<void>",
1066
+ resend:
1067
+ "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
1068
+ revoke:
1069
+ "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
1070
+ },
1071
+ requiredContextSlice: ["directory.invites"],
1072
+ scopes: ["users.write:*"],
1073
+ },
1026
1074
  // REQ-BANKID-AUTH — link / unlink a BankID identity to the signed-in
1027
1075
  // app-user. Self-service + JWT-gated (no widget scope). Mirror of contract.cjs.
1028
1076
  {
@@ -1677,9 +1725,9 @@ const WIDGET_CONTEXT_SHAPE = {
1677
1725
  "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
1678
1726
  "invites: { list(), revoke(id), resend(id) }, " +
1679
1727
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
1680
- "users backs useDirectory() + useUsers(); groups backs useGroups(); 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:*).",
1728
+ "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:*).",
1681
1729
  required: true,
1682
- fields: { users: "object", groups: "object", bankid: "object" },
1730
+ fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
1683
1731
  },
1684
1732
  assets: {
1685
1733
  description:
@@ -2884,7 +2932,14 @@ const CONTRACT = deepFreeze({
2884
2932
  // REFUSED by the validator (sc-4879). Server-side automation ships as its own
2885
2933
  // marketplace deliverable (`@colixsystems/action-sdk`), which a workspace
2886
2934
  // installs and configures separately. A widget renders; it does not automate.
2887
- version: "1.66.0",
2935
+ // 1.67.0: additive (sc-5335) — `useUsers()` gains
2936
+ // `sendPasswordReset(userId)`, which mails the standard self-serve
2937
+ // password-reset link to that user's own registered address so an
2938
+ // app-admin can unblock a locked-out member from inside the app. It
2939
+ // resolves `{ sent, email_masked }` and never returns the token or the
2940
+ // link, so it is not an account-takeover primitive; it gates on the same
2941
+ // `users.write:*` scope as invite / deactivate.
2942
+ version: "1.67.0",
2888
2943
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2889
2944
  hooks: HOOKS,
2890
2945
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -3363,8 +3363,37 @@ export function useUsers(query) {
3363
3363
  throw toDirectoryError(err);
3364
3364
  }
3365
3365
  }, []);
3366
+ // sc-5335: mail the self-serve reset link to a user who has locked
3367
+ // themselves out. Resolves `{ sent, email_masked }`; the host never returns
3368
+ // the token, so this cannot be used to sign in as that user. Rejects with a
3369
+ // DirectoryError carrying `code` USER_INACTIVE or NO_PASSWORD_CREDENTIAL
3370
+ // when the target cannot receive one.
3371
+ const sendPasswordReset = useCallback(async (userId) => {
3372
+ if (typeof usersRef.current.sendPasswordReset !== "function") {
3373
+ throw toDirectoryError(
3374
+ new Error(
3375
+ "useUsers: this host's directory client predates sendPasswordReset",
3376
+ ),
3377
+ );
3378
+ }
3379
+ try {
3380
+ return await usersRef.current.sendPasswordReset(userId);
3381
+ } catch (err) {
3382
+ throw toDirectoryError(err);
3383
+ }
3384
+ }, []);
3366
3385
 
3367
- return { users, loading, error, refetch, invite, deactivate, reactivate, remove };
3386
+ return {
3387
+ users,
3388
+ loading,
3389
+ error,
3390
+ refetch,
3391
+ invite,
3392
+ deactivate,
3393
+ reactivate,
3394
+ remove,
3395
+ sendPasswordReset,
3396
+ };
3368
3397
  }
3369
3398
 
3370
3399
  /**
@@ -3467,6 +3496,118 @@ export function useGroups(query) {
3467
3496
  return { groups, loading, error, refetch, create, remove, addMember, removeMember };
3468
3497
  }
3469
3498
 
3499
+ /**
3500
+ * sc-5097 — pending AppUser invite administration.
3501
+ *
3502
+ * Returns `{ invites, loading, error, refetch, resend, revoke }`. Reads the
3503
+ * injected `@colixsystems/directory-client` at
3504
+ * `ctx.directory.invites.{list, resend, revoke}` — `list` resolves to the
3505
+ * `{ data, meta }` envelope VERBATIM, so we unwrap `res.data` (default `[]`).
3506
+ *
3507
+ * Rows are snake_case exactly as the backend projects them: `{ id, tenant_id,
3508
+ * email, name, group_ids, status, expires_at, accepted_at, revoked_at,
3509
+ * created_at, invited_by_studio_user_id }`. `status` is the server-computed
3510
+ * lifecycle value (`"pending" | "accepted" | "revoked" | "expired"`) — prefer
3511
+ * it over re-deriving expiry on the client, whose clock may disagree.
3512
+ *
3513
+ * `query` is an optional `{ status?, limit?, offset? }` passed through
3514
+ * verbatim; `status` accepts `"pending" | "accepted" | "revoked" | "expired" |
3515
+ * "all"` and the endpoint defaults to `"all"`, so pass `{ status: "pending" }`
3516
+ * for the usual "who hasn't accepted yet" list.
3517
+ *
3518
+ * The WHOLE invite surface — list included — is gated on the `users.write`
3519
+ * capability plus a signed `users.write:*` scope: a pending invite exposes
3520
+ * the email of someone who is not a member yet, so a `users.read`-only
3521
+ * caller cannot see it. The `invites.read:*` / `invites.write:*` scope names
3522
+ * mint but gate nothing today, so declaring them alone yields FORBIDDEN.
3523
+ * Every call rejects with a `DirectoryError` carrying `code: "FORBIDDEN"`
3524
+ * when the capability is missing — surface it rather than hiding the tab.
3525
+ */
3526
+ export function useInvites(query) {
3527
+ const ctx = useWidgetContextOrThrow("useInvites");
3528
+ if (
3529
+ !ctx.directory ||
3530
+ !ctx.directory.invites ||
3531
+ typeof ctx.directory.invites.list !== "function"
3532
+ ) {
3533
+ throw new Error(
3534
+ "useInvites: host did not inject a directory client (ctx.directory.invites)",
3535
+ );
3536
+ }
3537
+ const [invites, setInvites] = useState([]);
3538
+ const [loading, setLoading] = useState(true);
3539
+ const [error, setError] = useState(null);
3540
+
3541
+ const queryRef = useRef(query);
3542
+ const invitesRef = useRef(ctx.directory.invites);
3543
+ queryRef.current = query;
3544
+ invitesRef.current = ctx.directory.invites;
3545
+
3546
+ const runRef = useRef(0);
3547
+
3548
+ const doFetch = useCallback(async () => {
3549
+ const myRun = ++runRef.current;
3550
+ setLoading(true);
3551
+ setError(null);
3552
+ try {
3553
+ const res = await invitesRef.current.list(queryRef.current);
3554
+ // Directory invites.list returns the { data, meta } envelope verbatim.
3555
+ const rows = res && Array.isArray(res.data) ? res.data : [];
3556
+ if (runRef.current !== myRun) return;
3557
+ setInvites(rows);
3558
+ setLoading(false);
3559
+ } catch (err) {
3560
+ if (runRef.current !== myRun) return;
3561
+ setError(toDirectoryError(err));
3562
+ setLoading(false);
3563
+ }
3564
+ }, []);
3565
+
3566
+ const queryKey = (() => {
3567
+ try {
3568
+ return JSON.stringify(query);
3569
+ } catch (_e) {
3570
+ return null;
3571
+ }
3572
+ })();
3573
+ useEffect(() => {
3574
+ doFetch();
3575
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3576
+ }, [queryKey]);
3577
+
3578
+ const refetch = useCallback(async () => {
3579
+ await doFetch();
3580
+ }, [doFetch]);
3581
+
3582
+ // Both mutations re-list on success: revoking drops the row and resending
3583
+ // moves `expires_at`, so a stale list would show the operator the old state.
3584
+ const resend = useCallback(
3585
+ async (inviteId) => {
3586
+ try {
3587
+ const row = await invitesRef.current.resend(inviteId);
3588
+ await doFetch();
3589
+ return row;
3590
+ } catch (err) {
3591
+ throw toDirectoryError(err);
3592
+ }
3593
+ },
3594
+ [doFetch],
3595
+ );
3596
+ const revoke = useCallback(
3597
+ async (inviteId) => {
3598
+ try {
3599
+ await invitesRef.current.revoke(inviteId);
3600
+ await doFetch();
3601
+ } catch (err) {
3602
+ throw toDirectoryError(err);
3603
+ }
3604
+ },
3605
+ [doFetch],
3606
+ );
3607
+
3608
+ return { invites, loading, error, refetch, resend, revoke };
3609
+ }
3610
+
3470
3611
  /**
3471
3612
  * REQ-BANKID-AUTH — link / unlink a BankID identity to the signed-in app-user,
3472
3613
  * and read whether BankID is available + already linked. Returns
package/dist/index.d.ts CHANGED
@@ -1523,12 +1523,26 @@ export interface InviteArgs {
1523
1523
  group_ids?: string[];
1524
1524
  }
1525
1525
 
1526
+ export type AppUserInviteStatus =
1527
+ | "pending"
1528
+ | "accepted"
1529
+ | "revoked"
1530
+ | "expired";
1531
+
1526
1532
  export interface AppUserInviteRow {
1527
1533
  id: string;
1528
1534
  email: string;
1529
- status: string;
1535
+ /** Server-computed lifecycle value — prefer it over re-deriving expiry. */
1536
+ status: AppUserInviteStatus;
1537
+ name?: string;
1538
+ group_ids?: string[];
1530
1539
  invited_at?: string;
1531
1540
  expires_at?: string;
1541
+ accepted_at?: string | null;
1542
+ revoked_at?: string | null;
1543
+ created_at?: string;
1544
+ tenant_id?: string;
1545
+ invited_by_studio_user_id?: string | null;
1532
1546
  }
1533
1547
 
1534
1548
  export interface UsersApi {
@@ -1585,6 +1599,40 @@ export interface GroupsApi {
1585
1599
  */
1586
1600
  export function useGroups(query?: GroupsQuery): GroupsApi;
1587
1601
 
1602
+ // --------------------------------------------------------------- useInvites
1603
+ //
1604
+ // sc-5097 — pending AppUser invite administration.
1605
+
1606
+ export interface InvitesQuery {
1607
+ /** Defaults to "all" server-side; pass "pending" for outstanding invites. */
1608
+ status?: AppUserInviteStatus | "all";
1609
+ limit?: number;
1610
+ offset?: number;
1611
+ }
1612
+
1613
+ export interface InvitesApi {
1614
+ invites: AppUserInviteRow[];
1615
+ loading: boolean;
1616
+ error: DirectoryError | null;
1617
+ refetch(): Promise<void>;
1618
+ /** Re-send the invitation email. Refetches on success. */
1619
+ resend(inviteId: string): Promise<AppUserInviteRow>;
1620
+ /** Cancel a pending invitation. Refetches on success. */
1621
+ revoke(inviteId: string): Promise<void>;
1622
+ }
1623
+
1624
+ /**
1625
+ * Pending AppUser invite administration through the injected directory-client
1626
+ * at `ctx.directory.invites.{list,resend,revoke}`.
1627
+ *
1628
+ * The WHOLE surface — listing included — requires the `users.write:*` scope
1629
+ * AND the SystemAcl `users.write` capability, because a pending invite exposes
1630
+ * the email of someone who is not a member yet. The `invites.read:*` /
1631
+ * `invites.write:*` scope names mint but no route enforces them, so declaring
1632
+ * only those yields a `FORBIDDEN` DirectoryError.
1633
+ */
1634
+ export function useInvites(query?: InvitesQuery): InvitesApi;
1635
+
1588
1636
  // ----------------------------------------------------- useBankIdLink
1589
1637
  //
1590
1638
  // REQ-BANKID-AUTH — link / unlink a BankID identity to the signed-in app-user.
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ export {
31
31
  useDirectory,
32
32
  useUsers,
33
33
  useGroups,
34
+ useInvites,
34
35
  useBankIdLink,
35
36
  useIdentification,
36
37
  IdentificationError,
@@ -31,6 +31,7 @@ export {
31
31
  useDirectory,
32
32
  useUsers,
33
33
  useGroups,
34
+ useInvites,
34
35
  useBankIdLink,
35
36
  useIdentification,
36
37
  IdentificationError,
package/dist/linter.cjs CHANGED
@@ -428,7 +428,14 @@ function _translationApiRules(source) {
428
428
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation. See
429
429
  // linter.js for the rationale comment. The two files must stay in
430
430
  // lockstep (the contract test asserts behaviour-equivalence).
431
- const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate"];
431
+ const USER_MUTATION_METHODS = [
432
+ "invite",
433
+ "deactivate",
434
+ "reactivate",
435
+ // sc-5335 — mailing a reset link is edit-style, so it rides `users.write`
436
+ // alongside deactivate rather than the destructive `users.delete`.
437
+ "sendPasswordReset",
438
+ ];
432
439
  // SC-902 — destructive user removal is gated on the dedicated
433
440
  // `users.delete` capability, NOT `users.write`. A widget that calls
434
441
  // useUsers().remove() must declare `users.delete:*` so the static contract
@@ -447,6 +454,7 @@ function _scopeRules(source, manifest) {
447
454
  );
448
455
  const usesUsersHook = /\buseUsers\s*\(/.test(source);
449
456
  const usesGroupsHook = /\buseGroups\s*\(/.test(source);
457
+ const usesInvitesHook = /\buseInvites\s*\(/.test(source);
450
458
 
451
459
  if (usesUsersHook) {
452
460
  const reads = declared.has("users.read:*") || declared.has("users.read");
@@ -473,6 +481,22 @@ function _scopeRules(source, manifest) {
473
481
  }
474
482
  }
475
483
 
484
+ // sc-5097 — the whole invite surface (list, resend, revoke) is gated on
485
+ // the "users.write" capability, and the scope gate accepts only a signed
486
+ // scope matching that key. `invites.*` scopes mint but gate nothing.
487
+ if (usesInvitesHook) {
488
+ const writes =
489
+ declared.has("users.write:*") || declared.has("users.write");
490
+ if (!writes) {
491
+ findings.push({
492
+ rule: "scope-required-for-useInvites",
493
+ label:
494
+ "useInvites() requires `users.write:*` in manifest.requestedScopes",
495
+ line: 0,
496
+ snippet: "",
497
+ });
498
+ }
499
+ }
476
500
  const lines = source.split(/\r?\n/);
477
501
  let firedUsersWrite = false;
478
502
  let firedUsersDelete = false;
package/dist/linter.js CHANGED
@@ -485,7 +485,8 @@ function _translationApiRules(source) {
485
485
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation.
486
486
  //
487
487
  // A widget that calls `useUsers().invite()` / `.deactivate()` /
488
- // `.reactivate()` / `.remove()` MUST declare `users.write:*` in its
488
+ // `.reactivate()` / `.sendPasswordReset()` / `.remove()` MUST declare
489
+ // `users.write:*` in its
489
490
  // manifest's `requestedScopes`; similarly `useGroups()` mutation methods
490
491
  // require `groups.write:*`. The rule is enforced statically so a manifest
491
492
  // that drifts from the source (e.g. an author forgot to add the scope
@@ -498,7 +499,14 @@ function _translationApiRules(source) {
498
499
  // AST-free; an author whose code happened to spell `.invite(` for an
499
500
  // unrelated reason can opt out with a `// @appstudio-skip-scope-check`
500
501
  // trailing comment on the offending line.
501
- const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate"];
502
+ const USER_MUTATION_METHODS = [
503
+ "invite",
504
+ "deactivate",
505
+ "reactivate",
506
+ // sc-5335 — mailing a reset link is edit-style, so it rides `users.write`
507
+ // alongside deactivate rather than the destructive `users.delete`.
508
+ "sendPasswordReset",
509
+ ];
502
510
  // SC-902 — destructive user removal is gated on the dedicated
503
511
  // `users.delete` capability, NOT `users.write`. A widget that calls
504
512
  // useUsers().remove() must declare `users.delete:*` so the static contract
@@ -522,6 +530,7 @@ function _scopeRules(source, manifest) {
522
530
  );
523
531
  const usesUsersHook = /\buseUsers\s*\(/.test(source);
524
532
  const usesGroupsHook = /\buseGroups\s*\(/.test(source);
533
+ const usesInvitesHook = /\buseInvites\s*\(/.test(source);
525
534
 
526
535
  if (usesUsersHook) {
527
536
  const reads = declared.has("users.read:*") || declared.has("users.read");
@@ -547,6 +556,22 @@ function _scopeRules(source, manifest) {
547
556
  }
548
557
  }
549
558
 
559
+ // sc-5097 — the whole invite surface (list, resend, revoke) is gated on
560
+ // the "users.write" capability, and the scope gate accepts only a signed
561
+ // scope matching that key. `invites.*` scopes mint but gate nothing.
562
+ if (usesInvitesHook) {
563
+ const writes =
564
+ declared.has("users.write:*") || declared.has("users.write");
565
+ if (!writes) {
566
+ findings.push({
567
+ rule: "scope-required-for-useInvites",
568
+ label:
569
+ "useInvites() requires `users.write:*` in manifest.requestedScopes",
570
+ line: 0,
571
+ snippet: "",
572
+ });
573
+ }
574
+ }
550
575
  const lines = source.split(/\r?\n/);
551
576
  let firedUsersWrite = false;
552
577
  let firedUsersDelete = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.93.0",
3
+ "version": "0.95.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"