@stapel/workspaces-react 0.13.0 → 0.15.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/api/generated/schema.d.ts +165 -3
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +22 -0
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/workspacesApi.d.ts +5 -1
  7. package/dist/api/workspacesApi.d.ts.map +1 -1
  8. package/dist/api/workspacesApi.js +16 -0
  9. package/dist/api/workspacesApi.js.map +1 -1
  10. package/dist/default/MembersManager.d.ts.map +1 -1
  11. package/dist/default/MembersManager.js +20 -6
  12. package/dist/default/MembersManager.js.map +1 -1
  13. package/dist/default/WorkspaceSettings.d.ts.map +1 -1
  14. package/dist/default/WorkspaceSettings.js +7 -3
  15. package/dist/default/WorkspaceSettings.js.map +1 -1
  16. package/dist/headless/Can.d.ts +8 -0
  17. package/dist/headless/Can.d.ts.map +1 -1
  18. package/dist/headless/Can.js +5 -1
  19. package/dist/headless/Can.js.map +1 -1
  20. package/dist/headless/Members.d.ts +14 -13
  21. package/dist/headless/Members.d.ts.map +1 -1
  22. package/dist/headless/Members.js +4 -12
  23. package/dist/headless/Members.js.map +1 -1
  24. package/dist/headless/RoleSelect.d.ts +15 -9
  25. package/dist/headless/RoleSelect.d.ts.map +1 -1
  26. package/dist/headless/RoleSelect.js +12 -8
  27. package/dist/headless/RoleSelect.js.map +1 -1
  28. package/dist/headless/WorkspaceList.d.ts +25 -10
  29. package/dist/headless/WorkspaceList.d.ts.map +1 -1
  30. package/dist/headless/WorkspaceList.js +14 -5
  31. package/dist/headless/WorkspaceList.js.map +1 -1
  32. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  33. package/dist/i18n/generated/errors.es.gen.js +2 -0
  34. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  35. package/dist/i18n/generated/errors.gen.d.ts +12 -0
  36. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  37. package/dist/i18n/generated/errors.gen.js +6 -0
  38. package/dist/i18n/generated/errors.gen.js.map +1 -1
  39. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  40. package/dist/i18n/generated/errors.ru.gen.js +2 -0
  41. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  42. package/dist/i18n/keys.d.ts +13 -0
  43. package/dist/i18n/keys.d.ts.map +1 -1
  44. package/dist/i18n/keys.js +23 -0
  45. package/dist/i18n/keys.js.map +1 -1
  46. package/dist/i18n/ru.d.ts.map +1 -1
  47. package/dist/i18n/ru.js +7 -0
  48. package/dist/i18n/ru.js.map +1 -1
  49. package/dist/index.d.ts +2 -2
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/model/queries.d.ts +46 -8
  54. package/dist/model/queries.d.ts.map +1 -1
  55. package/dist/model/queries.js +53 -9
  56. package/dist/model/queries.js.map +1 -1
  57. package/dist/model/queryKeys.d.ts +3 -1
  58. package/dist/model/queryKeys.d.ts.map +1 -1
  59. package/dist/model/queryKeys.js +6 -0
  60. package/dist/model/queryKeys.js.map +1 -1
  61. package/dist/model/selection.d.ts +20 -7
  62. package/dist/model/selection.d.ts.map +1 -1
  63. package/dist/model/selection.js +32 -12
  64. package/dist/model/selection.js.map +1 -1
  65. package/llms.txt +6 -3
  66. package/manifest.json +46 -3
  67. package/package.json +2 -2
  68. package/src/analytics/generated/events.json +1 -1
  69. package/src/api/generated/schema.ts +165 -3
  70. package/src/api/types.ts +23 -0
  71. package/src/api/workspacesApi.ts +22 -0
  72. package/src/default/MembersManager.tsx +69 -16
  73. package/src/default/WorkspaceSettings.tsx +41 -5
  74. package/src/headless/Can.tsx +13 -1
  75. package/src/headless/Members.tsx +18 -26
  76. package/src/headless/RoleSelect.tsx +17 -14
  77. package/src/headless/WorkspaceList.tsx +31 -14
  78. package/src/i18n/generated/errors.es.gen.ts +2 -0
  79. package/src/i18n/generated/errors.gen.ts +6 -0
  80. package/src/i18n/generated/errors.json +16 -0
  81. package/src/i18n/generated/errors.ru.gen.ts +2 -0
  82. package/src/i18n/keys.ts +26 -0
  83. package/src/i18n/ru.ts +10 -0
  84. package/src/index.ts +5 -0
  85. package/src/model/queries.ts +77 -16
  86. package/src/model/queryKeys.ts +16 -1
  87. package/src/model/selection.tsx +73 -21
