@colixsystems/widget-sdk 0.265.0 → 0.266.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
@@ -69,7 +69,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
69
69
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
70
70
  | **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:*`) |
71
71
  | **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
72
- | **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. |
72
+ | **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. |
73
73
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
74
74
  | **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. |
75
75
  | **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). |
@@ -86,7 +86,28 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
86
86
 
87
87
  ## Status
88
88
 
89
- `v0.265.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**.
89
+ `v0.266.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**.
90
+
91
+ ### What's new in 0.266.0 (contract 1.216.0)
92
+
93
+ **A widget can prepare an invitation now and send it later, again.** 0.194.0 withdrew this; it is back with the same shape. Without it `useUsers().invite()` emails the invitation the moment it is 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`:
94
+
95
+ ```js
96
+ const { invite } = useUsers();
97
+ const { invites, resend } = useInvites({ status: "not_sent" });
98
+
99
+ const prepared = await invite({ email: "bo@example.com", name: "Bo", send_invitation: false });
100
+ // prepared.status === "not_sent", prepared.sent_at === null — nobody was emailed.
101
+
102
+ await resend(prepared.id); // emails it; the row is now "pending" with a fresh 7-day expiry
103
+ ```
104
+
105
+ - **`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`.
106
+ - **`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.
107
+ - **`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.
108
+ - **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.
109
+
110
+ 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.
90
111
 
91
112
  ### What's new in 0.265.0 (contract 1.215.0)
92
113
 
@@ -1311,7 +1332,7 @@ An installed widget is not rebuilt or republished for this. The web Player hands
1311
1332
 
1312
1333
  ### What's new in 0.183.0 (contract 1.144.0)
1313
1334
 
1314
- Send-later invitations (`send_invitation`, the `not_sent` status, `sent_at`). Withdrawn in 0.194.0.
1335
+ Send-later invitations (`send_invitation`, the `not_sent` status, `sent_at`). Withdrawn in 0.194.0, reinstated in 0.266.0.
1315
1336
 
1316
1337
  ### What's new in 0.182.0 (contract 1.143.0)
1317
1338
 
@@ -3349,11 +3370,13 @@ The matching manifest declares the scopes:
3349
3370
  }
3350
3371
  ```
3351
3372
 
3373
+ `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)`.
3374
+
3352
3375
  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.
3353
3376
 
3354
3377
  ## Listing pending invitations from a widget
3355
3378
 
3356
- `useInvites(query?)` lists the workspace's invites and resends / revokes them:
3379
+ `useInvites(query?)` lists the workspace's invites and sends / revokes them:
3357
3380
 
3358
3381
  ```jsx
3359
3382
  import { useInvites, View, Text, Pressable } from '@colixsystems/widget-sdk';
