@colixsystems/widget-sdk 0.194.0 → 0.196.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 +64 -3
- package/dist/card-style.js +15 -4
- package/dist/contract.cjs +95 -11
- package/dist/contract.js +95 -11
- package/dist/hooks.js +29 -4
- package/dist/index.d.ts +47 -1
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +1 -0
- package/dist/linter.js +2 -1
- package/dist/theme-token-refs.js +3 -1
- package/package.json +1 -1
- package/src/contract.js +95 -11
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
60
60
|
| **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
|
|
61
61
|
| **FILES** | `useAssetsByTag(tag, { type })` (optional: the whole argument, and `type` within it) | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
|
|
62
62
|
| **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
|
|
63
|
-
| **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` plus the `users.read` (or `users.write`) capability, else `FORBIDDEN` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
|
|
63
|
+
| **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset, update }` | `directory.users.*` — `users.read:*` plus the `users.read` (or `users.write`) capability, else `FORBIDDEN` (edits, incl. `sendPasswordReset()` and `update()`, also `users.write:*`; `remove()` also `users.delete:*`) |
|
|
64
64
|
| **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
|
|
65
65
|
| **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, q?, app_user_id?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| open \| all` (endpoint default `all`; `open` = `pending` or `expired`); `q` matches the invite's name or email, `app_user_id` returns the invites reserving that user id. |
|
|
66
66
|
| **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
|
|
@@ -77,7 +77,68 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
77
77
|
|
|
78
78
|
## Status
|
|
79
79
|
|
|
80
|
-
`v0.
|
|
80
|
+
`v0.196.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
81
|
+
|
|
82
|
+
### What's new in 0.196.0 (contract 1.157.0)
|
|
83
|
+
|
|
84
|
+
**A gradient can carry more than two colours.** A component gradient was exactly `{ from, to, angle }`. It now takes an optional `via` list of up to three middle colours, painted in order and evenly spaced between `from` and `to`:
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
{ from: "#0f172a", via: ["#7c3aed", "#db2777"], to: "#f59e0b", angle: 135 }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- **`normaliseComponentGradient`** returns `via` only when the value carries some, so a two-colour gradient normalises exactly as before. A malformed `via` (not a list, more than `CONTRACT.themeComponentGradient.maxViaStops` entries, or an entry that is not a hex colour) is dropped and the two ends still paint, so a stored value never loses its gradient over it. Pass `{ strict: true }` to get `null` for a malformed `via` instead, which is what a write boundary wants.
|
|
91
|
+
- **`componentGradientColors(gradient)`** gives a normalised gradient's colours in paint order, ready for `<Gradient colors={…}>`. **`componentGradientMidpoint(gradient)`** gives the colour halfway along it, the basis for choosing readable text.
|
|
92
|
+
- **`<Card>` and `<Button>` paint the middle colours** when their gradient carries them, and a layout container's wash and photo scrim do the same on the web Player and in the exported app. A middle colour of a container wash may bind to the theme (`"theme:colors.primary"`) like its two ends.
|
|
93
|
+
- **Fully additive.** Every stored two-colour gradient renders byte-identically.
|
|
94
|
+
|
|
95
|
+
### What's new in 0.195.0 (contract 1.156.0)
|
|
96
|
+
|
|
97
|
+
**An admin can correct a member's name or email — `useUsers().update(userId, { name, email })`.** Send either key or both; it resolves `{ id, name, email }`. Call `refetch()` afterwards to refresh the list.
|
|
98
|
+
|
|
99
|
+
```jsx
|
|
100
|
+
import { useUsers } from "@colixsystems/widget-sdk";
|
|
101
|
+
|
|
102
|
+
// Manifest: requestedScopes: ["users.read:*", "users.write:*"]
|
|
103
|
+
function ChangeMemberEmail({ user }) {
|
|
104
|
+
const { update, refetch } = useUsers();
|
|
105
|
+
const save = async (email) => {
|
|
106
|
+
try {
|
|
107
|
+
await update(user.id, { email });
|
|
108
|
+
await refetch();
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err.reason === "EMAIL_TAKEN") { /* mark the email field */ }
|
|
111
|
+
// err.message is a sentence written for the person reading it.
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- **It needs `users.write:*`**, the same scope as `invite` and `deactivate`, plus the workspace's `users.write` capability for the signed-in person. **That scope now also covers changing a member's name and sign-in email**, which is a sensitive power: request it only for a surface meant for the people who administer the app. The linter asks for the scope when a source that calls `useUsers(` also calls `.update(`. If that `.update(` belongs to something else, end the line with `// @appstudio-skip-scope-check`.
|
|
119
|
+
- **An email change is announced to the user at their previous address**, so a change they did not expect never goes unnoticed. You do not send that message yourself.
|
|
120
|
+
- **Refusals name their reason.** `DirectoryError` gains `reason`, the host's specific refusal code, or `null` when it gave none. `code` stays the broad category.
|
|
121
|
+
|
|
122
|
+
| `err.code` | `err.reason` | Meaning |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| `VALIDATION` | `INVALID_UPDATE` | Neither `name` nor `email` was sent |
|
|
125
|
+
| `VALIDATION` | `INVALID_NAME` / `INVALID_EMAIL` | The value is empty, too long or not an address |
|
|
126
|
+
| `VALIDATION` | `EMAIL_TAKEN` | The address is not available |
|
|
127
|
+
| `VALIDATION` | `EMAIL_RESERVED` | A reserved address |
|
|
128
|
+
| `VALIDATION` | `SCIM_MANAGED_NAME` / `SCIM_MANAGED_EMAIL` | The profile is synced from an identity provider; change it there |
|
|
129
|
+
| `VALIDATION` | `SSO_LINKED_EMAIL` | The user signs in through an identity provider; change the email there |
|
|
130
|
+
| `VALIDATION` | `SERVICE_ACCOUNT_PROTECTED` | A service account's email cannot be changed |
|
|
131
|
+
| `VALIDATION` | `SEED_USER_PROTECTED` / `REVIEW_USER_PROTECTED` | The app's preview or store-review account: its email address cannot be changed |
|
|
132
|
+
| `VALIDATION` | `OWN_EMAIL_CHANGE_REFUSED` | The signed-in user cannot change their own email here; they use their account screen |
|
|
133
|
+
| `NOT_FOUND` | | No such user in this workspace |
|
|
134
|
+
| `FORBIDDEN` | | The signed-in person may not edit users |
|
|
135
|
+
| `INTERNAL` (`err.status === 429`) | | Too many email changes in a short time; `err.retryable` is `true` |
|
|
136
|
+
|
|
137
|
+
Every refusal from `EMAIL_TAKEN` down to `OWN_EMAIL_CHANGE_REFUSED`, except the `SCIM_MANAGED_*` pair, concerns the email only: a rename sent as `{ name }` alone still succeeds. `useUsers()` lists service accounts, so expect `SERVICE_ACCOUNT_PROTECTED`; better, do not offer an email field on a row whose `role` is `INTEGRATION`.
|
|
138
|
+
|
|
139
|
+
Email changes are rate-limited per caller. The call is never retried automatically, so a 429 reaches you at once: ask the person to try again later rather than looping.
|
|
140
|
+
|
|
141
|
+
`reason` is set on every `DirectoryError` the host explains, so `sendPasswordReset()` refusals read `err.reason === "USER_INACTIVE"` or `"NO_PASSWORD_CREDENTIAL"`. Additive: a host that does not offer `update` yet rejects the call with an `INTERNAL` `DirectoryError`.
|
|
81
142
|
|
|
82
143
|
### What's new in 0.194.0 (contract 1.155.0)
|
|
83
144
|
|
|
@@ -2236,7 +2297,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
2236
2297
|
|
|
2237
2298
|
## Managing app users from a widget
|
|
2238
2299
|
|
|
2239
|
-
`useUsers()` and `useGroups()` let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. Two gates apply: the manifest must declare the scope (the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller lacks the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that as a "you do not have permission" message.
|
|
2300
|
+
`useUsers()` and `useGroups()` let a widget invite, deactivate, reactivate, and remove members, change a member's name or sign-in email, plus create / delete groups and add / remove their members. `users.write:*` covers every edit, the name and sign-in email included (the member is told at their previous address when the email changes), so request it only for a surface meant for the people who administer the app. Two gates apply: the manifest must declare the scope (the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller lacks the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that as a "you do not have permission" message.
|
|
2240
2301
|
|
|
2241
2302
|
```js
|
|
2242
2303
|
import { Text, View, Pressable, useUsers, useGroups } from "@colixsystems/widget-sdk";
|
package/dist/card-style.js
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
// Every resolver emits ONLY the fields that are set, so a widget's own base
|
|
6
6
|
// style keeps whatever the author left alone.
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
CONTRACT,
|
|
10
|
+
isHexColor,
|
|
11
|
+
normaliseComponentGradient,
|
|
12
|
+
componentGradientColors,
|
|
13
|
+
componentGradientMidpoint,
|
|
14
|
+
} from "./contract.js";
|
|
9
15
|
import { normaliseCornerRadius, cornerRadiusStyle } from "./corner-radius.js";
|
|
10
16
|
import { normaliseSpacing, spacingStyle, isZeroSpacing } from "./spacing.js";
|
|
11
17
|
import { flattenStyle } from "./flatten-style.js";
|
|
@@ -117,7 +123,7 @@ export function cardLayoutStyle(style) {
|
|
|
117
123
|
export function gradientProps(value) {
|
|
118
124
|
const gradient = normaliseComponentGradient(value);
|
|
119
125
|
return gradient
|
|
120
|
-
? { colors:
|
|
126
|
+
? { colors: componentGradientColors(gradient), angle: gradient.angle }
|
|
121
127
|
: null;
|
|
122
128
|
}
|
|
123
129
|
|
|
@@ -131,8 +137,13 @@ export function gradientProps(value) {
|
|
|
131
137
|
*/
|
|
132
138
|
export function cardBackdrop(styles, gradient) {
|
|
133
139
|
if (gradient) {
|
|
134
|
-
const
|
|
135
|
-
|
|
140
|
+
const { colors } = gradient;
|
|
141
|
+
if (!colors.every(isOpaqueHex)) return null;
|
|
142
|
+
return componentGradientMidpoint({
|
|
143
|
+
from: colors[0],
|
|
144
|
+
via: colors.slice(1, -1),
|
|
145
|
+
to: colors[colors.length - 1],
|
|
146
|
+
});
|
|
136
147
|
}
|
|
137
148
|
const fill = flattenStyle(styles).backgroundColor;
|
|
138
149
|
return isOpaqueHex(fill) ? fill : null;
|
package/dist/contract.cjs
CHANGED
|
@@ -332,6 +332,8 @@ const THEME_COMPONENT_GRADIENT = Object.freeze({
|
|
|
332
332
|
angleMin: 0,
|
|
333
333
|
angleMax: 359,
|
|
334
334
|
defaultAngle: 180,
|
|
335
|
+
// sc-10685: middle colours allowed between the two ends (`via`).
|
|
336
|
+
maxViaStops: 3,
|
|
335
337
|
});
|
|
336
338
|
|
|
337
339
|
// REQ-THEME-LOOK: the app-wide SPACING MULTIPLIER. Layout spacing lives on the
|
|
@@ -1746,7 +1748,7 @@ const HOOKS = [
|
|
|
1746
1748
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
1747
1749
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
1748
1750
|
// Reads need `users.read:*` scope; edit-style mutations (invite /
|
|
1749
|
-
// deactivate / reactivate / sendPasswordReset) additionally need
|
|
1751
|
+
// deactivate / reactivate / sendPasswordReset / update) additionally need
|
|
1750
1752
|
// `users.write:*`, and the
|
|
1751
1753
|
// destructive `remove` needs `users.delete:*` (SC-902). The `invite`
|
|
1752
1754
|
// call accepts `{ email, name, groupIds? }` and returns the resulting
|
|
@@ -1760,26 +1762,52 @@ const HOOKS = [
|
|
|
1760
1762
|
optionalArgs: ["query"],
|
|
1761
1763
|
description:
|
|
1762
1764
|
"AppUser administration via the injected directory-client at " +
|
|
1763
|
-
"ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
|
|
1765
|
+
"ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}. " +
|
|
1764
1766
|
"Returns { users, loading, error, refetch, invite, deactivate, " +
|
|
1765
|
-
"reactivate, remove, sendPasswordReset }. " +
|
|
1767
|
+
"reactivate, remove, sendPasswordReset, update }. " +
|
|
1766
1768
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
1767
1769
|
"res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
|
|
1768
1770
|
"scope; edit-style mutations (invite/deactivate/reactivate/" +
|
|
1769
|
-
"sendPasswordReset) need users.write:*, and the destructive remove() " +
|
|
1771
|
+
"sendPasswordReset/update) need users.write:*, and the destructive remove() " +
|
|
1770
1772
|
"additionally needs users.delete:* (SC-902). The `invite` call accepts " +
|
|
1771
1773
|
"{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
|
|
1772
1774
|
"(the email is sent by the host). sendPasswordReset(userId) mails the " +
|
|
1773
1775
|
"standard self-serve reset link to that user's own registered address " +
|
|
1774
1776
|
"and resolves { sent, email_masked } — never the token or the link, so " +
|
|
1775
1777
|
"it cannot be used to sign in as them. It rejects with a DirectoryError " +
|
|
1776
|
-
"
|
|
1777
|
-
"INTEGRATION service account); surface those as an explanation
|
|
1778
|
-
"than a generic failure."
|
|
1778
|
+
"whose reason is USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL " +
|
|
1779
|
+
"(an INTEGRATION service account); surface those as an explanation " +
|
|
1780
|
+
"rather than a generic failure. update(userId, { name?, email? }) " +
|
|
1781
|
+
"changes a user's name, email, or both (at least one key) and resolves " +
|
|
1782
|
+
"{ id, name, email }; refetch() to refresh the list. An email change " +
|
|
1783
|
+
"notifies the user at their previous address. users.write:* therefore " +
|
|
1784
|
+
"lets a widget change a member's name and sign-in email: request it " +
|
|
1785
|
+
"only for a surface meant for the people who administer the app. It " +
|
|
1786
|
+
"rejects with a DirectoryError; code is the broad category and reason " +
|
|
1787
|
+
"the specific refusal. code VALIDATION comes with reason " +
|
|
1788
|
+
"INVALID_UPDATE (neither key), INVALID_NAME, INVALID_EMAIL, " +
|
|
1789
|
+
"EMAIL_TAKEN (the address is not available), EMAIL_RESERVED (a " +
|
|
1790
|
+
"reserved address), SCIM_MANAGED_NAME / SCIM_MANAGED_EMAIL (the " +
|
|
1791
|
+
"profile is synced from an identity provider, so it is changed there), " +
|
|
1792
|
+
"SSO_LINKED_EMAIL (the user signs in through an identity provider, so " +
|
|
1793
|
+
"the email is changed there), SERVICE_ACCOUNT_PROTECTED (a service " +
|
|
1794
|
+
"account's email cannot be changed), SEED_USER_PROTECTED / " +
|
|
1795
|
+
"REVIEW_USER_PROTECTED (the app's preview or store-review account: " +
|
|
1796
|
+
"its email address cannot be changed) or OWN_EMAIL_CHANGE_REFUSED " +
|
|
1797
|
+
"(the signed-in user cannot change their own email here; they use " +
|
|
1798
|
+
"their account screen). Every refusal from EMAIL_TAKEN on, except " +
|
|
1799
|
+
"the SCIM_MANAGED pair, concerns the email only: a rename sent as " +
|
|
1800
|
+
"{ name } alone still succeeds. code NOT_FOUND is an unknown user; " +
|
|
1801
|
+
"code FORBIDDEN a caller without the users.write capability. Email " +
|
|
1802
|
+
"changes are rate-limited per caller: an HTTP 429 arrives as code " +
|
|
1803
|
+
"INTERNAL with status 429 and retryable true, and is never retried " +
|
|
1804
|
+
"automatically, so ask the person to try again later. " +
|
|
1805
|
+
"DirectoryError.reason is the host's specific refusal code, or null " +
|
|
1806
|
+
"when it gave none; branch on it, and show error.message to the person.",
|
|
1779
1807
|
returnShape: {
|
|
1780
1808
|
users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
|
|
1781
1809
|
loading: "boolean",
|
|
1782
|
-
error: "DirectoryError | null // { code, message, retryable }",
|
|
1810
|
+
error: "DirectoryError | null // { code, reason, message, retryable }",
|
|
1783
1811
|
refetch: "() => Promise<void>",
|
|
1784
1812
|
invite:
|
|
1785
1813
|
"({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
|
|
@@ -1788,6 +1816,8 @@ const HOOKS = [
|
|
|
1788
1816
|
remove: "(userId) => Promise<void> // rejects with DirectoryError",
|
|
1789
1817
|
sendPasswordReset:
|
|
1790
1818
|
"(userId) => Promise<{ sent, email_masked }> // rejects with DirectoryError",
|
|
1819
|
+
update:
|
|
1820
|
+
"(userId, { name?, email? }) => Promise<{ id, name, email }> // at least one key; an email change notifies the user at their previous address; rejects with DirectoryError (reason EMAIL_TAKEN, SERVICE_ACCOUNT_PROTECTED, …; status 429 when rate-limited)",
|
|
1791
1821
|
},
|
|
1792
1822
|
requiredContextSlice: ["directory.users"],
|
|
1793
1823
|
scopes: ["users.read:*"],
|
|
@@ -5067,7 +5097,18 @@ const CONTRACT = deepFreeze({
|
|
|
5067
5097
|
// 1.155.0: narrowing (sc-10566) — withdraws 1.144.0: `useUsers().invite`
|
|
5068
5098
|
// takes no `send_invitation` and always emails, invite rows carry no
|
|
5069
5099
|
// `sent_at`, and `not_sent` is no longer a status.
|
|
5070
|
-
|
|
5100
|
+
// 1.156.0: additive (sc-10429) — `useUsers()` gains
|
|
5101
|
+
// `update(userId, { name?, email? })`, gated on `users.write:*`; the
|
|
5102
|
+
// linter requires that scope for a `.update(` call beside `useUsers(`.
|
|
5103
|
+
// `DirectoryError` gains `reason`, the host's specific refusal code.
|
|
5104
|
+
// 1.157.0: additive (sc-10685) — a component gradient takes an optional
|
|
5105
|
+
// `via` list of up to `themeComponentGradient.maxViaStops` middle colours
|
|
5106
|
+
// between `from` and `to`, evenly spaced. `normaliseComponentGradient`
|
|
5107
|
+
// returns it only when present, so a two-colour value normalises exactly
|
|
5108
|
+
// as before; a malformed `via` is dropped, or voids the gradient under the
|
|
5109
|
+
// new `{ strict: true }` option. New `componentGradientColors` / `componentGradientMidpoint`
|
|
5110
|
+
// read the paint list and the ink basis off a normalised gradient.
|
|
5111
|
+
version: "1.157.0",
|
|
5071
5112
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5072
5113
|
hooks: HOOKS,
|
|
5073
5114
|
primitives: PRIMITIVES,
|
|
@@ -5386,6 +5427,10 @@ const GRADIENT_HEX_RE = HEX_RE;
|
|
|
5386
5427
|
|
|
5387
5428
|
/**
|
|
5388
5429
|
* sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
|
|
5430
|
+
* sc-10685: plus `via`, the middle colours, only when the value carries some.
|
|
5431
|
+
* A malformed `via` is dropped and the two ends still paint, so a stored value
|
|
5432
|
+
* never loses its wash over it; `{ strict: true }` voids the gradient instead,
|
|
5433
|
+
* for the boundaries that must refuse the write.
|
|
5389
5434
|
*
|
|
5390
5435
|
* ONE validator for a value arriving by two routes — the theme token and the
|
|
5391
5436
|
* author's never-coerced per-instance `props.style` — so `set_theme` and the
|
|
@@ -5394,7 +5439,7 @@ const GRADIENT_HEX_RE = HEX_RE;
|
|
|
5394
5439
|
* hex grammar is also the injection guard — these stops are interpolated into
|
|
5395
5440
|
* CSS and into generated export source.
|
|
5396
5441
|
*/
|
|
5397
|
-
function normaliseComponentGradient(raw) {
|
|
5442
|
+
function normaliseComponentGradient(raw, options) {
|
|
5398
5443
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
5399
5444
|
const from = typeof raw.from === "string" ? raw.from.trim() : "";
|
|
5400
5445
|
const to = typeof raw.to === "string" ? raw.to.trim() : "";
|
|
@@ -5404,7 +5449,44 @@ function normaliseComponentGradient(raw) {
|
|
|
5404
5449
|
// land NaN in a style.
|
|
5405
5450
|
const angle = raw.angle;
|
|
5406
5451
|
const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
|
|
5407
|
-
|
|
5452
|
+
const via = normaliseGradientVia(raw.via);
|
|
5453
|
+
if (!via && options && options.strict) return null;
|
|
5454
|
+
const gradient = { from, to, angle: ((deg % 360) + 360) % 360 };
|
|
5455
|
+
if (via && via.length > 0) gradient.via = via;
|
|
5456
|
+
return gradient;
|
|
5457
|
+
}
|
|
5458
|
+
|
|
5459
|
+
// All or nothing: one bad middle colour and none of them paint. `null` = malformed.
|
|
5460
|
+
function normaliseGradientVia(raw) {
|
|
5461
|
+
if (raw === undefined || raw === null) return [];
|
|
5462
|
+
if (!Array.isArray(raw) || raw.length > THEME_COMPONENT_GRADIENT.maxViaStops) {
|
|
5463
|
+
return null;
|
|
5464
|
+
}
|
|
5465
|
+
const via = [];
|
|
5466
|
+
for (const item of raw) {
|
|
5467
|
+
const stop = typeof item === "string" ? item.trim() : "";
|
|
5468
|
+
if (!GRADIENT_HEX_RE.test(stop)) return null;
|
|
5469
|
+
via.push(stop);
|
|
5470
|
+
}
|
|
5471
|
+
return via;
|
|
5472
|
+
}
|
|
5473
|
+
|
|
5474
|
+
/**
|
|
5475
|
+
* sc-10685 — a NORMALISED gradient's colours in paint order, evenly spaced:
|
|
5476
|
+
* `[from, ...via, to]`. The one place both hosts read the list from.
|
|
5477
|
+
*/
|
|
5478
|
+
function componentGradientColors(gradient) {
|
|
5479
|
+
return [gradient.from, ...(gradient.via || []), gradient.to];
|
|
5480
|
+
}
|
|
5481
|
+
|
|
5482
|
+
/**
|
|
5483
|
+
* sc-10685 — the colour halfway along a NORMALISED gradient, which is what
|
|
5484
|
+
* text laid across it is contrasted against. Two colours: their mix, as before.
|
|
5485
|
+
*/
|
|
5486
|
+
function componentGradientMidpoint(gradient) {
|
|
5487
|
+
const colors = componentGradientColors(gradient);
|
|
5488
|
+
const middle = (colors.length - 1) / 2;
|
|
5489
|
+
return mixHex(colors[Math.floor(middle)], colors[Math.ceil(middle)], 0.5);
|
|
5408
5490
|
}
|
|
5409
5491
|
|
|
5410
5492
|
// REQ-THEME-LOOK: coerce a theme's spacing scale into the usable band. Shared
|
|
@@ -5447,6 +5529,8 @@ module.exports = {
|
|
|
5447
5529
|
INK_CONTRAST,
|
|
5448
5530
|
gradientAngleToVector,
|
|
5449
5531
|
normaliseComponentGradient,
|
|
5532
|
+
componentGradientColors,
|
|
5533
|
+
componentGradientMidpoint,
|
|
5450
5534
|
isThemeColorTokenRef,
|
|
5451
5535
|
parseThemeColorTokenRef,
|
|
5452
5536
|
formatThemeColorTokenRef,
|
package/dist/contract.js
CHANGED
|
@@ -332,6 +332,8 @@ const THEME_COMPONENT_GRADIENT = Object.freeze({
|
|
|
332
332
|
angleMin: 0,
|
|
333
333
|
angleMax: 359,
|
|
334
334
|
defaultAngle: 180,
|
|
335
|
+
// sc-10685: middle colours allowed between the two ends (`via`).
|
|
336
|
+
maxViaStops: 3,
|
|
335
337
|
});
|
|
336
338
|
|
|
337
339
|
// REQ-THEME-LOOK: the app-wide SPACING MULTIPLIER. Layout spacing lives on the
|
|
@@ -1746,7 +1748,7 @@ const HOOKS = [
|
|
|
1746
1748
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
1747
1749
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
1748
1750
|
// Reads need `users.read:*` scope; edit-style mutations (invite /
|
|
1749
|
-
// deactivate / reactivate / sendPasswordReset) additionally need
|
|
1751
|
+
// deactivate / reactivate / sendPasswordReset / update) additionally need
|
|
1750
1752
|
// `users.write:*`, and the
|
|
1751
1753
|
// destructive `remove` needs `users.delete:*` (SC-902). The `invite`
|
|
1752
1754
|
// call accepts `{ email, name, groupIds? }` and returns the resulting
|
|
@@ -1760,26 +1762,52 @@ const HOOKS = [
|
|
|
1760
1762
|
optionalArgs: ["query"],
|
|
1761
1763
|
description:
|
|
1762
1764
|
"AppUser administration via the injected directory-client at " +
|
|
1763
|
-
"ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
|
|
1765
|
+
"ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}. " +
|
|
1764
1766
|
"Returns { users, loading, error, refetch, invite, deactivate, " +
|
|
1765
|
-
"reactivate, remove, sendPasswordReset }. " +
|
|
1767
|
+
"reactivate, remove, sendPasswordReset, update }. " +
|
|
1766
1768
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
1767
1769
|
"res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
|
|
1768
1770
|
"scope; edit-style mutations (invite/deactivate/reactivate/" +
|
|
1769
|
-
"sendPasswordReset) need users.write:*, and the destructive remove() " +
|
|
1771
|
+
"sendPasswordReset/update) need users.write:*, and the destructive remove() " +
|
|
1770
1772
|
"additionally needs users.delete:* (SC-902). The `invite` call accepts " +
|
|
1771
1773
|
"{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
|
|
1772
1774
|
"(the email is sent by the host). sendPasswordReset(userId) mails the " +
|
|
1773
1775
|
"standard self-serve reset link to that user's own registered address " +
|
|
1774
1776
|
"and resolves { sent, email_masked } — never the token or the link, so " +
|
|
1775
1777
|
"it cannot be used to sign in as them. It rejects with a DirectoryError " +
|
|
1776
|
-
"
|
|
1777
|
-
"INTEGRATION service account); surface those as an explanation
|
|
1778
|
-
"than a generic failure."
|
|
1778
|
+
"whose reason is USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL " +
|
|
1779
|
+
"(an INTEGRATION service account); surface those as an explanation " +
|
|
1780
|
+
"rather than a generic failure. update(userId, { name?, email? }) " +
|
|
1781
|
+
"changes a user's name, email, or both (at least one key) and resolves " +
|
|
1782
|
+
"{ id, name, email }; refetch() to refresh the list. An email change " +
|
|
1783
|
+
"notifies the user at their previous address. users.write:* therefore " +
|
|
1784
|
+
"lets a widget change a member's name and sign-in email: request it " +
|
|
1785
|
+
"only for a surface meant for the people who administer the app. It " +
|
|
1786
|
+
"rejects with a DirectoryError; code is the broad category and reason " +
|
|
1787
|
+
"the specific refusal. code VALIDATION comes with reason " +
|
|
1788
|
+
"INVALID_UPDATE (neither key), INVALID_NAME, INVALID_EMAIL, " +
|
|
1789
|
+
"EMAIL_TAKEN (the address is not available), EMAIL_RESERVED (a " +
|
|
1790
|
+
"reserved address), SCIM_MANAGED_NAME / SCIM_MANAGED_EMAIL (the " +
|
|
1791
|
+
"profile is synced from an identity provider, so it is changed there), " +
|
|
1792
|
+
"SSO_LINKED_EMAIL (the user signs in through an identity provider, so " +
|
|
1793
|
+
"the email is changed there), SERVICE_ACCOUNT_PROTECTED (a service " +
|
|
1794
|
+
"account's email cannot be changed), SEED_USER_PROTECTED / " +
|
|
1795
|
+
"REVIEW_USER_PROTECTED (the app's preview or store-review account: " +
|
|
1796
|
+
"its email address cannot be changed) or OWN_EMAIL_CHANGE_REFUSED " +
|
|
1797
|
+
"(the signed-in user cannot change their own email here; they use " +
|
|
1798
|
+
"their account screen). Every refusal from EMAIL_TAKEN on, except " +
|
|
1799
|
+
"the SCIM_MANAGED pair, concerns the email only: a rename sent as " +
|
|
1800
|
+
"{ name } alone still succeeds. code NOT_FOUND is an unknown user; " +
|
|
1801
|
+
"code FORBIDDEN a caller without the users.write capability. Email " +
|
|
1802
|
+
"changes are rate-limited per caller: an HTTP 429 arrives as code " +
|
|
1803
|
+
"INTERNAL with status 429 and retryable true, and is never retried " +
|
|
1804
|
+
"automatically, so ask the person to try again later. " +
|
|
1805
|
+
"DirectoryError.reason is the host's specific refusal code, or null " +
|
|
1806
|
+
"when it gave none; branch on it, and show error.message to the person.",
|
|
1779
1807
|
returnShape: {
|
|
1780
1808
|
users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
|
|
1781
1809
|
loading: "boolean",
|
|
1782
|
-
error: "DirectoryError | null // { code, message, retryable }",
|
|
1810
|
+
error: "DirectoryError | null // { code, reason, message, retryable }",
|
|
1783
1811
|
refetch: "() => Promise<void>",
|
|
1784
1812
|
invite:
|
|
1785
1813
|
"({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
|
|
@@ -1788,6 +1816,8 @@ const HOOKS = [
|
|
|
1788
1816
|
remove: "(userId) => Promise<void> // rejects with DirectoryError",
|
|
1789
1817
|
sendPasswordReset:
|
|
1790
1818
|
"(userId) => Promise<{ sent, email_masked }> // rejects with DirectoryError",
|
|
1819
|
+
update:
|
|
1820
|
+
"(userId, { name?, email? }) => Promise<{ id, name, email }> // at least one key; an email change notifies the user at their previous address; rejects with DirectoryError (reason EMAIL_TAKEN, SERVICE_ACCOUNT_PROTECTED, …; status 429 when rate-limited)",
|
|
1791
1821
|
},
|
|
1792
1822
|
requiredContextSlice: ["directory.users"],
|
|
1793
1823
|
scopes: ["users.read:*"],
|
|
@@ -5063,7 +5093,18 @@ const CONTRACT = deepFreeze({
|
|
|
5063
5093
|
// 1.155.0: narrowing (sc-10566) — withdraws 1.144.0: `useUsers().invite`
|
|
5064
5094
|
// takes no `send_invitation` and always emails, invite rows carry no
|
|
5065
5095
|
// `sent_at`, and `not_sent` is no longer a status.
|
|
5066
|
-
|
|
5096
|
+
// 1.156.0: additive (sc-10429) — `useUsers()` gains
|
|
5097
|
+
// `update(userId, { name?, email? })`, gated on `users.write:*`; the
|
|
5098
|
+
// linter requires that scope for a `.update(` call beside `useUsers(`.
|
|
5099
|
+
// `DirectoryError` gains `reason`, the host's specific refusal code.
|
|
5100
|
+
// 1.157.0: additive (sc-10685) — a component gradient takes an optional
|
|
5101
|
+
// `via` list of up to `themeComponentGradient.maxViaStops` middle colours
|
|
5102
|
+
// between `from` and `to`, evenly spaced. `normaliseComponentGradient`
|
|
5103
|
+
// returns it only when present, so a two-colour value normalises exactly
|
|
5104
|
+
// as before; a malformed `via` is dropped, or voids the gradient under the
|
|
5105
|
+
// new `{ strict: true }` option. New `componentGradientColors` / `componentGradientMidpoint`
|
|
5106
|
+
// read the paint list and the ink basis off a normalised gradient.
|
|
5107
|
+
version: "1.157.0",
|
|
5067
5108
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5068
5109
|
hooks: HOOKS,
|
|
5069
5110
|
primitives: PRIMITIVES,
|
|
@@ -5382,6 +5423,10 @@ const GRADIENT_HEX_RE = HEX_RE;
|
|
|
5382
5423
|
|
|
5383
5424
|
/**
|
|
5384
5425
|
* sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
|
|
5426
|
+
* sc-10685: plus `via`, the middle colours, only when the value carries some.
|
|
5427
|
+
* A malformed `via` is dropped and the two ends still paint, so a stored value
|
|
5428
|
+
* never loses its wash over it; `{ strict: true }` voids the gradient instead,
|
|
5429
|
+
* for the boundaries that must refuse the write.
|
|
5385
5430
|
*
|
|
5386
5431
|
* ONE validator for a value arriving by two routes — the theme token and the
|
|
5387
5432
|
* author's never-coerced per-instance `props.style` — so `set_theme` and the
|
|
@@ -5390,7 +5435,7 @@ const GRADIENT_HEX_RE = HEX_RE;
|
|
|
5390
5435
|
* hex grammar is also the injection guard — these stops are interpolated into
|
|
5391
5436
|
* CSS and into generated export source.
|
|
5392
5437
|
*/
|
|
5393
|
-
function normaliseComponentGradient(raw) {
|
|
5438
|
+
function normaliseComponentGradient(raw, options) {
|
|
5394
5439
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
5395
5440
|
const from = typeof raw.from === "string" ? raw.from.trim() : "";
|
|
5396
5441
|
const to = typeof raw.to === "string" ? raw.to.trim() : "";
|
|
@@ -5400,7 +5445,44 @@ function normaliseComponentGradient(raw) {
|
|
|
5400
5445
|
// land NaN in a style.
|
|
5401
5446
|
const angle = raw.angle;
|
|
5402
5447
|
const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
|
|
5403
|
-
|
|
5448
|
+
const via = normaliseGradientVia(raw.via);
|
|
5449
|
+
if (!via && options && options.strict) return null;
|
|
5450
|
+
const gradient = { from, to, angle: ((deg % 360) + 360) % 360 };
|
|
5451
|
+
if (via && via.length > 0) gradient.via = via;
|
|
5452
|
+
return gradient;
|
|
5453
|
+
}
|
|
5454
|
+
|
|
5455
|
+
// All or nothing: one bad middle colour and none of them paint. `null` = malformed.
|
|
5456
|
+
function normaliseGradientVia(raw) {
|
|
5457
|
+
if (raw === undefined || raw === null) return [];
|
|
5458
|
+
if (!Array.isArray(raw) || raw.length > THEME_COMPONENT_GRADIENT.maxViaStops) {
|
|
5459
|
+
return null;
|
|
5460
|
+
}
|
|
5461
|
+
const via = [];
|
|
5462
|
+
for (const item of raw) {
|
|
5463
|
+
const stop = typeof item === "string" ? item.trim() : "";
|
|
5464
|
+
if (!GRADIENT_HEX_RE.test(stop)) return null;
|
|
5465
|
+
via.push(stop);
|
|
5466
|
+
}
|
|
5467
|
+
return via;
|
|
5468
|
+
}
|
|
5469
|
+
|
|
5470
|
+
/**
|
|
5471
|
+
* sc-10685 — a NORMALISED gradient's colours in paint order, evenly spaced:
|
|
5472
|
+
* `[from, ...via, to]`. The one place both hosts read the list from.
|
|
5473
|
+
*/
|
|
5474
|
+
function componentGradientColors(gradient) {
|
|
5475
|
+
return [gradient.from, ...(gradient.via || []), gradient.to];
|
|
5476
|
+
}
|
|
5477
|
+
|
|
5478
|
+
/**
|
|
5479
|
+
* sc-10685 — the colour halfway along a NORMALISED gradient, which is what
|
|
5480
|
+
* text laid across it is contrasted against. Two colours: their mix, as before.
|
|
5481
|
+
*/
|
|
5482
|
+
function componentGradientMidpoint(gradient) {
|
|
5483
|
+
const colors = componentGradientColors(gradient);
|
|
5484
|
+
const middle = (colors.length - 1) / 2;
|
|
5485
|
+
return mixHex(colors[Math.floor(middle)], colors[Math.ceil(middle)], 0.5);
|
|
5404
5486
|
}
|
|
5405
5487
|
|
|
5406
5488
|
// REQ-THEME-LOOK: coerce a theme's spacing scale into the usable band. Shared
|
|
@@ -5443,6 +5525,8 @@ export {
|
|
|
5443
5525
|
INK_CONTRAST,
|
|
5444
5526
|
gradientAngleToVector,
|
|
5445
5527
|
normaliseComponentGradient,
|
|
5528
|
+
componentGradientColors,
|
|
5529
|
+
componentGradientMidpoint,
|
|
5446
5530
|
isThemeColorTokenRef,
|
|
5447
5531
|
parseThemeColorTokenRef,
|
|
5448
5532
|
formatThemeColorTokenRef,
|
package/dist/hooks.js
CHANGED
|
@@ -5394,6 +5394,8 @@ export function useFolderPermissions(folderId, options = {}) {
|
|
|
5394
5394
|
* - "INVITE_ONLY" — 409 from accept-invite-style flows where the tenant
|
|
5395
5395
|
* is invite-only and the email is not on the list.
|
|
5396
5396
|
* - "INTERNAL" — anything else (network, 5xx).
|
|
5397
|
+
*
|
|
5398
|
+
* `reason` is the host's own refusal code ("EMAIL_TAKEN", …) or null.
|
|
5397
5399
|
*/
|
|
5398
5400
|
// sc-4986 — the directory refusals retrying the same call can never clear.
|
|
5399
5401
|
const NON_RETRYABLE_DIRECTORY_CODES = new Set([
|
|
@@ -5415,6 +5417,10 @@ export class DirectoryError extends Error {
|
|
|
5415
5417
|
opts && opts.retryable !== undefined
|
|
5416
5418
|
? Boolean(opts.retryable)
|
|
5417
5419
|
: !NON_RETRYABLE_DIRECTORY_CODES.has(code);
|
|
5420
|
+
this.reason =
|
|
5421
|
+
opts && typeof opts.reason === "string" && opts.reason
|
|
5422
|
+
? opts.reason
|
|
5423
|
+
: null;
|
|
5418
5424
|
if (opts && opts.cause) this.cause = opts.cause;
|
|
5419
5425
|
}
|
|
5420
5426
|
}
|
|
@@ -5440,7 +5446,12 @@ function toDirectoryError(err) {
|
|
|
5440
5446
|
(err && typeof err.message === "string"
|
|
5441
5447
|
? err.message
|
|
5442
5448
|
: "Directory call failed"),
|
|
5443
|
-
{
|
|
5449
|
+
{
|
|
5450
|
+
cause: err,
|
|
5451
|
+
status,
|
|
5452
|
+
retryable: isRetryableDomainStatus(status),
|
|
5453
|
+
reason: envelopeCode,
|
|
5454
|
+
},
|
|
5444
5455
|
);
|
|
5445
5456
|
}
|
|
5446
5457
|
|
|
@@ -5534,9 +5545,9 @@ export function useDirectory(query) {
|
|
|
5534
5545
|
* REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
|
|
5535
5546
|
*
|
|
5536
5547
|
* Returns `{ users, loading, error, refetch, invite, deactivate,
|
|
5537
|
-
* reactivate, remove }`. Reads through the injected
|
|
5548
|
+
* reactivate, remove, sendPasswordReset, update }`. Reads through the injected
|
|
5538
5549
|
* `@colixsystems/directory-client` at `ctx.directory.users.{list, get,
|
|
5539
|
-
* invite, deactivate, reactivate}` — `list` resolves to the `{ data, meta }`
|
|
5550
|
+
* invite, deactivate, reactivate, sendPasswordReset, update}` — `list` resolves to the `{ data, meta }`
|
|
5540
5551
|
* envelope VERBATIM, so we unwrap `res.data` (default `[]`). User rows are
|
|
5541
5552
|
* snake_case (`id`, `name`, `email`, `role`, `is_active`, …) and bodies
|
|
5542
5553
|
* (e.g. `{ email, name, group_ids? }`) pass through verbatim. `query` is an
|
|
@@ -5637,7 +5648,7 @@ export function useUsers(query) {
|
|
|
5637
5648
|
// sc-5335: mail the self-serve reset link to a user who has locked
|
|
5638
5649
|
// themselves out. Resolves `{ sent, email_masked }`; the host never returns
|
|
5639
5650
|
// the token, so this cannot be used to sign in as that user. Rejects with a
|
|
5640
|
-
// DirectoryError carrying `
|
|
5651
|
+
// DirectoryError carrying `reason` USER_INACTIVE or NO_PASSWORD_CREDENTIAL
|
|
5641
5652
|
// when the target cannot receive one.
|
|
5642
5653
|
const sendPasswordReset = useCallback(async (userId) => {
|
|
5643
5654
|
if (typeof usersRef.current.sendPasswordReset !== "function") {
|
|
@@ -5653,6 +5664,19 @@ export function useUsers(query) {
|
|
|
5653
5664
|
throw toDirectoryError(err);
|
|
5654
5665
|
}
|
|
5655
5666
|
}, []);
|
|
5667
|
+
// An email change notifies the user at their previous address.
|
|
5668
|
+
const update = useCallback(async (userId, changes) => {
|
|
5669
|
+
if (typeof usersRef.current.update !== "function") {
|
|
5670
|
+
throw toDirectoryError(
|
|
5671
|
+
new Error("useUsers: this host's directory client predates update"),
|
|
5672
|
+
);
|
|
5673
|
+
}
|
|
5674
|
+
try {
|
|
5675
|
+
return await usersRef.current.update(userId, changes);
|
|
5676
|
+
} catch (err) {
|
|
5677
|
+
throw toDirectoryError(err);
|
|
5678
|
+
}
|
|
5679
|
+
}, []);
|
|
5656
5680
|
|
|
5657
5681
|
return {
|
|
5658
5682
|
users,
|
|
@@ -5664,6 +5688,7 @@ export function useUsers(query) {
|
|
|
5664
5688
|
reactivate,
|
|
5665
5689
|
remove,
|
|
5666
5690
|
sendPasswordReset,
|
|
5691
|
+
update,
|
|
5667
5692
|
};
|
|
5668
5693
|
}
|
|
5669
5694
|
|
package/dist/index.d.ts
CHANGED
|
@@ -521,6 +521,8 @@ export interface DirectoryClient {
|
|
|
521
521
|
invite(body: Record<string, unknown>): Promise<unknown>;
|
|
522
522
|
deactivate(userId: string): Promise<unknown>;
|
|
523
523
|
reactivate(userId: string): Promise<unknown>;
|
|
524
|
+
sendPasswordReset?(userId: string): Promise<unknown>;
|
|
525
|
+
update?(userId: string, body: Record<string, unknown>): Promise<unknown>;
|
|
524
526
|
};
|
|
525
527
|
groups: {
|
|
526
528
|
list(query?: GroupsQuery): Promise<ListEnvelope>;
|
|
@@ -2316,12 +2318,15 @@ export class DirectoryError extends Error {
|
|
|
2316
2318
|
status: number | null;
|
|
2317
2319
|
/** sc-4986 — whether retrying the SAME call could plausibly succeed. */
|
|
2318
2320
|
retryable: boolean;
|
|
2321
|
+
/** The host's specific refusal code (`"EMAIL_TAKEN"`, …); null when none. */
|
|
2322
|
+
reason: string | null;
|
|
2319
2323
|
constructor(
|
|
2320
2324
|
code: DirectoryError["code"],
|
|
2321
2325
|
message: string,
|
|
2322
2326
|
opts?: {
|
|
2323
2327
|
status?: number | null;
|
|
2324
2328
|
retryable?: boolean;
|
|
2329
|
+
reason?: string | null;
|
|
2325
2330
|
cause?: unknown;
|
|
2326
2331
|
},
|
|
2327
2332
|
);
|
|
@@ -2472,6 +2477,17 @@ export interface AppUserInviteRow {
|
|
|
2472
2477
|
invited_by_studio_user_id?: string | null;
|
|
2473
2478
|
}
|
|
2474
2479
|
|
|
2480
|
+
export interface UserProfileChanges {
|
|
2481
|
+
name?: string;
|
|
2482
|
+
email?: string;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
export interface AppUserProfile {
|
|
2486
|
+
id: string;
|
|
2487
|
+
name: string | null;
|
|
2488
|
+
email: string | null;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2475
2491
|
export interface UsersApi {
|
|
2476
2492
|
users: AppUserRow[];
|
|
2477
2493
|
loading: boolean;
|
|
@@ -2481,11 +2497,29 @@ export interface UsersApi {
|
|
|
2481
2497
|
deactivate(userId: string): Promise<AppUserRow>;
|
|
2482
2498
|
reactivate(userId: string): Promise<AppUserRow>;
|
|
2483
2499
|
remove(userId: string): Promise<void>;
|
|
2500
|
+
sendPasswordReset(
|
|
2501
|
+
userId: string,
|
|
2502
|
+
): Promise<{ sent: boolean; email_masked: string }>;
|
|
2503
|
+
/**
|
|
2504
|
+
* Changes a user's name, sign-in email, or both; at least one key is
|
|
2505
|
+
* required. An email change notifies the user at their previous address.
|
|
2506
|
+
* Rejects with a `DirectoryError` whose `reason` names the refusal:
|
|
2507
|
+
* `INVALID_UPDATE`, `INVALID_NAME`, `INVALID_EMAIL`, `EMAIL_TAKEN`,
|
|
2508
|
+
* `EMAIL_RESERVED`, `SCIM_MANAGED_NAME`, `SCIM_MANAGED_EMAIL`,
|
|
2509
|
+
* `SSO_LINKED_EMAIL`, `SERVICE_ACCOUNT_PROTECTED`, `SEED_USER_PROTECTED`,
|
|
2510
|
+
* `REVIEW_USER_PROTECTED`, `OWN_EMAIL_CHANGE_REFUSED`. Email changes are
|
|
2511
|
+
* rate-limited: a 429 rejects with `status: 429`, `code: "INTERNAL"`,
|
|
2512
|
+
* `retryable: true`, and is never retried automatically.
|
|
2513
|
+
*/
|
|
2514
|
+
update(
|
|
2515
|
+
userId: string,
|
|
2516
|
+
changes: UserProfileChanges,
|
|
2517
|
+
): Promise<AppUserProfile>;
|
|
2484
2518
|
}
|
|
2485
2519
|
|
|
2486
2520
|
/**
|
|
2487
2521
|
* AppUser administration. Reads through the injected directory-client at
|
|
2488
|
-
* `ctx.directory.users.{list,get,invite,deactivate,reactivate}`. Reads
|
|
2522
|
+
* `ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}`. Reads
|
|
2489
2523
|
* require the `users.read:*` scope in the manifest; mutations additionally
|
|
2490
2524
|
* require `users.write:*`. Widgets that declare the scopes but whose calling
|
|
2491
2525
|
* APP_USER lacks the corresponding SystemAcl `users.*` capability grant get a
|
|
@@ -3415,18 +3449,30 @@ export interface ComponentGradient {
|
|
|
3415
3449
|
from: string;
|
|
3416
3450
|
to: string;
|
|
3417
3451
|
angle: number;
|
|
3452
|
+
/** Middle colours between `from` and `to`. Present only when there are some. */
|
|
3453
|
+
via?: string[];
|
|
3418
3454
|
}
|
|
3419
3455
|
|
|
3420
3456
|
/**
|
|
3421
3457
|
* Normalise a component `gradient` style value to `{ from, to, angle }`, or null
|
|
3422
3458
|
* when it is unusable. Both stops are required; the angle wraps into 0-359.
|
|
3459
|
+
* An optional `via` list (at most `CONTRACT.themeComponentGradient.maxViaStops`
|
|
3460
|
+
* hex colours) is kept as given. A malformed one is dropped and the two ends
|
|
3461
|
+
* still paint; pass `{ strict: true }` to get null for it instead.
|
|
3423
3462
|
* Shared by the theme-token coercion and the widget render path, so the same
|
|
3424
3463
|
* value cannot resolve two ways.
|
|
3425
3464
|
*/
|
|
3426
3465
|
export function normaliseComponentGradient(
|
|
3427
3466
|
raw: unknown,
|
|
3467
|
+
options?: { strict?: boolean },
|
|
3428
3468
|
): ComponentGradient | null;
|
|
3429
3469
|
|
|
3470
|
+
/** A normalised gradient's colours in paint order: `[from, ...via, to]`. */
|
|
3471
|
+
export function componentGradientColors(gradient: ComponentGradient): string[];
|
|
3472
|
+
|
|
3473
|
+
/** The colour halfway along a normalised gradient: the basis for text ink. */
|
|
3474
|
+
export function componentGradientMidpoint(gradient: ComponentGradient): string;
|
|
3475
|
+
|
|
3430
3476
|
/** sc-8326 — the `theme:<path>` binding prefix, shared with the hosts' resolver. */
|
|
3431
3477
|
export const THEME_TOKEN_REF_PREFIX: string;
|
|
3432
3478
|
|
package/dist/index.js
CHANGED
package/dist/index.native.js
CHANGED
package/dist/linter.cjs
CHANGED
|
@@ -657,6 +657,7 @@ const USER_MUTATION_METHODS = [
|
|
|
657
657
|
// sc-5335 — mailing a reset link is edit-style, so it rides `users.write`
|
|
658
658
|
// alongside deactivate rather than the destructive `users.delete`.
|
|
659
659
|
"sendPasswordReset",
|
|
660
|
+
"update",
|
|
660
661
|
];
|
|
661
662
|
// SC-902 — destructive user removal is gated on the dedicated
|
|
662
663
|
// `users.delete` capability, NOT `users.write`. A widget that calls
|
package/dist/linter.js
CHANGED
|
@@ -587,7 +587,7 @@ function _translationApiRules(source) {
|
|
|
587
587
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation.
|
|
588
588
|
//
|
|
589
589
|
// A widget that calls `useUsers().invite()` / `.deactivate()` /
|
|
590
|
-
// `.reactivate()` / `.sendPasswordReset()` / `.remove()` MUST declare
|
|
590
|
+
// `.reactivate()` / `.sendPasswordReset()` / `.update()` / `.remove()` MUST declare
|
|
591
591
|
// `users.write:*` in its
|
|
592
592
|
// manifest's `requestedScopes`; similarly `useGroups()` mutation methods
|
|
593
593
|
// require `groups.write:*`. The rule is enforced statically so a manifest
|
|
@@ -608,6 +608,7 @@ const USER_MUTATION_METHODS = [
|
|
|
608
608
|
// sc-5335 — mailing a reset link is edit-style, so it rides `users.write`
|
|
609
609
|
// alongside deactivate rather than the destructive `users.delete`.
|
|
610
610
|
"sendPasswordReset",
|
|
611
|
+
"update",
|
|
611
612
|
];
|
|
612
613
|
// SC-902 — destructive user removal is gated on the dedicated
|
|
613
614
|
// `users.delete` capability, NOT `users.write`. A widget that calls
|
package/dist/theme-token-refs.js
CHANGED
|
@@ -148,7 +148,9 @@ function resolveAgainstSchema(schema, value, theme) {
|
|
|
148
148
|
// VALUES — colours, sizes, enums — so an allowlisted `theme:` string in it can
|
|
149
149
|
// only ever have been a binding. The depth cap keeps the walk bounded for the
|
|
150
150
|
// structured style values (a gradient's stops, per-corner radii).
|
|
151
|
-
|
|
151
|
+
// sc-10685: 4, so a widget gradient's `via` list (style > gradient > via >
|
|
152
|
+
// item) resolves like its two ends.
|
|
153
|
+
const MAX_STYLE_DEPTH = 4;
|
|
152
154
|
|
|
153
155
|
function resolveStyleBag(value, theme, depth) {
|
|
154
156
|
if (typeof value === "string") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.196.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
|
@@ -332,6 +332,8 @@ const THEME_COMPONENT_GRADIENT = Object.freeze({
|
|
|
332
332
|
angleMin: 0,
|
|
333
333
|
angleMax: 359,
|
|
334
334
|
defaultAngle: 180,
|
|
335
|
+
// sc-10685: middle colours allowed between the two ends (`via`).
|
|
336
|
+
maxViaStops: 3,
|
|
335
337
|
});
|
|
336
338
|
|
|
337
339
|
// REQ-THEME-LOOK: the app-wide SPACING MULTIPLIER. Layout spacing lives on the
|
|
@@ -1746,7 +1748,7 @@ const HOOKS = [
|
|
|
1746
1748
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
1747
1749
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
1748
1750
|
// Reads need `users.read:*` scope; edit-style mutations (invite /
|
|
1749
|
-
// deactivate / reactivate / sendPasswordReset) additionally need
|
|
1751
|
+
// deactivate / reactivate / sendPasswordReset / update) additionally need
|
|
1750
1752
|
// `users.write:*`, and the
|
|
1751
1753
|
// destructive `remove` needs `users.delete:*` (SC-902). The `invite`
|
|
1752
1754
|
// call accepts `{ email, name, groupIds? }` and returns the resulting
|
|
@@ -1760,26 +1762,52 @@ const HOOKS = [
|
|
|
1760
1762
|
optionalArgs: ["query"],
|
|
1761
1763
|
description:
|
|
1762
1764
|
"AppUser administration via the injected directory-client at " +
|
|
1763
|
-
"ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
|
|
1765
|
+
"ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset,update}. " +
|
|
1764
1766
|
"Returns { users, loading, error, refetch, invite, deactivate, " +
|
|
1765
|
-
"reactivate, remove, sendPasswordReset }. " +
|
|
1767
|
+
"reactivate, remove, sendPasswordReset, update }. " +
|
|
1766
1768
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
1767
1769
|
"res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
|
|
1768
1770
|
"scope; edit-style mutations (invite/deactivate/reactivate/" +
|
|
1769
|
-
"sendPasswordReset) need users.write:*, and the destructive remove() " +
|
|
1771
|
+
"sendPasswordReset/update) need users.write:*, and the destructive remove() " +
|
|
1770
1772
|
"additionally needs users.delete:* (SC-902). The `invite` call accepts " +
|
|
1771
1773
|
"{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
|
|
1772
1774
|
"(the email is sent by the host). sendPasswordReset(userId) mails the " +
|
|
1773
1775
|
"standard self-serve reset link to that user's own registered address " +
|
|
1774
1776
|
"and resolves { sent, email_masked } — never the token or the link, so " +
|
|
1775
1777
|
"it cannot be used to sign in as them. It rejects with a DirectoryError " +
|
|
1776
|
-
"
|
|
1777
|
-
"INTEGRATION service account); surface those as an explanation
|
|
1778
|
-
"than a generic failure."
|
|
1778
|
+
"whose reason is USER_INACTIVE (deactivated) or NO_PASSWORD_CREDENTIAL " +
|
|
1779
|
+
"(an INTEGRATION service account); surface those as an explanation " +
|
|
1780
|
+
"rather than a generic failure. update(userId, { name?, email? }) " +
|
|
1781
|
+
"changes a user's name, email, or both (at least one key) and resolves " +
|
|
1782
|
+
"{ id, name, email }; refetch() to refresh the list. An email change " +
|
|
1783
|
+
"notifies the user at their previous address. users.write:* therefore " +
|
|
1784
|
+
"lets a widget change a member's name and sign-in email: request it " +
|
|
1785
|
+
"only for a surface meant for the people who administer the app. It " +
|
|
1786
|
+
"rejects with a DirectoryError; code is the broad category and reason " +
|
|
1787
|
+
"the specific refusal. code VALIDATION comes with reason " +
|
|
1788
|
+
"INVALID_UPDATE (neither key), INVALID_NAME, INVALID_EMAIL, " +
|
|
1789
|
+
"EMAIL_TAKEN (the address is not available), EMAIL_RESERVED (a " +
|
|
1790
|
+
"reserved address), SCIM_MANAGED_NAME / SCIM_MANAGED_EMAIL (the " +
|
|
1791
|
+
"profile is synced from an identity provider, so it is changed there), " +
|
|
1792
|
+
"SSO_LINKED_EMAIL (the user signs in through an identity provider, so " +
|
|
1793
|
+
"the email is changed there), SERVICE_ACCOUNT_PROTECTED (a service " +
|
|
1794
|
+
"account's email cannot be changed), SEED_USER_PROTECTED / " +
|
|
1795
|
+
"REVIEW_USER_PROTECTED (the app's preview or store-review account: " +
|
|
1796
|
+
"its email address cannot be changed) or OWN_EMAIL_CHANGE_REFUSED " +
|
|
1797
|
+
"(the signed-in user cannot change their own email here; they use " +
|
|
1798
|
+
"their account screen). Every refusal from EMAIL_TAKEN on, except " +
|
|
1799
|
+
"the SCIM_MANAGED pair, concerns the email only: a rename sent as " +
|
|
1800
|
+
"{ name } alone still succeeds. code NOT_FOUND is an unknown user; " +
|
|
1801
|
+
"code FORBIDDEN a caller without the users.write capability. Email " +
|
|
1802
|
+
"changes are rate-limited per caller: an HTTP 429 arrives as code " +
|
|
1803
|
+
"INTERNAL with status 429 and retryable true, and is never retried " +
|
|
1804
|
+
"automatically, so ask the person to try again later. " +
|
|
1805
|
+
"DirectoryError.reason is the host's specific refusal code, or null " +
|
|
1806
|
+
"when it gave none; branch on it, and show error.message to the person.",
|
|
1779
1807
|
returnShape: {
|
|
1780
1808
|
users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
|
|
1781
1809
|
loading: "boolean",
|
|
1782
|
-
error: "DirectoryError | null // { code, message, retryable }",
|
|
1810
|
+
error: "DirectoryError | null // { code, reason, message, retryable }",
|
|
1783
1811
|
refetch: "() => Promise<void>",
|
|
1784
1812
|
invite:
|
|
1785
1813
|
"({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
|
|
@@ -1788,6 +1816,8 @@ const HOOKS = [
|
|
|
1788
1816
|
remove: "(userId) => Promise<void> // rejects with DirectoryError",
|
|
1789
1817
|
sendPasswordReset:
|
|
1790
1818
|
"(userId) => Promise<{ sent, email_masked }> // rejects with DirectoryError",
|
|
1819
|
+
update:
|
|
1820
|
+
"(userId, { name?, email? }) => Promise<{ id, name, email }> // at least one key; an email change notifies the user at their previous address; rejects with DirectoryError (reason EMAIL_TAKEN, SERVICE_ACCOUNT_PROTECTED, …; status 429 when rate-limited)",
|
|
1791
1821
|
},
|
|
1792
1822
|
requiredContextSlice: ["directory.users"],
|
|
1793
1823
|
scopes: ["users.read:*"],
|
|
@@ -5063,7 +5093,18 @@ const CONTRACT = deepFreeze({
|
|
|
5063
5093
|
// 1.155.0: narrowing (sc-10566) — withdraws 1.144.0: `useUsers().invite`
|
|
5064
5094
|
// takes no `send_invitation` and always emails, invite rows carry no
|
|
5065
5095
|
// `sent_at`, and `not_sent` is no longer a status.
|
|
5066
|
-
|
|
5096
|
+
// 1.156.0: additive (sc-10429) — `useUsers()` gains
|
|
5097
|
+
// `update(userId, { name?, email? })`, gated on `users.write:*`; the
|
|
5098
|
+
// linter requires that scope for a `.update(` call beside `useUsers(`.
|
|
5099
|
+
// `DirectoryError` gains `reason`, the host's specific refusal code.
|
|
5100
|
+
// 1.157.0: additive (sc-10685) — a component gradient takes an optional
|
|
5101
|
+
// `via` list of up to `themeComponentGradient.maxViaStops` middle colours
|
|
5102
|
+
// between `from` and `to`, evenly spaced. `normaliseComponentGradient`
|
|
5103
|
+
// returns it only when present, so a two-colour value normalises exactly
|
|
5104
|
+
// as before; a malformed `via` is dropped, or voids the gradient under the
|
|
5105
|
+
// new `{ strict: true }` option. New `componentGradientColors` / `componentGradientMidpoint`
|
|
5106
|
+
// read the paint list and the ink basis off a normalised gradient.
|
|
5107
|
+
version: "1.157.0",
|
|
5067
5108
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5068
5109
|
hooks: HOOKS,
|
|
5069
5110
|
primitives: PRIMITIVES,
|
|
@@ -5382,6 +5423,10 @@ const GRADIENT_HEX_RE = HEX_RE;
|
|
|
5382
5423
|
|
|
5383
5424
|
/**
|
|
5384
5425
|
* sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
|
|
5426
|
+
* sc-10685: plus `via`, the middle colours, only when the value carries some.
|
|
5427
|
+
* A malformed `via` is dropped and the two ends still paint, so a stored value
|
|
5428
|
+
* never loses its wash over it; `{ strict: true }` voids the gradient instead,
|
|
5429
|
+
* for the boundaries that must refuse the write.
|
|
5385
5430
|
*
|
|
5386
5431
|
* ONE validator for a value arriving by two routes — the theme token and the
|
|
5387
5432
|
* author's never-coerced per-instance `props.style` — so `set_theme` and the
|
|
@@ -5390,7 +5435,7 @@ const GRADIENT_HEX_RE = HEX_RE;
|
|
|
5390
5435
|
* hex grammar is also the injection guard — these stops are interpolated into
|
|
5391
5436
|
* CSS and into generated export source.
|
|
5392
5437
|
*/
|
|
5393
|
-
function normaliseComponentGradient(raw) {
|
|
5438
|
+
function normaliseComponentGradient(raw, options) {
|
|
5394
5439
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
5395
5440
|
const from = typeof raw.from === "string" ? raw.from.trim() : "";
|
|
5396
5441
|
const to = typeof raw.to === "string" ? raw.to.trim() : "";
|
|
@@ -5400,7 +5445,44 @@ function normaliseComponentGradient(raw) {
|
|
|
5400
5445
|
// land NaN in a style.
|
|
5401
5446
|
const angle = raw.angle;
|
|
5402
5447
|
const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
|
|
5403
|
-
|
|
5448
|
+
const via = normaliseGradientVia(raw.via);
|
|
5449
|
+
if (!via && options && options.strict) return null;
|
|
5450
|
+
const gradient = { from, to, angle: ((deg % 360) + 360) % 360 };
|
|
5451
|
+
if (via && via.length > 0) gradient.via = via;
|
|
5452
|
+
return gradient;
|
|
5453
|
+
}
|
|
5454
|
+
|
|
5455
|
+
// All or nothing: one bad middle colour and none of them paint. `null` = malformed.
|
|
5456
|
+
function normaliseGradientVia(raw) {
|
|
5457
|
+
if (raw === undefined || raw === null) return [];
|
|
5458
|
+
if (!Array.isArray(raw) || raw.length > THEME_COMPONENT_GRADIENT.maxViaStops) {
|
|
5459
|
+
return null;
|
|
5460
|
+
}
|
|
5461
|
+
const via = [];
|
|
5462
|
+
for (const item of raw) {
|
|
5463
|
+
const stop = typeof item === "string" ? item.trim() : "";
|
|
5464
|
+
if (!GRADIENT_HEX_RE.test(stop)) return null;
|
|
5465
|
+
via.push(stop);
|
|
5466
|
+
}
|
|
5467
|
+
return via;
|
|
5468
|
+
}
|
|
5469
|
+
|
|
5470
|
+
/**
|
|
5471
|
+
* sc-10685 — a NORMALISED gradient's colours in paint order, evenly spaced:
|
|
5472
|
+
* `[from, ...via, to]`. The one place both hosts read the list from.
|
|
5473
|
+
*/
|
|
5474
|
+
function componentGradientColors(gradient) {
|
|
5475
|
+
return [gradient.from, ...(gradient.via || []), gradient.to];
|
|
5476
|
+
}
|
|
5477
|
+
|
|
5478
|
+
/**
|
|
5479
|
+
* sc-10685 — the colour halfway along a NORMALISED gradient, which is what
|
|
5480
|
+
* text laid across it is contrasted against. Two colours: their mix, as before.
|
|
5481
|
+
*/
|
|
5482
|
+
function componentGradientMidpoint(gradient) {
|
|
5483
|
+
const colors = componentGradientColors(gradient);
|
|
5484
|
+
const middle = (colors.length - 1) / 2;
|
|
5485
|
+
return mixHex(colors[Math.floor(middle)], colors[Math.ceil(middle)], 0.5);
|
|
5404
5486
|
}
|
|
5405
5487
|
|
|
5406
5488
|
// REQ-THEME-LOOK: coerce a theme's spacing scale into the usable band. Shared
|
|
@@ -5443,6 +5525,8 @@ export {
|
|
|
5443
5525
|
INK_CONTRAST,
|
|
5444
5526
|
gradientAngleToVector,
|
|
5445
5527
|
normaliseComponentGradient,
|
|
5528
|
+
componentGradientColors,
|
|
5529
|
+
componentGradientMidpoint,
|
|
5446
5530
|
isThemeColorTokenRef,
|
|
5447
5531
|
parseThemeColorTokenRef,
|
|
5448
5532
|
formatThemeColorTokenRef,
|