@@ -89,6 +89,43 @@ export interface paths {
89
89
  patch: operations["workspaces_api_v1_partial_update"];
90
90
  trace?: never;
91
91
  };
92
+ "/workspaces/api/v1/{workspace_id}/audit": {
93
+ parameters: {
94
+ query?: never;
95
+ header?: never;
96
+ path?: never;
97
+ cookie?: never;
98
+ };
99
+ /**
100
+ * @description ``GET <workspace_id>/audit`` — the workspace's membership history.
101
+ *
102
+ * THE QUESTION THIS ANSWERS is "who let this person in, who took them out,
103
+ * and when" — which nothing in this module could answer before. Half the
104
+ * transitions the owner listed emit no comm event at all (an invitation
105
+ * created, an invitation accepted, an account born from one), and the ones
106
+ * that do emit are fire-and-forget notifications to other services: nothing
107
+ * keeps them, so there was no record to ask.
108
+ *
109
+ * GATED ON ``members.view``, not on a new capability of its own. An audit of
110
+ * who is in the workspace is the same class of fact as the member list —
111
+ * every role that may see who is in the room may see how they got there. A
112
+ * separate mandate would mean a deployment could grant one without the
113
+ * other, which describes no real product.
114
+ *
115
+ * Read-only by construction: this view has no write method, and the model
116
+ * has no update or delete path.
117
+ *
118
+ * **Permissions:** `IsAuthenticated`
119
+ */
120
+ get: operations["workspaces_api_v1_audit_list"];
121
+ put?: never;
122
+ post?: never;
123
+ delete?: never;
124
+ options?: never;
125
+ head?: never;
126
+ patch?: never;
127
+ trace?: never;
128
+ };
92
129
  "/workspaces/api/v1/{workspace_id}/invitations": {
93
130
  parameters: {
94
131
  query?: never;
@@ -180,9 +217,9 @@ export interface paths {
180
217
  *
181
218
  * Accepts an **expired** invitation on purpose — a dead TTL is the most
182
219
  * common reason to resend — and refuses the three stored terminal states,
183
- * which are decisions rather than delivery failures. The token is
184
- * rotated and the TTL restarts, so the fresh letter carries a fresh link
185
- * and any stale copy of the old one stops working.
220
+ * which are decisions rather than delivery failures. The TTL restarts, so
221
+ * the invitee has the full window again from the letter that just went
222
+ * out.
186
223
  *
187
224
  * Reviving an expired invitation re-reserves a seat, so the plan ceiling
188
225
  * is re-checked here exactly as it is on invite: capability first ("may
@@ -190,6 +227,17 @@ export interface paths {
190
227
  * that is already pending costs no additional seat and is never blocked
191
228
  * by that check.
192
229
  *
230
+ * Then the cooldown ("may we mail this PERSON again yet", 429). It is
231
+ * read here and enforced again inside the service's row lock, the same
232
+ * two-level shape the state check already has: the view answers a number
233
+ * the admin can read, the lock is what actually makes two simultaneous
234
+ * presses send one letter.
235
+ *
236
+ * The token is NOT rotated by default from 0.23 — the invitee's existing
237
+ * link keeps working, because the resend goes to the same mailbox that
238
+ * link is already sitting in. See
239
+ * ``STAPEL_WORKSPACES["INVITATION_ROTATE_TOKEN_ON_RESEND"]``.
240
+ *
193
241
  * **Permissions:** `IsAuthenticated`
194
242
  */
195
243
  post: operations["workspaces_api_v1_invitations_resend_create"];
@@ -739,6 +787,54 @@ export interface paths {
739
787
  export type webhooks = Record<string, never>;
740
788
  export interface components {
741
789
  schemas: {
790
+ /** @description One line of a workspace's membership history. */
791
+ AuditEventResponse: {
792
+ /**
793
+ * Format: uuid
794
+ * @description Audit event UUID
795
+ */
796
+ id: string;
797
+ /**
798
+ * @description What happened — a CLOSED vocabulary (models.AuditAction): invitation_created, invitation_accepted, invitation_revoked, invitation_declined, account_created_by_invitation, member_joined, member_provisioned, member_removed, member_role_changed, member_suspended, member_unsuspended
799
+ * @example member_removed
800
+ */
801
+ action: string;
802
+ /**
803
+ * Format: uuid
804
+ * @description Who performed it; null for a transition nobody performed (a policy sweep)
805
+ * @example 0192a...
806
+ */
807
+ actor_id: string | null;
808
+ /** @description Best-effort name of the actor, resolved like every other name in this module (stapel-profiles first); "" when unknown */
809
+ actor_display_name: string;
810
+ /**
811
+ * Format: uuid
812
+ * @description Whom it happened to; null for an invitation to an address with no account yet
813
+ * @example 0192b...
814
+ */
815
+ subject_id: string | null;
816
+ /** @description Best-effort name of the subject; "" when unknown */
817
+ subject_display_name: string;
818
+ /**
819
+ * @description The invited address, when the row is about an invitation
820
+ * @example new@acme.test
821
+ */
822
+ subject_email: string;
823
+ /**
824
+ * @description Role involved, when the action carries one
825
+ * @example member
826
+ */
827
+ role: string;
828
+ /** @description Action-specific extras — old_role/new_role for a role change, reason for a suspension */
829
+ metadata: {
830
+ [key: string]: unknown;
831
+ };
832
+ /**
833
+ * @description ISO 8601 timestamp
834
+ * @example 2026-08-13T10:00:00Z
835
+ */
836
+ created_at: string;
837
+ };
742
838
  /** @description What the name is after the write — the stored value, not the request. */
743
839
  DisplayNameResponse: {
744
840
  /**
@@ -891,6 +987,17 @@ export interface components {
891
987
  * @example Ada Lovelace
892
988
  */
893
989
  display_name?: string | null;
990
+ /**
991
+ * Format: uuid
992
+ * @description UUID of the admin who withdrew it; null unless revoked, or when that account is gone
993
+ * @example 0192a...
994
+ */
995
+ revoked_by_id?: string | null;
996
+ /**
997
+ * @description ISO 8601 time the invitation was last emailed; null if no letter was ever sent
998
+ * @example 2026-07-24T10:00:00Z
999
+ */
1000
+ last_sent_at?: string | null;
894
1001
  };
895
1002
  /** @description Invite payload. */
896
1003
  MemberInviteRequest: {
@@ -1003,6 +1110,19 @@ export interface components {
1003
1110
  */
1004
1111
  display_name?: string | null;
1005
1112
  };
1113
+ PaginatedAuditEventResponseList: {
1114
+ items: components["schemas"]["AuditEventResponse"][];
1115
+ /** @description Anchor value for next page */
1116
+ next_anchor?: string | null;
1117
+ /** @description Anchor value for previous page */
1118
+ prev_anchor?: string | null;
1119
+ /** @description Whether there are more items after this page */
1120
+ has_next: boolean;
1121
+ /** @description Whether there are items before this page */
1122
+ has_prev: boolean;
1123
+ /** @description Number of items in current page */
1124
+ count: number;
1125
+ };
1006
1126
  PaginatedInvitationResponseList: {
1007
1127
  items: components["schemas"]["InvitationResponse"][];
1008
1128
  /** @description Anchor value for next page */
@@ -1203,6 +1323,11 @@ export interface components {
1203
1323
  * @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
1204
1324
  */
1205
1325
  preferred_workspace_id?: string;
1326
+ /**
1327
+ * @description Whether THIS caller may found a new workspace on this instance (WORKSPACE_CREATE_POLICY, evaluated by services.can_create_workspace). The ANSWER, not the policy name: resolving "instance_owner" client-side would mean re-implementing the instance-owner lookup in every client, and a client that got it wrong would draw a button that 403s — or hide one that should be there. Rides the list because the workspace switcher is the surface that draws "+ New space", and it already fetches this
1328
+ * @example true
1329
+ */
1330
+ can_create_workspace?: boolean;
1206
1331
  };
1207
1332
  /** @description Workspace details. */
1208
1333
  WorkspaceResponse: {
@@ -1266,6 +1391,11 @@ export interface components {
1266
1391
  * ]
1267
1392
  */
1268
1393
  my_capabilities?: string[];
1394
+ /**
1395
+ * @description The owner's display name, best-effort from stapel-profiles (empty when it has none, is not installed, or is unreachable — never invented). Exists because a workspace NAME stopped identifying one: a person can belong to several spaces all called "Personal" — one per company that made them one — and ownership can be handed over, so "Personal" answers nothing on its own. Pickers draw it as the second line under the name. Resolved here rather than in each client, which would otherwise each need a profiles batch of its own for a caption
1396
+ * @example Victor P.
1397
+ */
1398
+ owner_display_name?: string;
1269
1399
  };
1270
1400
  };
1271
1401
  responses: never;
@@ -1388,6 +1518,38 @@ export interface operations {
1388
1518
  };
1389
1519
  };
1390
1520
  };
1521
+ workspaces_api_v1_audit_list: {
1522
+ parameters: {
1523
+ query?: {
1524
+ /** @description Narrow to one action (models.AuditAction). An unknown value matches nothing rather than being ignored — a filter that silently does not apply is worse than an empty page. */
1525
+ action?: string;
1526
+ /** @description Anchor value to paginate from (exclusive) */
1527
+ anchor?: string;
1528
+ /** @description Pagination direction */
1529
+ direction?: "next" | "prev" | "center";
1530
+ /** @description Number of items (default 100, max 500) */
1531
+ limit?: number;
1532
+ /** @description Narrow to one person's history (as the SUBJECT). */
1533
+ user_id?: string;
1534
+ };
1535
+ header?: never;
1536
+ path: {
1537
+ workspace_id: string;
1538
+ };
1539
+ cookie?: never;
1540
+ };
1541
+ requestBody?: never;
1542
+ responses: {
1543
+ 200: {
1544
+ headers: {
1545
+ [name: string]: unknown;
1546
+ };
1547
+ content: {
1548
+ "application/json": components["schemas"]["PaginatedAuditEventResponseList"];
1549
+ };
1550
+ };
1551
+ };
1552
+ };
1391
1553
  workspaces_api_v1_invitations_list: {
1392
1554
  parameters: {
1393
1555
  query?: {
package/src/api/types.ts CHANGED
@@ -33,6 +33,29 @@ export type PreferredWorkspace = Schemas["PreferredWorkspaceRequest"];
33
33
  /** PUT/DELETE /me/preferred-workspace 200 body — the choice after the write
34
34
  * (`""` after a DELETE cleared it). */
35
35
  export type PreferredWorkspaceResult = Schemas["PreferredWorkspaceResponse"];
36
+ /** One line of a workspace's membership history (`GET {id}/audit`).
37
+ *
38
+ * The vocabulary of `action` is CLOSED on the server (stapel-workspaces
39
+ * `models.AuditAction`) — an audit whose action is a free string is a log:
40
+ * nobody can filter it, translate it, or notice that a new lifecycle
41
+ * transition shipped without a line.
42
+ */
43
+ export type AuditEvent = Schemas["AuditEventResponse"];
44
+ /** `GET {id}/audit` 200 body — one anchor-paginated page of history. */
45
+ export type AuditPage = Schemas["PaginatedAuditEventResponseList"];
46
+ /** Query parameters for the audit list (narrow by action or by subject). */
47
+ export interface AuditParams {
48
+ /** One `AuditEvent["action"]`. An unknown value matches nothing rather than
49
+ * being ignored — a filter that silently does not apply is worse than an
50
+ * empty page. */
51
+ readonly action?: string;
52
+ /** One person's history, as the SUBJECT of the events. */
53
+ readonly user_id?: string;
54
+ readonly anchor?: string;
55
+ readonly direction?: "next" | "prev";
56
+ readonly limit?: number;
57
+ }
58
+
36
59
  /** POST / request body — create a workspace (slug auto-generated when omitted). */
37
60
  export type WorkspaceCreate = Schemas["WorkspaceCreateRequest"];
38
61
  /** PATCH /{id} request body — a partial name / slug / settings update. */
@@ -12,6 +12,8 @@ import type {
12
12
  MemberPasswordReset,
13
13
  MemberPasswordResetResult,
14
14
  MemberRoleUpdate,
15
+ AuditParams,
16
+ AuditPage,
15
17
  MembersParams,
16
18
  PreferredWorkspace,
17
19
  PreferredWorkspaceResult,
@@ -88,6 +90,14 @@ export interface WorkspacesApi {
88
90
  clearPreferredWorkspace(): Promise<PreferredWorkspaceResult>;
89
91
 
90
92
  /** A workspace's members (viewer+), an anchor-paginated page. */
93
+ /** The workspace's membership history, newest first — who let this person
94
+ * in, who took them out, and when (stapel-workspaces 0.24). Anchor-paginated
95
+ * like the member and invitation lists. */
96
+ listAudit(
97
+ workspaceId: string,
98
+ params?: AuditParams
99
+ ): Promise<AuditPage>;
100
+
91
101
  listMembers(
92
102
  workspaceId: string,
93
103
  params?: MembersParams
@@ -259,6 +269,18 @@ export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
259
269
  clearPreferredWorkspace: () =>
260
270
  client.delete("/me/preferred-workspace", mutating()),
261
271
 
272
+ listAudit: (workspaceId, params) => {
273
+ const query: Record<string, string | number> = {};
274
+ if (params?.action !== undefined) query.action = params.action;
275
+ if (params?.user_id !== undefined) query.user_id = params.user_id;
276
+ if (params?.anchor !== undefined) query.anchor = params.anchor;
277
+ if (params?.direction !== undefined) query.direction = params.direction;
278
+ if (params?.limit !== undefined) query.limit = params.limit;
279
+ return client.get(`/${encodeURIComponent(workspaceId)}/audit`, {
280
+ query,
281
+ });
282
+ },
283
+
262
284
  listMembers: (workspaceId, params) => {
263
285
  const query: Record<string, string | number> = {};
264
286
  if (params?.anchor !== undefined) query.anchor = params.anchor;
@@ -15,9 +15,9 @@
15
15
  */
16
16
  import { useState } from "react";
17
17
  import type { ReactElement } from "react";
18
- import { Button, Card, Input, Modal, Popconfirm, Select, Table, Typography } from "antd";
18
+ import { Button, Card, Empty, Input, Modal, Popconfirm, Select, Table, Typography } from "antd";
19
19
  import type { TableProps } from "antd";
20
- import { useErrorDisplay, useT } from "@stapel/core";
20
+ import { loadedRowsOrEmpty, matchList, useErrorDisplay, useT } from "@stapel/core";
21
21
  import { Members } from "../headless/Members.js";
22
22
  import { RoleSelect } from "../headless/RoleSelect.js";
23
23
  import type { RoleSelectBag } from "../headless/RoleSelect.js";
@@ -54,9 +54,15 @@ export function MembersManager(props: MembersManagerProps): ReactElement {
54
54
  <RoleSelect>
55
55
  {(rolesBag: RoleSelectBag) => (
56
56
  <Members workspaceId={props.workspaceId}>
57
- {({ members, isLoading, isError, error, invite, isInviting, updateRole, remove }) => {
57
+ {({ state, writeError, invite, isInviting, updateRole, remove, refetch }) => {
58
58
  const { labelFor } = rolesBag;
59
- const roleOptions = rolesBag.roles.map((r) => ({
59
+ // The registry read is a SEPARATE load from the roster's, and it
60
+ // gets its own sentence below rather than silently producing an
61
+ // empty picker — a role menu with nothing in it and a role menu
62
+ // that could not be fetched look identical to a person.
63
+ const rolesFailed = rolesBag.state.status === "failed";
64
+ const rolesLoading = rolesBag.state.status === "loading";
65
+ const roleOptions = loadedRowsOrEmpty(rolesBag.state).map((r) => ({
60
66
  value: r.role,
61
67
  label: labelFor(r.role),
62
68
  }));
@@ -95,7 +101,7 @@ export function MembersManager(props: MembersManagerProps): ReactElement {
95
101
  <Select<string>
96
102
  value={member.role}
97
103
  style={{ width: 160 }}
98
- loading={rolesBag.isLoading}
104
+ loading={rolesLoading}
99
105
  onChange={(next) => updateRole({ userId: member.user_id, role: next })}
100
106
  // A member's CURRENT role may be missing from the
101
107
  // registry options (a deployment removed an overlay
@@ -148,17 +154,64 @@ export function MembersManager(props: MembersManagerProps): ReactElement {
148
154
  )}
149
155
  </div>
150
156
 
151
- {isError && <ErrorAlert error={errorDisplay(error)} style={{ marginTop: 12 }} />}
157
+ {/* A write that failed — the roster read has its own arm below. */}
158
+ {writeError !== null && (
159
+ <ErrorAlert error={errorDisplay(writeError)} style={{ marginTop: 12 }} />
160
+ )}
161
+ {rolesFailed && (
162
+ <ErrorAlert
163
+ testId="members-roles-error"
164
+ error={{ message: t(WORKSPACES_I18N_KEYS.rolesLoadFailed), detail: undefined }}
165
+ style={{ marginTop: 12 }}
166
+ />
167
+ )}
152
168
 
153
- <Table<Member>
154
- style={{ marginTop: 16 }}
155
- size="small"
156
- loading={isLoading}
157
- rowKey={(member) => member.id}
158
- dataSource={members as Member[]}
159
- columns={columns}
160
- pagination={false}
161
- />
169
+ {matchList(state, {
170
+ loading: () => (
171
+ <Table<Member>
172
+ style={{ marginTop: 16 }}
173
+ size="small"
174
+ loading
175
+ rowKey={(member) => member.id}
176
+ dataSource={[]}
177
+ columns={columns}
178
+ pagination={false}
179
+ />
180
+ ),
181
+ // An antd Table with no rows renders "No data". On a failed
182
+ // read that is the empty-state lie with a built-in
183
+ // illustration, so the table does not get rendered at all.
184
+ failed: (error) => (
185
+ <div style={{ marginTop: 16 }} data-testid="members-list-error">
186
+ <ErrorAlert error={errorDisplay(error)} />
187
+ <Button
188
+ style={{ marginTop: 12 }}
189
+ onClick={refetch}
190
+ data-analytics="none"
191
+ data-analytics-reason="local-ui-refetch-after-a-stated-read-failure"
192
+ >
193
+ {t(WORKSPACES_I18N_KEYS.retry)}
194
+ </Button>
195
+ </div>
196
+ ),
197
+ empty: () => (
198
+ <Empty
199
+ style={{ marginTop: 16 }}
200
+ data-testid="members-list-empty"
201
+ description={t(WORKSPACES_I18N_KEYS.membersEmpty)}
202
+ />
203
+ ),
204
+ ready: (members) => (
205
+ <Table<Member>
206
+ style={{ marginTop: 16 }}
207
+ size="small"
208
+ rowKey={(member) => member.id}
209
+ dataSource={[...members]}
210
+ columns={columns}
211
+ pagination={false}
212
+ />
213
+ ),
214
+ })}
162
215
 
163
216
  <Modal
164
217
  title={t(WORKSPACES_I18N_KEYS.membersInviteDialogTitle)}
@@ -182,7 +235,7 @@ export function MembersManager(props: MembersManagerProps): ReactElement {
182
235
  value={inviteRole}
183
236
  onChange={setInviteRole}
184
237
  style={{ width: "100%" }}
185
- loading={rolesBag.isLoading}
238
+ loading={rolesLoading}
186
239
  options={inviteRoleOptions}
187
240
  />
188
241
  </div>
@@ -9,7 +9,14 @@
9
9
  import { useEffect, useState } from "react";
10
10
  import type { ReactElement } from "react";
11
11
  import { Badge, Button, Card, Input, Popconfirm, Spin, Typography } from "antd";
12
- import { useErrorDisplay, useT } from "@stapel/core";
12
+ import {
13
+ actionAvailable,
14
+ actionBlocked,
15
+ firstBlock,
16
+ useActionGate,
17
+ useErrorDisplay,
18
+ useT,
19
+ } from "@stapel/core";
13
20
  import { useWorkspace } from "../model/queries.js";
14
21
  import { useUpdateWorkspace, useDeleteWorkspace } from "../model/mutations.js";
15
22
  import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
@@ -35,6 +42,17 @@ export function WorkspaceSettings(props: WorkspaceSettingsProps): ReactElement {
35
42
  const workspace = query.data;
36
43
  const [name, setName] = useState("");
37
44
 
45
+ // Two unrelated situations used to share one `disabled={!isOwner ||
46
+ // !name.trim()}` bit, and a person facing either of them saw the same dead
47
+ // button. `firstBlock` keeps them two, in the order you would explain them.
48
+ const isOwner = workspace?.my_role === "owner";
49
+ const saveGate = useActionGate(
50
+ firstBlock(
51
+ isOwner ? actionAvailable() : actionBlocked(WORKSPACES_I18N_KEYS.blockedNotOwner),
52
+ name.trim() ? actionAvailable() : actionBlocked(WORKSPACES_I18N_KEYS.blockedNameRequired)
53
+ )
54
+ );
55
+
38
56
  useEffect(() => {
39
57
  if (workspace) setName(workspace.name);
40
58
  }, [workspace]);
@@ -66,8 +84,6 @@ export function WorkspaceSettings(props: WorkspaceSettingsProps): ReactElement {
66
84
  );
67
85
  }
68
86
 
69
- const isOwner = workspace.my_role === "owner";
70
-
71
87
  return (
72
88
  <div data-testid="workspace-settings" style={{ display: "grid", gap: 16 }}>
73
89
  <Card>
@@ -79,7 +95,18 @@ export function WorkspaceSettings(props: WorkspaceSettingsProps): ReactElement {
79
95
  <div style={{ display: "grid", gap: 12, maxWidth: 480, marginTop: 16 }}>
80
96
  <div>
81
97
  <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldName)}</Typography.Text>
82
- <Input value={name} onChange={(e) => setName(e.target.value)} disabled={!isOwner} />
98
+ <Input
99
+ value={name}
100
+ onChange={(e) => setName(e.target.value)}
101
+ disabled={!isOwner}
102
+ />
103
+ {!isOwner && (
104
+ <div>
105
+ <Typography.Text type="secondary" data-testid="workspace-name-blocked">
106
+ {t(WORKSPACES_I18N_KEYS.blockedNotOwner)}
107
+ </Typography.Text>
108
+ </div>
109
+ )}
83
110
  </div>
84
111
  <div>
85
112
  <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldSlug)}</Typography.Text>
@@ -101,13 +128,22 @@ export function WorkspaceSettings(props: WorkspaceSettingsProps): ReactElement {
101
128
  type="primary"
102
129
  style={{ marginTop: 16 }}
103
130
  loading={updateMutation.isPending}
104
- disabled={!isOwner || !name.trim()}
131
+ disabled={saveGate.disabled}
105
132
  onClick={handleSave}
106
133
  data-analytics="none"
107
134
  data-analytics-reason="business action — host app wraps with its own tracked(); pairs carry no @stapel/analytics runtime dependency by architecture"
108
135
  >
109
136
  {updateMutation.isPending ? t(WORKSPACES_I18N_KEYS.saving) : t(WORKSPACES_I18N_KEYS.save)}
110
137
  </Button>
138
+ {/* Beside the control, as TEXT: a disabled button receives no pointer
139
+ events, so a tooltip on it is a reason nobody can read. */}
140
+ {saveGate.reason !== undefined && (
141
+ <div style={{ marginTop: 8 }}>
142
+ <Typography.Text type="secondary" data-testid="workspace-save-blocked">
143
+ {saveGate.reason}
144
+ </Typography.Text>
145
+ </div>
146
+ )}
111
147
  </Card>
112
148
 
113
149
  {isOwner && (
@@ -8,6 +8,14 @@ export interface CanBag {
8
8
  /** The underlying read is still in flight (verdict is `false` meanwhile —
9
9
  * deny-by-default; render a skeleton off this if flicker matters). */
10
10
  readonly isLoading: boolean;
11
+ /**
12
+ * The read FAILED, so `allowed: false` means "we could not find out",
13
+ * not "you may not". Deny-by-default still holds — a capability check that
14
+ * opened up on an outage would be a security hole — but a skin with
15
+ * somewhere to put a sentence should say which of the two it is rather than
16
+ * silently withholding the control.
17
+ */
18
+ readonly isUnknown: boolean;
11
19
  }
12
20
 
13
21
  /**
@@ -43,7 +51,11 @@ export function Can(props: {
43
51
  const caps = useCapabilities(props.workspaceId);
44
52
  const allowed = caps.can(props.capability);
45
53
  if (typeof props.children === "function") {
46
- return props.children({ allowed, isLoading: caps.isLoading });
54
+ return props.children({
55
+ allowed,
56
+ isLoading: caps.state.status === "loading",
57
+ isUnknown: caps.state.status === "failed",
58
+ });
47
59
  }
48
60
  return allowed ? props.children : (props.fallback ?? null);
49
61
  }
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
- import type { StapelApiError } from "@stapel/core";
2
+ import { loadStateFromQuery, mapLoad } from "@stapel/core";
3
+ import type { LoadState, StapelApiError } from "@stapel/core";
3
4
  import type { Member, MemberInvite } from "../api/types.js";
4
5
  import { useMembers } from "../model/queries.js";
5
6
  import {
@@ -12,18 +13,19 @@ import type { MemberRoleChange } from "../model/mutations.js";
12
13
  /** Render-prop bag for {@link Members}. */
13
14
  export interface MembersBag {
14
15
  /**
15
- * The workspace's members once loaded (empty before load). This is one page
16
- * (default 100) — the roster's own {@link Member.email}/`search` narrows
17
- * further; a full pager is out of scope for this renderless view (bring
18
- * your own via `useMembers`' `params` for anchor pagination).
16
+ * The roster read, as a state a skin cannot flatten — render it with core's
17
+ * `matchList`. This is one page (default 100); a full pager is out of scope
18
+ * for this renderless view (bring your own via `useMembers`' `params` for
19
+ * anchor pagination).
20
+ *
21
+ * Separate from {@link writeError} on purpose: "the roster could not be
22
+ * loaded" and "your invite was rejected" are different sentences, and the
23
+ * old merged `isError` could only produce one of them.
19
24
  */
20
- readonly members: readonly Member[];
21
- /** The initial member-list load is in flight. */
22
- readonly isLoading: boolean;
23
- /** The read, an invite, a role change, or a removal failed. */
24
- readonly isError: boolean;
25
- /** The error, when `isError` (a localizable `StapelApiError`), else null. */
26
- readonly error: StapelApiError | null;
25
+ readonly state: LoadState<readonly Member[]>;
26
+ /** An invite, a role change, or a removal failed. Never the read — that is
27
+ * {@link state}. */
28
+ readonly writeError: StapelApiError | null;
27
29
  /** Invite one or more emails at a role. */
28
30
  invite(body: MemberInvite): void;
29
31
  /** An invite call is in flight. */
@@ -49,7 +51,7 @@ export interface MembersBag {
49
51
  *
50
52
  * ```tsx
51
53
  * <Members workspaceId={id}>
52
- * {({ members, invite, updateRole, remove }) => ( ... )}
54
+ * {({ state, invite, updateRole, remove }) => ( ... )}
53
55
  * </Members>
54
56
  * ```
55
57
  */
@@ -62,19 +64,9 @@ export function Members(props: {
62
64
  const roleMutation = useUpdateMemberRole(props.workspaceId);
63
65
  const removeMutation = useRemoveMember(props.workspaceId);
64
66
  return props.children({
65
- members: query.data?.items ?? [],
66
- isLoading: query.isLoading,
67
- isError:
68
- query.isError ||
69
- inviteMutation.isError ||
70
- roleMutation.isError ||
71
- removeMutation.isError,
72
- error:
73
- query.error ??
74
- inviteMutation.error ??
75
- roleMutation.error ??
76
- removeMutation.error ??
77
- null,
67
+ state: mapLoad(loadStateFromQuery(query), (page) => page.items),
68
+ writeError:
69
+ inviteMutation.error ?? roleMutation.error ?? removeMutation.error ?? null,
78
70
  invite: (body) => {
79
71
  inviteMutation.mutate(body);
80
72
  },