@@ -3368,7 +3391,9 @@ function PendingInvites() {
3368
3391
  {invites.map((i) => (
3369
3392
  <View key={i.id}>
3370
3393
  <Text>{i.email} · {i.status}</Text>
3371
- <Pressable onPress={() => resend(i.id).catch(() => {})}><Text>Resend</Text></Pressable>
3394
+ <Pressable onPress={() => resend(i.id).catch(() => {})}>
3395
+ <Text>{i.status === 'not_sent' ? 'Send invitation' : 'Resend'}</Text>
3396
+ </Pressable>
3372
3397
  <Pressable onPress={() => revoke(i.id).catch(() => {})}><Text>Revoke</Text></Pressable>
3373
3398
  </View>
3374
3399
  ))}
@@ -3377,9 +3402,16 @@ function PendingInvites() {
3377
3402
  }
3378
3403
  ```
3379
3404
 
3380
- Rows are snake_case: `{ id, app_user_id, email, name, group_ids, status, expires_at,
3381
- accepted_at, revoked_at, created_at }`. Trust the server-computed `status` rather than
3382
- comparing `expires_at` against the device clock.
3405
+ Rows are snake_case: `{ id, app_user_id, email, name, group_ids, status, sent_at,
3406
+ expires_at, accepted_at, revoked_at, created_at }`. `status` is one of `pending`,
3407
+ `accepted`, `revoked`, `expired` or `not_sent`; trust the server-computed value rather
3408
+ than comparing `expires_at` against the device clock.
3409
+
3410
+ A `not_sent` row is an invitation created with `send_invitation: false`: nobody has
3411
+ been emailed and `sent_at` is `null`. `resend(inviteId)` is the one call that sends
3412
+ an invitation, a first send and a repeat send alike — it emails the invitee, stamps
3413
+ `sent_at`, restarts the 7-day expiry and resolves the row as `pending`. Pass
3414
+ `{ status: 'not_sent' }` to list only the prepared ones.
3383
3415
 
3384
3416
  `query` also takes `q` (a case-insensitive substring match on the invite's name or
3385
3417
  email) and `app_user_id`. Each row's `app_user_id` is the id the invitee's user account
@@ -3387,7 +3419,7 @@ will have once they accept. A `USER` column may hold that id before the person h
3387
3419
  joined — it then reads back as `{ id, name, invited: true }`, with no `email` — and
3388
3420
  `useInvites({ app_user_id: value.id, status: 'open', limit: 1 })` returns the invite
3389
3421
  behind it. `status: 'open'` is every invite that is neither accepted nor revoked
3390
- (`pending` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
3422
+ (`pending`, `not_sent` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
3391
3423
 
3392
3424
  **The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
3393
3425
  someone who is not a member yet, so the backend gates the entire invite surface —
package/dist/contract.cjs CHANGED
@@ -2361,8 +2361,9 @@ const HOOKS = [
2361
2361
  // deactivate / reactivate / sendPasswordReset / update) additionally need
2362
2362
  // `users.write:*`, and the
2363
2363
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
2364
- // call accepts `{ email, name, groupIds? }` and returns the resulting
2365
- // AppUserInvite row (the email is sent by the host). Mutating users from
2364
+ // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
2365
+ // the resulting AppUserInvite row (the host sends the email, unless
2366
+ // `send_invitation` is false). Mutating users from
2366
2367
  // a widget requires the corresponding SystemAcl capability grant in the
2367
2368
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
2368
2369
  // that only call read methods need only `users.read:*`.
@@ -2382,8 +2383,13 @@ const HOOKS = [
2382
2383
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
2383
2384
  "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
2384
2385
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
2385
- "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
2386
- "(the email is sent by the host). sendPasswordReset(userId) mails the " +
2386
+ "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
2387
+ "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
2388
+ "The host emails the invitation at once unless send_invitation is " +
2389
+ "false: then nothing is emailed, the row comes back with sent_at null " +
2390
+ "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
2391
+ "later. A send_invitation that is not a boolean rejects with " +
2392
+ "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
2387
2393
  "standard self-serve reset link to that user's own registered address " +
2388
2394
  "and resolves { sent, email_masked } — never the token or the link, so " +
2389
2395
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
@@ -2422,7 +2428,7 @@ const HOOKS = [
2422
2428
  error: "DirectoryError | null // { code, reason, message, retryable }",
2423
2429
  refetch: "() => Promise<void>",
2424
2430
  invite:
2425
- "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
2431
+ "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
2426
2432
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2427
2433
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2428
2434
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
@@ -2640,7 +2646,8 @@ const HOOKS = [
2640
2646
  // sc-5097 — pending AppUser invite administration. Returns
2641
2647
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
2642
2648
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
2643
- // caller's `users.write` SystemAcl capability.
2649
+ // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
2650
+ // the first send of a `not_sent` invite — there is no separate `send`.
2644
2651
  {
2645
2652
  name: "useInvites",
2646
2653
  signature: "useInvites(query)",
@@ -2650,13 +2657,23 @@ const HOOKS = [
2650
2657
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
2651
2658
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
2652
2659
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
2653
- "res.data; rows are snake_case (email, status, expires_at, …) and " +
2654
- "status is the server-computed pending/accepted/revoked/expired " +
2655
- "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2660
+ "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
2661
+ "and status is the server-computed pending/accepted/revoked/expired/" +
2662
+ "not_sent value. not_sent is an invitation created with " +
2663
+ "send_invitation: false that has not been emailed yet: its sent_at is " +
2664
+ "null, it cannot be accepted until it is sent, and its expires_at " +
2665
+ "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
2666
+ "ONE call that sends an invitation — the first send of a not_sent one " +
2667
+ "and a repeat send of a sent one alike: it emails the invitee, stamps " +
2668
+ "sent_at, restarts the 7-day expiry and resolves the row with status " +
2669
+ "'pending'. There is no separate send method; label the button " +
2670
+ "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
2671
+ "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2656
2672
  "verbatim; the endpoint defaults to status 'all', so pass " +
2657
- "{ status: 'pending' } for the usual outstanding-invite list; " +
2658
- "status 'open' is every invite neither accepted nor revoked (pending " +
2659
- "or expired). q is a " +
2673
+ "{ status: 'pending' } for the usual outstanding-invite list and " +
2674
+ "{ status: 'not_sent' } for the prepared ones; " +
2675
+ "status 'open' is every invite neither accepted nor revoked (pending, " +
2676
+ "not_sent or expired). q is a " +
2660
2677
  "case-insensitive substring match on the invite's name or email. " +
2661
2678
  "Each row's app_user_id is the id the invitee's user account will " +
2662
2679
  "have once they accept; a USER column may already hold it, and " +
@@ -2673,12 +2690,12 @@ const HOOKS = [
2673
2690
  "refetch on success.",
2674
2691
  returnShape: {
2675
2692
  invites:
2676
- "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 }",
2693
+ "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 }",
2677
2694
  loading: "boolean",
2678
2695
  error: "DirectoryError | null // { code, message, retryable }",
2679
2696
  refetch: "() => Promise<void>",
2680
2697
  resend:
2681
- "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
2698
+ "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
2682
2699
  revoke:
2683
2700
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
2684
2701
  },
@@ -4172,7 +4189,7 @@ const WIDGET_CONTEXT_SHAPE = {
4172
4189
  "invites: { list(), revoke(id), resend(id) }, " +
4173
4190
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }, " +
4174
4191
  "presence: { online(tableId, recordIds) -> { data: [{ record_id, online_user_ids, online_count }] }, me() -> { show_online }, setShowOnline(bool) -> { show_online } } }. " +
4175
- "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); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). 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:*).",
4192
+ "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); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). 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:*).",
4176
4193
  required: true,
4177
4194
  fields: { users: "object", groups: "object", invites: "object", bankid: "object", presence: "object" },
4178
4195
  },
@@ -6445,7 +6462,11 @@ const CONTRACT = deepFreeze({
6445
6462
  // random id per row and may be `defaultLocked`.
6446
6463
  // 1.215.0: additive (sc-13938) — `usePdfExport` `openPdf` / `exportToPdf`
6447
6464
  // take `appendFileIds`: stored PDFs whose pages follow the rendered HTML.
6448
- version: "1.215.0",
6465
+ // 1.216.0: additive (sc-14096) — reinstates 1.144.0, withdrawn in 1.155.0:
6466
+ // `useUsers().invite` takes `send_invitation` (false stores the invite
6467
+ // unsent), invite rows carry `sent_at`, `not_sent` is a status, and
6468
+ // `useInvites().resend` performs the first send.
6469
+ version: "1.216.0",
6449
6470
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6450
6471
  hooks: HOOKS,
6451
6472
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -2358,8 +2358,9 @@ const HOOKS = [
2358
2358
  // deactivate / reactivate / sendPasswordReset / update) additionally need
2359
2359
  // `users.write:*`, and the
2360
2360
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
2361
- // call accepts `{ email, name, groupIds? }` and returns the resulting
2362
- // AppUserInvite row (the email is sent by the host). Mutating users from
2361
+ // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
2362
+ // the resulting AppUserInvite row (the host sends the email, unless
2363
+ // `send_invitation` is false). Mutating users from
2363
2364
  // a widget requires the corresponding SystemAcl capability grant in the
2364
2365
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
2365
2366
  // that only call read methods need only `users.read:*`.
@@ -2379,8 +2380,13 @@ const HOOKS = [
2379
2380
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
2380
2381
  "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
2381
2382
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
2382
- "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
2383
- "(the email is sent by the host). sendPasswordReset(userId) mails the " +
2383
+ "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
2384
+ "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
2385
+ "The host emails the invitation at once unless send_invitation is " +
2386
+ "false: then nothing is emailed, the row comes back with sent_at null " +
2387
+ "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
2388
+ "later. A send_invitation that is not a boolean rejects with " +
2389
+ "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
2384
2390
  "standard self-serve reset link to that user's own registered address " +
2385
2391
  "and resolves { sent, email_masked } — never the token or the link, so " +
2386
2392
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
@@ -2419,7 +2425,7 @@ const HOOKS = [
2419
2425
  error: "DirectoryError | null // { code, reason, message, retryable }",
2420
2426
  refetch: "() => Promise<void>",
2421
2427
  invite:
2422
- "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
2428
+ "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
2423
2429
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2424
2430
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2425
2431
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
@@ -2637,7 +2643,8 @@ const HOOKS = [
2637
2643
  // sc-5097 — pending AppUser invite administration. Returns
2638
2644
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
2639
2645
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
2640
- // caller's `users.write` SystemAcl capability.
2646
+ // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
2647
+ // the first send of a `not_sent` invite — there is no separate `send`.
2641
2648
  {
2642
2649
  name: "useInvites",
2643
2650
  signature: "useInvites(query)",
@@ -2647,13 +2654,23 @@ const HOOKS = [
2647
2654
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
2648
2655
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
2649
2656
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
2650
- "res.data; rows are snake_case (email, status, expires_at, …) and " +
2651
- "status is the server-computed pending/accepted/revoked/expired " +
2652
- "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2657
+ "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
2658
+ "and status is the server-computed pending/accepted/revoked/expired/" +
2659
+ "not_sent value. not_sent is an invitation created with " +
2660
+ "send_invitation: false that has not been emailed yet: its sent_at is " +
2661
+ "null, it cannot be accepted until it is sent, and its expires_at " +
2662
+ "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
2663
+ "ONE call that sends an invitation — the first send of a not_sent one " +
2664
+ "and a repeat send of a sent one alike: it emails the invitee, stamps " +
2665
+ "sent_at, restarts the 7-day expiry and resolves the row with status " +
2666
+ "'pending'. There is no separate send method; label the button " +
2667
+ "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
2668
+ "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2653
2669
  "verbatim; the endpoint defaults to status 'all', so pass " +
2654
- "{ status: 'pending' } for the usual outstanding-invite list; " +
2655
- "status 'open' is every invite neither accepted nor revoked (pending " +
2656
- "or expired). q is a " +
2670
+ "{ status: 'pending' } for the usual outstanding-invite list and " +
2671
+ "{ status: 'not_sent' } for the prepared ones; " +
2672
+ "status 'open' is every invite neither accepted nor revoked (pending, " +
2673
+ "not_sent or expired). q is a " +
2657
2674
  "case-insensitive substring match on the invite's name or email. " +
2658
2675
  "Each row's app_user_id is the id the invitee's user account will " +
2659
2676
  "have once they accept; a USER column may already hold it, and " +
@@ -2670,12 +2687,12 @@ const HOOKS = [
2670
2687
  "refetch on success.",
2671
2688
  returnShape: {
2672
2689
  invites:
2673
- "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 }",
2690
+ "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 }",
2674
2691
  loading: "boolean",
2675
2692
  error: "DirectoryError | null // { code, message, retryable }",
2676
2693
  refetch: "() => Promise<void>",
2677
2694
  resend:
2678
- "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
2695
+ "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
2679
2696
  revoke:
2680
2697
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
2681
2698
  },
@@ -4169,7 +4186,7 @@ const WIDGET_CONTEXT_SHAPE = {
4169
4186
  "invites: { list(), revoke(id), resend(id) }, " +
4170
4187
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }, " +
4171
4188
  "presence: { online(tableId, recordIds) -> { data: [{ record_id, online_user_ids, online_count }] }, me() -> { show_online }, setShowOnline(bool) -> { show_online } } }. " +
4172
- "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); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). 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:*).",
4189
+ "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); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). 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:*).",
4173
4190
  required: true,
4174
4191
  fields: { users: "object", groups: "object", invites: "object", bankid: "object", presence: "object" },
4175
4192
  },
@@ -6442,7 +6459,11 @@ const CONTRACT = deepFreeze({
6442
6459
  // random id per row and may be `defaultLocked`.
6443
6460
  // 1.215.0: additive (sc-13938) — `usePdfExport` `openPdf` / `exportToPdf`
6444
6461
  // take `appendFileIds`: stored PDFs whose pages follow the rendered HTML.
6445
- version: "1.215.0",
6462
+ // 1.216.0: additive (sc-14096) — reinstates 1.144.0, withdrawn in 1.155.0:
6463
+ // `useUsers().invite` takes `send_invitation` (false stores the invite
6464
+ // unsent), invite rows carry `sent_at`, `not_sent` is a status, and
6465
+ // `useInvites().resend` performs the first send.
6466
+ version: "1.216.0",
6446
6467
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6447
6468
  hooks: HOOKS,
6448
6469
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -5977,7 +5977,7 @@ export function useDirectory(query) {
5977
5977
  * invite, deactivate, reactivate, sendPasswordReset, update}` — `list` resolves to the `{ data, meta }`
5978
5978
  * envelope VERBATIM, so we unwrap `res.data` (default `[]`). User rows are
5979
5979
  * snake_case (`id`, `name`, `email`, `role`, `is_active`, …) and bodies
5980
- * (e.g. `{ email, name, group_ids? }`) pass through verbatim. `query` is an
5980
+ * (e.g. `{ email, name, group_ids?, send_invitation? }`) pass through verbatim. `query` is an
5981
5981
  * optional `{ q?, role?, is_active?, group_id?, limit?, offset? }`; `group_id`
5982
5982
  * (the `id` of a `useGroups()` row) narrows the list to that group's members.
5983
5983
  * The list refetches whenever `JSON.stringify(query)` changes; the imperative methods
@@ -6228,15 +6228,20 @@ export function useGroups(query) {
6228
6228
  * `{ data, meta }` envelope VERBATIM, so we unwrap `res.data` (default `[]`).
6229
6229
  *
6230
6230
  * Rows are snake_case exactly as the backend projects them: `{ id, tenant_id,
6231
- * email, name, group_ids, status, expires_at, accepted_at, revoked_at,
6232
- * created_at, invited_by_studio_user_id }`. `status` is the server-computed
6233
- * lifecycle value (`"pending" | "accepted" | "revoked" | "expired"`) — prefer
6234
- * it over re-deriving expiry on the client, whose clock may disagree.
6231
+ * email, name, group_ids, status, sent_at, expires_at, accepted_at,
6232
+ * revoked_at, created_at, invited_by_studio_user_id }`. `status` is the
6233
+ * server-computed lifecycle value (`"pending" | "accepted" | "revoked" |
6234
+ * "expired" | "not_sent"`) — prefer it over re-deriving expiry on the client,
6235
+ * whose clock may disagree.
6236
+ *
6237
+ * `not_sent` is an invite created with `send_invitation: false`: `sent_at` is
6238
+ * null and nothing was emailed. `resend(inviteId)` is the one call that sends
6239
+ * — a first send and a repeat send alike — and resolves the row as `pending`.
6235
6240
  *
6236
6241
  * `query` is an optional `{ status?, limit?, offset? }` passed through
6237
6242
  * verbatim; `status` accepts `"pending" | "accepted" | "revoked" | "expired" |
6238
- * "all"` and the endpoint defaults to `"all"`, so pass `{ status: "pending" }`
6239
- * for the usual "who hasn't accepted yet" list.
6243
+ * "not_sent" | "all"` and the endpoint defaults to `"all"`, so pass
6244
+ * `{ status: "pending" }` for the usual "who hasn't accepted yet" list.
6240
6245
  *
6241
6246
  * The WHOLE invite surface — list included — is gated on the `users.write`
6242
6247
  * capability plus a signed `users.write:*` scope: a pending invite exposes
@@ -6303,7 +6308,8 @@ export function useInvites(query) {
6303
6308
  }, [doFetch]);
