@colixsystems/widget-sdk 0.182.0 → 0.183.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
@@ -59,7 +59,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
59
59
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
60
60
  | **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:*`) |
61
61
  | **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
62
- | **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. |
62
+ | **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. |
63
63
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
64
64
  | **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. |
65
65
  | **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). |
@@ -74,7 +74,28 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
74
74
 
75
75
  ## Status
76
76
 
77
- `v0.182.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**.
77
+ `v0.183.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**.
78
+
79
+ ### What's new in 0.183.0 (contract 1.144.0)
80
+
81
+ **A widget can prepare an invitation now and send it later.** `useUsers().invite()` always emailed the invitation the moment it was created, so a widget could not line people up ahead of a launch day or let a second person check the list first. `invite()` now takes an optional `send_invitation`:
82
+
83
+ ```js
84
+ const { invite } = useUsers();
85
+ const { invites, resend } = useInvites({ status: "not_sent" });
86
+
87
+ const prepared = await invite({ email: "bo@example.com", name: "Bo", send_invitation: false });
88
+ // prepared.status === "not_sent", prepared.sent_at === null — nobody was emailed.
89
+
90
+ await resend(prepared.id); // emails it; the row is now "pending" with a fresh 7-day expiry
91
+ ```
92
+
93
+ - **`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`.
94
+ - **`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.
95
+ - **`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.
96
+ - **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.
97
+
98
+ 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.
78
99
 
79
100
  ### What's new in 0.182.0 (contract 1.143.0)
80
101
 
@@ -1392,7 +1413,7 @@ Additive — one new hook, one new optional device capability, one new error cla
1392
1413
 
1393
1414
  **New linter rule `measured-width-ignores-padding` — a measured width includes the measuring element's own padding (sc-4913).**
1394
1415
 
1395
- - **`measured-width-ignores-padding` (severity `warning`, non-blocking).** A widget that puts `onLayout` on an element which sets its own `padding` (or `paddingHorizontal`/`Left`/`Right`), and then sizes grid cells from the measured number, is flagged. `onLayout` reports the element's **frame** width and padding sits inside that frame, so the space the children really get is `width - paddingLeft - paddingRight`. Cells sized to fill the raw measurement overflow the content box, the last one wraps, and the widget ships a whole empty column of whitespace beside its cards — on both the web Player and the native Expo export, with nothing in the console. Author fix: spread `onLayout` on an **unpadded** element (keep the padding on a parent, or measure an inner `<View>` inside the padded root) so the number you hold is the usable width. Better still for content-sized cells: skip the measurement entirely and wrap with flex — a `{ flexDirection: "row", flexWrap: "wrap", gap }` row whose cards take `{ flexGrow: 1, flexBasis: CARD_MIN }` splits the row's real content width itself and can never leave a leftover band.
1416
+ - **`measured-width-ignores-padding` (severity `warning`, non-blocking).** A widget that puts `onLayout` on an element which sets its own `padding` (or `paddingHorizontal`/`Left`/`Right`), and then sizes grid cells from the measured number, is flagged. `onLayout` reports the element's **frame** width and padding sits inside that frame, so the space the children really get is `width - paddingLeft - paddingRight`. Cells sized to fill the raw measurement overflow the content box, the last one wraps, and the widget ships a whole empty column of whitespace beside its cards — on both the web Player and the native Expo export, with nothing in the console. Author fix: spread `onLayout` on an **unpadded** element (keep the padding on a parent, or measure an inner `<View>` inside the padded root) so the number you hold is the usable width. For content-sized cards, that unpadded measurement is also how a grid counts its cards per line (see the card-grid rule under **Size to your container**).
1396
1417
  - **Why a warning.** The rule fires only when the measured value feeds sizing arithmetic — a padded box measured just to pick a wide/narrow form (`isNarrowWidth(width)`) is off by one padding pair and stays silent. A widget that already subtracts its own padding by hand matches too, because no text scan can verify the subtraction; that is deliberate — the remediation is correct for it as well and retires the arithmetic. Comments are not scanned, so documenting the anti-pattern is safe.
1397
1418
 
1398
1419
  `CONTRACT` is unchanged (no new field), and no export changed signature.
@@ -2044,7 +2065,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
2044
2065
  - **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
2045
2066
  - **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
2046
2067
  - **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
2047
- - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a cell whose definite size is computed from the width (a calendar day, a chart), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers** (sc-8508): `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
2068
+ - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a count or size computed from the width (a calendar day, a chart, a card grid's cards per line), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers** (sc-8508): `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead — but a card grid or a tile row is never capped. **A card grid fills its lines, it never wraps freely** — a free `flexWrap` strands the remainder (six cards that fit five render 5 + 1). Count the cards that fit the measured width (`const max = Math.max(1, Math.floor((width + gap) / (CARD_MIN + gap)));`), take `const lines = Math.ceil(count / max); const cols = Math.ceil(count / lines);`, and render each slice of `cols` cards as a row of `{ flex: 1, minWidth: 0 }` cells, padding the last line with empty `{ flex: 1 }` spacers so its cards keep the width of the cards above (six that fit five are 3 + 3, ten that fit four are 4 + 4 + 2). **A short value never breaks across lines** — a time, date, price, count, percentage or code takes `numberOfLines={1}` and `flexShrink: 0`, never a fixed width narrower than it, and a narrow slot re-arranges or steps the font down instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
2048
2069
  - **Compose forms — pair fields into rows, don't stack one per row.** Lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
2049
2070
  - **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` — see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
2050
2071
  - **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` when `widthTier(width) === "narrow"`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
@@ -2101,11 +2122,13 @@ The matching manifest declares the scopes:
2101
2122
  }
2102
2123
  ```
2103
2124
 
2125
+ `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)`.
2126
+
2104
2127
  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.
2105
2128
 
2106
2129
  ## Listing pending invitations from a widget
2107
2130
 
2108
- `useInvites(query?)` lists the workspace's invites and resends / revokes them:
2131
+ `useInvites(query?)` lists the workspace's invites and sends / revokes them:
2109
2132
 
2110
2133
  ```jsx
2111
2134
  import { useInvites, View, Text, Pressable } from '@colixsystems/widget-sdk';
@@ -2120,7 +2143,9 @@ function PendingInvites() {
2120
2143
  {invites.map((i) => (
2121
2144
  <View key={i.id}>
2122
2145
  <Text>{i.email} · {i.status}</Text>
2123
- <Pressable onPress={() => resend(i.id).catch(() => {})}><Text>Resend</Text></Pressable>
2146
+ <Pressable onPress={() => resend(i.id).catch(() => {})}>
2147
+ <Text>{i.status === 'not_sent' ? 'Send invitation' : 'Resend'}</Text>
2148
+ </Pressable>
2124
2149
  <Pressable onPress={() => revoke(i.id).catch(() => {})}><Text>Revoke</Text></Pressable>
2125
2150
  </View>
2126
2151
  ))}
@@ -2129,9 +2154,16 @@ function PendingInvites() {
2129
2154
  }
2130
2155
  ```
2131
2156
 
2132
- Rows are snake_case: `{ id, app_user_id, email, name, group_ids, status, expires_at,
2133
- accepted_at, revoked_at, created_at }`. Trust the server-computed `status` rather than
2134
- comparing `expires_at` against the device clock.
2157
+ Rows are snake_case: `{ id, app_user_id, email, name, group_ids, status, sent_at,
2158
+ expires_at, accepted_at, revoked_at, created_at }`. `status` is one of `pending`,
2159
+ `accepted`, `revoked`, `expired` or `not_sent`; trust the server-computed value rather
2160
+ than comparing `expires_at` against the device clock.
2161
+
2162
+ A `not_sent` row is an invitation created with `send_invitation: false`: nobody has
2163
+ been emailed and `sent_at` is `null`. `resend(inviteId)` is the one call that sends
2164
+ an invitation, a first send and a repeat send alike — it emails the invitee, stamps
2165
+ `sent_at`, restarts the 7-day expiry and resolves the row as `pending`. Pass
2166
+ `{ status: 'not_sent' }` to list only the prepared ones.
2135
2167
 
2136
2168
  `query` also takes `q` (a case-insensitive substring match on the invite's name or
2137
2169
  email) and `app_user_id`. Each row's `app_user_id` is the id the invitee's user account
@@ -2139,7 +2171,7 @@ will have once they accept. A `USER` column may hold that id before the person h
2139
2171
  joined — it then reads back as `{ id, name, invited: true }`, with no `email` — and
2140
2172
  `useInvites({ app_user_id: value.id, status: 'open', limit: 1 })` returns the invite
2141
2173
  behind it. `status: 'open'` is every invite that is neither accepted nor revoked
2142
- (`pending` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
2174
+ (`pending`, `not_sent` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
2143
2175
 
2144
2176
  **The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
2145
2177
  someone who is not a member yet, so the backend gates the entire invite surface —
package/dist/contract.cjs CHANGED
@@ -1697,8 +1697,9 @@ const HOOKS = [
1697
1697
  // deactivate / reactivate / sendPasswordReset) additionally need
1698
1698
  // `users.write:*`, and the
1699
1699
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
1700
- // call accepts `{ email, name, groupIds? }` and returns the resulting
1701
- // AppUserInvite row (the email is sent by the host). Mutating users from
1700
+ // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
1701
+ // the resulting AppUserInvite row (the host sends the email, unless
1702
+ // `send_invitation` is false). Mutating users from
1702
1703
  // a widget requires the corresponding SystemAcl capability grant in the
1703
1704
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
1704
1705
  // that only call read methods need only `users.read:*`.
@@ -1716,8 +1717,13 @@ const HOOKS = [
1716
1717
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1717
1718
  "sendPasswordReset) need users.write:*, and the destructive remove() " +
1718
1719
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
1719
- "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
1720
- "(the email is sent by the host). sendPasswordReset(userId) mails the " +
1720
+ "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
1721
+ "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
1722
+ "The host emails the invitation at once unless send_invitation is " +
1723
+ "false: then nothing is emailed, the row comes back with sent_at null " +
1724
+ "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
1725
+ "later. A send_invitation that is not a boolean rejects with " +
1726
+ "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
1721
1727
  "standard self-serve reset link to that user's own registered address " +
1722
1728
  "and resolves { sent, email_masked } — never the token or the link, so " +
1723
1729
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
@@ -1730,7 +1736,7 @@ const HOOKS = [
1730
1736
  error: "DirectoryError | null // { code, message, retryable }",
1731
1737
  refetch: "() => Promise<void>",
1732
1738
  invite:
1733
- "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
1739
+ "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
1734
1740
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
1735
1741
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
1736
1742
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
@@ -1775,7 +1781,8 @@ const HOOKS = [
1775
1781
  // sc-5097 — pending AppUser invite administration. Returns
1776
1782
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
1777
1783
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
1778
- // caller's `users.write` SystemAcl capability.
1784
+ // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
1785
+ // the first send of a `not_sent` invite — there is no separate `send`.
1779
1786
  {
1780
1787
  name: "useInvites",
1781
1788
  signature: "useInvites(query)",
@@ -1785,13 +1792,23 @@ const HOOKS = [
1785
1792
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
1786
1793
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
1787
1794
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
1788
- "res.data; rows are snake_case (email, status, expires_at, …) and " +
1789
- "status is the server-computed pending/accepted/revoked/expired " +
1790
- "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
1795
+ "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
1796
+ "and status is the server-computed pending/accepted/revoked/expired/" +
1797
+ "not_sent value. not_sent is an invitation created with " +
1798
+ "send_invitation: false that has not been emailed yet: its sent_at is " +
1799
+ "null, it cannot be accepted until it is sent, and its expires_at " +
1800
+ "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
1801
+ "ONE call that sends an invitation — the first send of a not_sent one " +
1802
+ "and a repeat send of a sent one alike: it emails the invitee, stamps " +
1803
+ "sent_at, restarts the 7-day expiry and resolves the row with status " +
1804
+ "'pending'. There is no separate send method; label the button " +
1805
+ "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
1806
+ "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
1791
1807
  "verbatim; the endpoint defaults to status 'all', so pass " +
1792
- "{ status: 'pending' } for the usual outstanding-invite list; " +
1793
- "status 'open' is every invite neither accepted nor revoked (pending " +
1794
- "or expired). q is a " +
1808
+ "{ status: 'pending' } for the usual outstanding-invite list and " +
1809
+ "{ status: 'not_sent' } for the prepared ones; " +
1810
+ "status 'open' is every invite neither accepted nor revoked (pending, " +
1811
+ "not_sent or expired). q is a " +
1795
1812
  "case-insensitive substring match on the invite's name or email. " +
1796
1813
  "Each row's app_user_id is the id the invitee's user account will " +
1797
1814
  "have once they accept; a USER column may already hold it, and " +
@@ -1808,12 +1825,12 @@ const HOOKS = [
1808
1825
  "refetch on success.",
1809
1826
  returnShape: {
1810
1827
  invites:
1811
- "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 }",
1828
+ "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 }",
1812
1829
  loading: "boolean",
1813
1830
  error: "DirectoryError | null // { code, message, retryable }",
1814
1831
  refetch: "() => Promise<void>",
1815
1832
  resend:
1816
- "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
1833
+ "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
1817
1834
  revoke:
1818
1835
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
1819
1836
  },
@@ -2855,7 +2872,7 @@ const WIDGET_CONTEXT_SHAPE = {
2855
2872
  "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
2856
2873
  "invites: { list(), revoke(id), resend(id) }, " +
2857
2874
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
2858
- "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:*).",
2875
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke invites — resend also sends one created with send_invitation: false — gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
2859
2876
  required: true,
2860
2877
  fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
2861
2878
  },
@@ -4782,7 +4799,11 @@ const CONTRACT = deepFreeze({
4782
4799
  // accept.
4783
4800
  // 1.143.0: additive (sc-9756) — a datastoreTemplate RELATION column may
4784
4801
  // declare `onDelete` ("CASCADE" | "SET_NULL") for ONE_TO_ONE/ONE_TO_MANY.
4785
- version: "1.143.0",
4802
+ // 1.144.0: additive (sc-9714) — `useUsers().invite` takes an optional
4803
+ // `send_invitation`; `false` stores the invite unsent. Invite rows gain
4804
+ // `sent_at` and the `not_sent` status, and `useInvites().resend` is its
4805
+ // first send. Omitting the flag emails at once, as before.
4806
+ version: "1.144.0",
4786
4807
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4787
4808
  hooks: HOOKS,
4788
4809
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1697,8 +1697,9 @@ const HOOKS = [
1697
1697
  // deactivate / reactivate / sendPasswordReset) additionally need
1698
1698
  // `users.write:*`, and the
1699
1699
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
1700
- // call accepts `{ email, name, groupIds? }` and returns the resulting
1701
- // AppUserInvite row (the email is sent by the host). Mutating users from
1700
+ // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
1701
+ // the resulting AppUserInvite row (the host sends the email, unless
1702
+ // `send_invitation` is false). Mutating users from
1702
1703
  // a widget requires the corresponding SystemAcl capability grant in the
1703
1704
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
1704
1705
  // that only call read methods need only `users.read:*`.
@@ -1716,8 +1717,13 @@ const HOOKS = [
1716
1717
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1717
1718
  "sendPasswordReset) need users.write:*, and the destructive remove() " +
1718
1719
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
1719
- "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
1720
- "(the email is sent by the host). sendPasswordReset(userId) mails the " +
1720
+ "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
1721
+ "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
1722
+ "The host emails the invitation at once unless send_invitation is " +
1723
+ "false: then nothing is emailed, the row comes back with sent_at null " +
1724
+ "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
1725
+ "later. A send_invitation that is not a boolean rejects with " +
1726
+ "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
1721
1727
  "standard self-serve reset link to that user's own registered address " +
1722
1728
  "and resolves { sent, email_masked } — never the token or the link, so " +
1723
1729
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
@@ -1730,7 +1736,7 @@ const HOOKS = [
1730
1736
  error: "DirectoryError | null // { code, message, retryable }",
1731
1737
  refetch: "() => Promise<void>",
1732
1738
  invite:
1733
- "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
1739
+ "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
1734
1740
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
1735
1741
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
1736
1742
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
@@ -1775,7 +1781,8 @@ const HOOKS = [
1775
1781
  // sc-5097 — pending AppUser invite administration. Returns
1776
1782
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
1777
1783
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
1778
- // caller's `users.write` SystemAcl capability.
1784
+ // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
1785
+ // the first send of a `not_sent` invite — there is no separate `send`.
1779
1786
  {
1780
1787
  name: "useInvites",
1781
1788
  signature: "useInvites(query)",
@@ -1785,13 +1792,23 @@ const HOOKS = [
1785
1792
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
1786
1793
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
1787
1794
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
1788
- "res.data; rows are snake_case (email, status, expires_at, …) and " +
1789
- "status is the server-computed pending/accepted/revoked/expired " +
1790
- "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
1795
+ "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
1796
+ "and status is the server-computed pending/accepted/revoked/expired/" +
1797
+ "not_sent value. not_sent is an invitation created with " +
1798
+ "send_invitation: false that has not been emailed yet: its sent_at is " +
1799
+ "null, it cannot be accepted until it is sent, and its expires_at " +
1800
+ "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
1801
+ "ONE call that sends an invitation — the first send of a not_sent one " +
1802
+ "and a repeat send of a sent one alike: it emails the invitee, stamps " +
1803
+ "sent_at, restarts the 7-day expiry and resolves the row with status " +
1804
+ "'pending'. There is no separate send method; label the button " +
1805
+ "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
1806
+ "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
1791
1807
  "verbatim; the endpoint defaults to status 'all', so pass " +
1792
- "{ status: 'pending' } for the usual outstanding-invite list; " +
1793
- "status 'open' is every invite neither accepted nor revoked (pending " +
1794
- "or expired). q is a " +
1808
+ "{ status: 'pending' } for the usual outstanding-invite list and " +
1809
+ "{ status: 'not_sent' } for the prepared ones; " +
1810
+ "status 'open' is every invite neither accepted nor revoked (pending, " +
1811
+ "not_sent or expired). q is a " +
1795
1812
  "case-insensitive substring match on the invite's name or email. " +
1796
1813
  "Each row's app_user_id is the id the invitee's user account will " +
1797
1814
  "have once they accept; a USER column may already hold it, and " +
@@ -1808,12 +1825,12 @@ const HOOKS = [
1808
1825
  "refetch on success.",
1809
1826
  returnShape: {
1810
1827
  invites:
1811
- "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 }",
1828
+ "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 }",
1812
1829
  loading: "boolean",
1813
1830
  error: "DirectoryError | null // { code, message, retryable }",
1814
1831
  refetch: "() => Promise<void>",
1815
1832
  resend:
1816
- "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
1833
+ "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
1817
1834
  revoke:
1818
1835
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
1819
1836
  },
@@ -2851,7 +2868,7 @@ const WIDGET_CONTEXT_SHAPE = {
2851
2868
  "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
2852
2869
  "invites: { list(), revoke(id), resend(id) }, " +
2853
2870
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
2854
- "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:*).",
2871
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke invites — resend also sends one created with send_invitation: false — gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
2855
2872
  required: true,
2856
2873
  fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
2857
2874
  },
@@ -4778,7 +4795,11 @@ const CONTRACT = deepFreeze({
4778
4795
  // accept.
4779
4796
  // 1.143.0: additive (sc-9756) — a datastoreTemplate RELATION column may
4780
4797
  // declare `onDelete` ("CASCADE" | "SET_NULL") for ONE_TO_ONE/ONE_TO_MANY.
4781
- version: "1.143.0",
4798
+ // 1.144.0: additive (sc-9714) — `useUsers().invite` takes an optional
4799
+ // `send_invitation`; `false` stores the invite unsent. Invite rows gain
4800
+ // `sent_at` and the `not_sent` status, and `useInvites().resend` is its
4801
+ // first send. Omitting the flag emails at once, as before.
4802
+ version: "1.144.0",
4782
4803
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4783
4804
  hooks: HOOKS,
4784
4805
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -5137,7 +5137,7 @@ export function useDirectory(query) {
5137
5137
  * invite, deactivate, reactivate}` — `list` resolves to the `{ data, meta }`
5138
5138
  * envelope VERBATIM, so we unwrap `res.data` (default `[]`). User rows are
5139
5139
  * snake_case (`id`, `name`, `email`, `role`, `is_active`, …) and bodies
5140
- * (e.g. `{ email, name, group_ids? }`) pass through verbatim. `query` is an
5140
+ * (e.g. `{ email, name, group_ids?, send_invitation? }`) pass through verbatim. `query` is an
5141
5141
  * optional `{ q?, role?, is_active?, group_id?, limit?, offset? }`; `group_id`
5142
5142
  * (the `id` of a `useGroups()` row) narrows the list to that group's members.
5143
5143
  * The list refetches whenever `JSON.stringify(query)` changes; the imperative methods
@@ -5374,15 +5374,20 @@ export function useGroups(query) {
5374
5374
  * `{ data, meta }` envelope VERBATIM, so we unwrap `res.data` (default `[]`).
5375
5375
  *
5376
5376
  * Rows are snake_case exactly as the backend projects them: `{ id, tenant_id,
5377
- * email, name, group_ids, status, expires_at, accepted_at, revoked_at,
5378
- * created_at, invited_by_studio_user_id }`. `status` is the server-computed
5379
- * lifecycle value (`"pending" | "accepted" | "revoked" | "expired"`) — prefer
5380
- * it over re-deriving expiry on the client, whose clock may disagree.
5377
+ * email, name, group_ids, status, sent_at, expires_at, accepted_at,
5378
+ * revoked_at, created_at, invited_by_studio_user_id }`. `status` is the
5379
+ * server-computed lifecycle value (`"pending" | "accepted" | "revoked" |
5380
+ * "expired" | "not_sent"`) — prefer it over re-deriving expiry on the client,
5381
+ * whose clock may disagree.
5382
+ *
5383
+ * `not_sent` is an invite created with `send_invitation: false`: `sent_at` is
5384
+ * null and nothing was emailed. `resend(inviteId)` is the one call that sends
5385
+ * — a first send and a repeat send alike — and resolves the row as `pending`.
5381
5386
  *
5382
5387
  * `query` is an optional `{ status?, limit?, offset? }` passed through
5383
5388
  * verbatim; `status` accepts `"pending" | "accepted" | "revoked" | "expired" |
5384
- * "all"` and the endpoint defaults to `"all"`, so pass `{ status: "pending" }`
5385
- * for the usual "who hasn't accepted yet" list.
5389
+ * "not_sent" | "all"` and the endpoint defaults to `"all"`, so pass
5390
+ * `{ status: "pending" }` for the usual "who hasn't accepted yet" list.
5386
5391
  *
5387
5392
  * The WHOLE invite surface — list included — is gated on the `users.write`
5388
5393
  * capability plus a signed `users.write:*` scope: a pending invite exposes
@@ -5449,7 +5454,8 @@ export function useInvites(query) {
5449
5454
  }, [doFetch]);