6304
6309
 
6305
6310
  // Both mutations re-list on success: revoking drops the row and resending
6306
- // moves `expires_at`, so a stale list would show the operator the old state.
6311
+ // moves `expires_at` (and a first send's status), so a stale list would
6312
+ // show the operator the old state.
6307
6313
  const resend = useCallback(
6308
6314
  async (inviteId) => {
6309
6315
  try {
package/dist/index.d.ts CHANGED
@@ -3289,13 +3289,25 @@ export interface InviteArgs {
3289
3289
  email: string;
3290
3290
  name?: string;
3291
3291
  group_ids?: string[];
3292
+ /**
3293
+ * Defaults to `true`: the invitation is emailed at once. `false` stores it
3294
+ * without emailing anyone (`status: "not_sent"`); send it later with
3295
+ * `useInvites().resend(inviteId)`.
3296
+ */
3297
+ send_invitation?: boolean;
3292
3298
  }
3293
3299
 
3300
+ /**
3301
+ * When several apply the first of accepted, revoked, not_sent, expired,
3302
+ * pending wins. `not_sent` is an invitation created with
3303
+ * `send_invitation: false` that has not been emailed yet.
3304
+ */
3294
3305
  export type AppUserInviteStatus =
3295
3306
  | "pending"
3296
3307
  | "accepted"
3297
3308
  | "revoked"
3298
- | "expired";
3309
+ | "expired"
3310
+ | "not_sent";
3299
3311
 
3300
3312
  export interface AppUserInviteRow {
3301
3313
  id: string;
@@ -3310,7 +3322,9 @@ export interface AppUserInviteRow {
3310
3322
  status: AppUserInviteStatus;
3311
3323
  name?: string;
3312
3324
  group_ids?: string[];
3313
- invited_at?: string;
3325
+ /** When the invitation email last left; `null` until it is first sent. */
3326
+ sent_at: string | null;
3327
+ /** Meaningless while `status` is `"not_sent"` — it restarts on the send. */
3314
3328
  expires_at?: string;
3315
3329
  accepted_at?: string | null;
3316
3330
  revoked_at?: string | null;
@@ -3410,8 +3424,9 @@ export function useGroups(query?: GroupsQuery): GroupsApi;
3410
3424
 
3411
3425
  export interface InvitesQuery {
3412
3426
  /**
3413
- * Defaults to "all" server-side; pass "pending" for outstanding invites.
3414
- * "open" is "pending" or "expired": neither accepted nor revoked.
3427
+ * Defaults to "all" server-side; pass "pending" for outstanding invites and
3428
+ * "not_sent" for the ones prepared but not emailed yet.
3429
+ * "open" is "pending", "not_sent" or "expired": neither accepted nor revoked.
3415
3430
  */
3416
3431
  status?: AppUserInviteStatus | "open" | "all";
3417
3432
  /** Case-insensitive substring match on the invite's name or email. */
@@ -3432,7 +3447,12 @@ export interface InvitesApi {
3432
3447
  loading: boolean;
3433
3448
  error: DirectoryError | null;
3434
3449
  refetch(): Promise<void>;
3435
- /** Re-send the invitation email. Refetches on success. */
3450
+ /**
3451
+ * Email the invitation and restart its 7-day expiry. The one call that
3452
+ * sends: the first send of a `not_sent` invitation and a repeat send of a
3453
+ * sent one alike. Resolves the row with `status: "pending"`; refetches on
3454
+ * success.
3455
+ */
3436
3456
  resend(inviteId: string): Promise<AppUserInviteRow>;
3437
3457
  /** Cancel a pending invitation. Refetches on success. */
3438
3458
  revoke(inviteId: string): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.265.0",
3
+ "version": "0.266.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
@@ -2358,8 +2358,9 @@ const HOOKS = [
2358
2358
  // deactivate / reactivate / sendPasswordReset / update) additionally need
2359
2359
  // `users.write:*`, and the
2360
2360
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
2361
- // call accepts `{ email, name, groupIds? }` and returns the resulting
2362
- // AppUserInvite row (the email is sent by the host). Mutating users from
2361
+ // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
2362
+ // the resulting AppUserInvite row (the host sends the email, unless
2363
+ // `send_invitation` is false). Mutating users from
2363
2364
  // a widget requires the corresponding SystemAcl capability grant in the
2364
2365
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
2365
2366
  // that only call read methods need only `users.read:*`.
@@ -2379,8 +2380,13 @@ const HOOKS = [
2379
2380
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
2380
2381
  "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
2381
2382
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
2382
- "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
2383
- "(the email is sent by the host). sendPasswordReset(userId) mails the " +
2383
+ "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
2384
+ "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
2385
+ "The host emails the invitation at once unless send_invitation is " +
2386
+ "false: then nothing is emailed, the row comes back with sent_at null " +
2387
+ "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
2388
+ "later. A send_invitation that is not a boolean rejects with " +
2389
+ "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
2384
2390
  "standard self-serve reset link to that user's own registered address " +
2385
2391
  "and resolves { sent, email_masked } — never the token or the link, so " +
2386
2392
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
@@ -2419,7 +2425,7 @@ const HOOKS = [
2419
2425
  error: "DirectoryError | null // { code, reason, message, retryable }",
2420
2426
  refetch: "() => Promise<void>",
2421
2427
  invite:
2422
- "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
2428
+ "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
2423
2429
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2424
2430
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2425
2431
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
@@ -2637,7 +2643,8 @@ const HOOKS = [
2637
2643
  // sc-5097 — pending AppUser invite administration. Returns
2638
2644
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
2639
2645
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
2640
- // caller's `users.write` SystemAcl capability.
2646
+ // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
2647
+ // the first send of a `not_sent` invite — there is no separate `send`.
2641
2648
  {
2642
2649
  name: "useInvites",
2643
2650
  signature: "useInvites(query)",
@@ -2647,13 +2654,23 @@ const HOOKS = [
2647
2654
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
2648
2655
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
2649
2656
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
2650
- "res.data; rows are snake_case (email, status, expires_at, …) and " +
2651
- "status is the server-computed pending/accepted/revoked/expired " +
2652
- "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2657
+ "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
2658
+ "and status is the server-computed pending/accepted/revoked/expired/" +
2659
+ "not_sent value. not_sent is an invitation created with " +
2660
+ "send_invitation: false that has not been emailed yet: its sent_at is " +
2661
+ "null, it cannot be accepted until it is sent, and its expires_at " +
2662
+ "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
2663
+ "ONE call that sends an invitation — the first send of a not_sent one " +
2664
+ "and a repeat send of a sent one alike: it emails the invitee, stamps " +
2665
+ "sent_at, restarts the 7-day expiry and resolves the row with status " +
2666
+ "'pending'. There is no separate send method; label the button " +
2667
+ "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
2668
+ "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2653
2669
  "verbatim; the endpoint defaults to status 'all', so pass " +
2654
- "{ status: 'pending' } for the usual outstanding-invite list; " +
2655
- "status 'open' is every invite neither accepted nor revoked (pending " +
2656
- "or expired). q is a " +
2670
+ "{ status: 'pending' } for the usual outstanding-invite list and " +
2671
+ "{ status: 'not_sent' } for the prepared ones; " +
2672
+ "status 'open' is every invite neither accepted nor revoked (pending, " +
2673
+ "not_sent or expired). q is a " +
2657
2674
  "case-insensitive substring match on the invite's name or email. " +
2658
2675
  "Each row's app_user_id is the id the invitee's user account will " +
2659
2676
  "have once they accept; a USER column may already hold it, and " +
@@ -2670,12 +2687,12 @@ const HOOKS = [
2670
2687
  "refetch on success.",
2671
2688
  returnShape: {
2672
2689
  invites:
2673
- "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 }",
2690
+ "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 }",
2674
2691
  loading: "boolean",
2675
2692
  error: "DirectoryError | null // { code, message, retryable }",
2676
2693
  refetch: "() => Promise<void>",
2677
2694
  resend:
2678
- "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
2695
+ "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
2679
2696
  revoke:
2680
2697
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
2681
2698
  },
@@ -4169,7 +4186,7 @@ const WIDGET_CONTEXT_SHAPE = {
4169
4186
  "invites: { list(), revoke(id), resend(id) }, " +
4170
4187
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }, " +
4171
4188
  "presence: { online(tableId, recordIds) -> { data: [{ record_id, online_user_ids, online_count }] }, me() -> { show_online }, setShowOnline(bool) -> { show_online } } }. " +
4172
- "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); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). 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:*).",
4189
+ "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); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). 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:*).",
4173
4190
  required: true,
4174
4191
  fields: { users: "object", groups: "object", invites: "object", bankid: "object", presence: "object" },
4175
4192
  },
@@ -6442,7 +6459,11 @@ const CONTRACT = deepFreeze({
6442
6459
  // random id per row and may be `defaultLocked`.
6443
6460
  // 1.215.0: additive (sc-13938) — `usePdfExport` `openPdf` / `exportToPdf`
6444
6461
  // take `appendFileIds`: stored PDFs whose pages follow the rendered HTML.
6445
- version: "1.215.0",
6462
+ // 1.216.0: additive (sc-14096) — reinstates 1.144.0, withdrawn in 1.155.0:
6463
+ // `useUsers().invite` takes `send_invitation` (false stores the invite
6464
+ // unsent), invite rows carry `sent_at`, `not_sent` is a status, and
6465
+ // `useInvites().resend` performs the first send.
6466
+ version: "1.216.0",
6446
6467
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6447
6468
  hooks: HOOKS,
6448
6469
  primitives: PRIMITIVES,