5450
5455
 
5451
5456
  // Both mutations re-list on success: revoking drops the row and resending
5452
- // moves `expires_at`, so a stale list would show the operator the old state.
5457
+ // moves `expires_at` (and a first send's status), so a stale list would
5458
+ // show the operator the old state.
5453
5459
  const resend = useCallback(
5454
5460
  async (inviteId) => {
5455
5461
  try {
package/dist/index.d.ts CHANGED
@@ -2181,13 +2181,25 @@ export interface InviteArgs {
2181
2181
  email: string;
2182
2182
  name?: string;
2183
2183
  group_ids?: string[];
2184
+ /**
2185
+ * Defaults to `true`: the invitation is emailed at once. `false` stores it
2186
+ * without emailing anyone (`status: "not_sent"`); send it later with
2187
+ * `useInvites().resend(inviteId)`.
2188
+ */
2189
+ send_invitation?: boolean;
2184
2190
  }
2185
2191
 
2192
+ /**
2193
+ * When several apply the first of accepted, revoked, not_sent, expired,
2194
+ * pending wins. `not_sent` is an invitation created with
2195
+ * `send_invitation: false` that has not been emailed yet.
2196
+ */
2186
2197
  export type AppUserInviteStatus =
2187
2198
  | "pending"
2188
2199
  | "accepted"
2189
2200
  | "revoked"
2190
- | "expired";
2201
+ | "expired"
2202
+ | "not_sent";
2191
2203
 
2192
2204
  export interface AppUserInviteRow {
2193
2205
  id: string;
@@ -2202,7 +2214,9 @@ export interface AppUserInviteRow {
2202
2214
  status: AppUserInviteStatus;
2203
2215
  name?: string;
2204
2216
  group_ids?: string[];
2205
- invited_at?: string;
2217
+ /** When the invitation email last left; `null` until it is first sent. */
2218
+ sent_at: string | null;
2219
+ /** Meaningless while `status` is `"not_sent"` — it restarts on the send. */
2206
2220
  expires_at?: string;
2207
2221
  accepted_at?: string | null;
2208
2222
  revoked_at?: string | null;
@@ -2271,8 +2285,9 @@ export function useGroups(query?: GroupsQuery): GroupsApi;
2271
2285
 
2272
2286
  export interface InvitesQuery {
2273
2287
  /**
2274
- * Defaults to "all" server-side; pass "pending" for outstanding invites.
2275
- * "open" is "pending" or "expired": neither accepted nor revoked.
2288
+ * Defaults to "all" server-side; pass "pending" for outstanding invites and
2289
+ * "not_sent" for the ones prepared but not emailed yet.
2290
+ * "open" is "pending", "not_sent" or "expired": neither accepted nor revoked.
2276
2291
  */
2277
2292
  status?: AppUserInviteStatus | "open" | "all";
2278
2293
  /** Case-insensitive substring match on the invite's name or email. */
@@ -2293,7 +2308,12 @@ export interface InvitesApi {
2293
2308
  loading: boolean;
2294
2309
  error: DirectoryError | null;
2295
2310
  refetch(): Promise<void>;
2296
- /** Re-send the invitation email. Refetches on success. */
2311
+ /**
2312
+ * Email the invitation and restart its 7-day expiry. The one call that
2313
+ * sends: the first send of a `not_sent` invitation and a repeat send of a
2314
+ * sent one alike. Resolves the row with `status: "pending"`; refetches on
2315
+ * success.
2316
+ */
2297
2317
  resend(inviteId: string): Promise<AppUserInviteRow>;
2298
2318
  /** Cancel a pending invitation. Refetches on success. */
2299
2319
  revoke(inviteId: string): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.182.0",
3
+ "version": "0.183.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",