@stapel/workspaces-react 0.6.0 → 0.8.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 (105) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/api/generated/schema.d.ts +774 -7
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +104 -7
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/workspacesApi.d.ts +55 -1
  7. package/dist/api/workspacesApi.d.ts.map +1 -1
  8. package/dist/api/workspacesApi.js +23 -0
  9. package/dist/api/workspacesApi.js.map +1 -1
  10. package/dist/default/InviteAcceptPage.d.ts +28 -0
  11. package/dist/default/InviteAcceptPage.d.ts.map +1 -0
  12. package/dist/default/InviteAcceptPage.js +141 -0
  13. package/dist/default/InviteAcceptPage.js.map +1 -0
  14. package/dist/default/MembersManager.d.ts +4 -4
  15. package/dist/default/MembersManager.d.ts.map +1 -1
  16. package/dist/default/MembersManager.js +61 -56
  17. package/dist/default/MembersManager.js.map +1 -1
  18. package/dist/default/index.d.ts +2 -0
  19. package/dist/default/index.d.ts.map +1 -1
  20. package/dist/default/index.js +1 -0
  21. package/dist/default/index.js.map +1 -1
  22. package/dist/flows/inviteAcceptFlow.d.ts +155 -0
  23. package/dist/flows/inviteAcceptFlow.d.ts.map +1 -0
  24. package/dist/flows/inviteAcceptFlow.js +126 -0
  25. package/dist/flows/inviteAcceptFlow.js.map +1 -0
  26. package/dist/headless/Can.d.ts +40 -0
  27. package/dist/headless/Can.d.ts.map +1 -0
  28. package/dist/headless/Can.js +30 -0
  29. package/dist/headless/Can.js.map +1 -0
  30. package/dist/headless/InviteAcceptFlow.d.ts +44 -0
  31. package/dist/headless/InviteAcceptFlow.d.ts.map +1 -0
  32. package/dist/headless/InviteAcceptFlow.js +67 -0
  33. package/dist/headless/InviteAcceptFlow.js.map +1 -0
  34. package/dist/headless/RoleSelect.d.ts +41 -0
  35. package/dist/headless/RoleSelect.d.ts.map +1 -0
  36. package/dist/headless/RoleSelect.js +34 -0
  37. package/dist/headless/RoleSelect.js.map +1 -0
  38. package/dist/i18n/generated/errors.gen.d.ts +48 -0
  39. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  40. package/dist/i18n/generated/errors.gen.js +24 -0
  41. package/dist/i18n/generated/errors.gen.js.map +1 -1
  42. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  43. package/dist/i18n/generated/errors.ru.gen.js +8 -0
  44. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  45. package/dist/i18n/keys.d.ts +28 -0
  46. package/dist/i18n/keys.d.ts.map +1 -1
  47. package/dist/i18n/keys.js +64 -0
  48. package/dist/i18n/keys.js.map +1 -1
  49. package/dist/i18n/ru.d.ts.map +1 -1
  50. package/dist/i18n/ru.js +30 -0
  51. package/dist/i18n/ru.js.map +1 -1
  52. package/dist/index.d.ts +17 -4
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +10 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/model/capabilities.d.ts +21 -0
  57. package/dist/model/capabilities.d.ts.map +1 -0
  58. package/dist/model/capabilities.js +32 -0
  59. package/dist/model/capabilities.js.map +1 -0
  60. package/dist/model/emailMask.d.ts +22 -0
  61. package/dist/model/emailMask.d.ts.map +1 -0
  62. package/dist/model/emailMask.js +36 -0
  63. package/dist/model/emailMask.js.map +1 -0
  64. package/dist/model/mutations.d.ts +107 -3
  65. package/dist/model/mutations.d.ts.map +1 -1
  66. package/dist/model/mutations.js +190 -1
  67. package/dist/model/mutations.js.map +1 -1
  68. package/dist/model/queries.d.ts +108 -2
  69. package/dist/model/queries.d.ts.map +1 -1
  70. package/dist/model/queries.js +139 -1
  71. package/dist/model/queries.js.map +1 -1
  72. package/dist/model/queryKeys.d.ts +12 -1
  73. package/dist/model/queryKeys.d.ts.map +1 -1
  74. package/dist/model/queryKeys.js +25 -0
  75. package/dist/model/queryKeys.js.map +1 -1
  76. package/dist/model/stepUp.d.ts +25 -0
  77. package/dist/model/stepUp.d.ts.map +1 -0
  78. package/dist/model/stepUp.js +79 -0
  79. package/dist/model/stepUp.js.map +1 -0
  80. package/llms.txt +49 -15
  81. package/manifest.json +346 -3
  82. package/package.json +5 -5
  83. package/src/analytics/generated/events.json +1 -1
  84. package/src/api/generated/schema.ts +774 -7
  85. package/src/api/types.ts +113 -7
  86. package/src/api/workspacesApi.ts +137 -0
  87. package/src/default/InviteAcceptPage.tsx +373 -0
  88. package/src/default/MembersManager.tsx +155 -145
  89. package/src/default/index.ts +2 -0
  90. package/src/flows/inviteAcceptFlow.ts +289 -0
  91. package/src/headless/Can.tsx +49 -0
  92. package/src/headless/InviteAcceptFlow.tsx +105 -0
  93. package/src/headless/RoleSelect.tsx +58 -0
  94. package/src/i18n/generated/errors.gen.ts +24 -0
  95. package/src/i18n/generated/errors.json +62 -0
  96. package/src/i18n/generated/errors.ru.gen.ts +8 -0
  97. package/src/i18n/keys.ts +68 -0
  98. package/src/i18n/ru.ts +34 -0
  99. package/src/index.ts +54 -1
  100. package/src/model/capabilities.ts +37 -0
  101. package/src/model/emailMask.ts +37 -0
  102. package/src/model/mutations.ts +248 -4
  103. package/src/model/queries.ts +205 -2
  104. package/src/model/queryKeys.ts +45 -1
  105. package/src/model/stepUp.ts +96 -0
package/src/index.ts CHANGED
@@ -25,6 +25,12 @@ export type {
25
25
  FlowError,
26
26
  } from "@stapel/core";
27
27
  export { toFlowError } from "./flows/errors.js";
28
+ export { createInviteAcceptFlow } from "./flows/inviteAcceptFlow.js";
29
+ export type {
30
+ InviteAcceptFlow as InviteAcceptFlowController,
31
+ InviteAcceptFlowDeps,
32
+ InviteAcceptState,
33
+ } from "./flows/inviteAcceptFlow.js";
28
34
  export { WORKSPACES_FLOWS, flowEndpoints } from "./flows/registry.js";
29
35
  export type {
30
36
  WorkspacesFlowId,
@@ -51,7 +57,28 @@ export {
51
57
  useWorkspaces,
52
58
  useWorkspace,
53
59
  useMembers,
60
+ useInvitations,
61
+ useInfiniteInvitations,
62
+ useRoles,
63
+ useInvitationPreview,
64
+ useCapabilities,
65
+ useCapabilityGate,
54
66
  } from "./model/queries.js";
67
+ export type { CapabilitiesResult, CapabilityGate } from "./model/queries.js";
68
+
69
+ // ── model (capability matcher + email-mask ports — backend-synced utils) ─────
70
+ export { capabilityMatches, hasCapability } from "./model/capabilities.js";
71
+ export {
72
+ BUILTIN_CAPABILITY_LEVELS,
73
+ SENSITIVE_SCOPE,
74
+ capabilityLevel,
75
+ readVerificationEnrollment,
76
+ } from "./model/stepUp.js";
77
+ export type {
78
+ CapabilityLevel,
79
+ VerificationEnrollment,
80
+ } from "./model/stepUp.js";
81
+ export { maskEmail, emailMatchesMask } from "./model/emailMask.js";
55
82
 
56
83
  // ── model (write hooks) ──────────────────────────────────────────────────────
57
84
  export {
@@ -62,8 +89,17 @@ export {
62
89
  useUpdateMemberRole,
63
90
  useRemoveMember,
64
91
  useAcceptInvitation,
92
+ useClaimInvitation,
93
+ useDeclineInvitation,
94
+ useRevokeInvitation,
95
+ useResendInvitation,
96
+ useResetMemberPassword,
97
+ useUpdateSecuritySettings,
98
+ } from "./model/mutations.js";
99
+ export type {
100
+ MemberRoleChange,
101
+ MemberPasswordResetVars,
65
102
  } from "./model/mutations.js";
66
- export type { MemberRoleChange } from "./model/mutations.js";
67
103
 
68
104
  // ── api (wire type aliases) ──────────────────────────────────────────────────
69
105
  export type {
@@ -78,7 +114,18 @@ export type {
78
114
  MemberInviteResult,
79
115
  MemberRoleUpdate,
80
116
  Invitation,
117
+ InvitationPage as InvitationListData,
118
+ InvitationsParams,
119
+ InvitationStatusFilter,
81
120
  InvitationAccept,
121
+ InvitationPreview,
122
+ InvitationClaim,
123
+ MemberPasswordReset,
124
+ MemberPasswordResetResult,
125
+ ProvisionedUserPolicy,
126
+ WorkspaceSecuritySettings,
127
+ RoleInfo,
128
+ RoleList,
82
129
  WorkspaceRole,
83
130
  WorkspaceKind,
84
131
  } from "./api/types.js";
@@ -91,6 +138,12 @@ export { Members } from "./headless/Members.js";
91
138
  export type { MembersBag } from "./headless/Members.js";
92
139
  export { AcceptInvitation } from "./headless/AcceptInvitation.js";
93
140
  export type { AcceptInvitationBag } from "./headless/AcceptInvitation.js";
141
+ export { Can } from "./headless/Can.js";
142
+ export type { CanBag } from "./headless/Can.js";
143
+ export { RoleSelect } from "./headless/RoleSelect.js";
144
+ export type { RoleSelectBag } from "./headless/RoleSelect.js";
145
+ export { InviteAcceptFlow } from "./headless/InviteAcceptFlow.js";
146
+ export type { InviteAcceptFlowBag } from "./headless/InviteAcceptFlow.js";
94
147
 
95
148
  // ── i18n ─────────────────────────────────────────────────────────────────────
96
149
  export {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Client-side capability matcher (org-program §A1/§A2) — a PORT of
3
+ * stapel-workspaces' `capabilities.capability_matches` (which is itself
4
+ * mirrored by the consumer helper in `stapel_core.django.workspaces`).
5
+ * KEEP SEMANTICS IN SYNC with the backend: capability strings are namespaced
6
+ * `"<domain>.<action>"`, free-form for the client (`meetings.spotlight`,
7
+ * `records.view`, …); a granted string may be the exact capability, the
8
+ * global wildcard `"*"`, or a prefix wildcard like `"members.*"` (which
9
+ * matches `members.remove` AND deeper `members.role.change`).
10
+ *
11
+ * The client-side check is a UI convenience (hide/disable affordances) —
12
+ * NEVER an access decision: the backend re-checks every capability on every
13
+ * operation (`has_capability` in-service, `workspaces.check_capability`
14
+ * cross-service).
15
+ */
16
+
17
+ /** Match one granted capability string against the requested one. */
18
+ export function capabilityMatches(
19
+ capability: string,
20
+ granted: string
21
+ ): boolean {
22
+ if (granted === "*" || granted === capability) return true;
23
+ if (granted.endsWith(".*")) {
24
+ return capability.startsWith(granted.slice(0, -1));
25
+ }
26
+ return false;
27
+ }
28
+
29
+ /** True if any of `granted` (verbatim registry strings, wildcards included)
30
+ * matches `capability`. Empty/absent grants deny — deny-by-default. */
31
+ export function hasCapability(
32
+ granted: readonly string[] | null | undefined,
33
+ capability: string
34
+ ): boolean {
35
+ if (!granted) return false;
36
+ return granted.some((g) => capabilityMatches(capability, g));
37
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Email masking — a PORT of stapel-workspaces' `views._mask_email` (the
3
+ * function that produces `InvitationPreviewResponse.email_masked`). KEEP IN
4
+ * SYNC with the backend: first character of the local part and of the domain
5
+ * name survive, the TLD stays readable — `m***@e***.com`.
6
+ *
7
+ * Why the client re-implements it: the public invitation preview deliberately
8
+ * carries only the MASKED email (harvest-proof, org-program §B2), yet the
9
+ * §B4 flow must route "session & email match" vs "wrong account" BEFORE
10
+ * attempting the accept. Masking the signed-in account's own (fully known)
11
+ * email with the same algorithm and comparing the two masks is the exact
12
+ * decision the backend would make — without ever exposing the invitee's
13
+ * address. A mask collision (two addresses masking identically) merely shows
14
+ * the accept prompt; the backend's real email-match on accept remains the
15
+ * enforcement point.
16
+ */
17
+
18
+ /** Mask an email exactly like the backend's invitation preview does. */
19
+ export function maskEmail(email: string): string {
20
+ const at = email.indexOf("@");
21
+ const local = at === -1 ? email : email.slice(0, at);
22
+ const domain = at === -1 ? "" : email.slice(at + 1);
23
+ const dot = domain.lastIndexOf(".");
24
+ const maskedLocal = `${local.slice(0, 1) || "*"}***`;
25
+ if (dot !== -1) {
26
+ const domainName = domain.slice(0, dot);
27
+ const tld = domain.slice(dot + 1);
28
+ return `${maskedLocal}@${domainName.slice(0, 1) || "*"}***.${tld}`;
29
+ }
30
+ return `${maskedLocal}@${domain.slice(0, 1) || "*"}***`;
31
+ }
32
+
33
+ /** Whether `email`, masked by the backend's algorithm, equals `emailMasked`
34
+ * (case-insensitive on the email, as the backend matches `iexact`). */
35
+ export function emailMatchesMask(email: string, emailMasked: string): boolean {
36
+ return maskEmail(email.trim().toLowerCase()) === emailMasked.toLowerCase();
37
+ }
@@ -5,13 +5,17 @@ import type {
5
5
  } from "@tanstack/react-query";
6
6
  import type { StapelApiError } from "@stapel/core";
7
7
  import type {
8
+ Invitation,
8
9
  Member,
9
10
  MemberInvite,
10
11
  MemberInviteResult,
12
+ MemberPasswordReset,
13
+ MemberPasswordResetResult,
11
14
  InvitationAccept,
15
+ InvitationClaim,
12
16
  Workspace,
13
17
  WorkspaceCreate,
14
- WorkspaceRole,
18
+ WorkspaceSecuritySettings,
15
19
  WorkspaceUpdate,
16
20
  } from "../api/types.js";
17
21
  import { useWorkspacesApi } from "./context.js";
@@ -123,10 +127,14 @@ export function useInviteMembers(
123
127
  return useMutation(options);
124
128
  }
125
129
 
126
- /** The variable for {@link useUpdateMemberRole}: which member, which new role. */
130
+ /** The variable for {@link useUpdateMemberRole}: which member, which new role.
131
+ * `role` is an open string since stapel-workspaces 0.6.0 (org-program §A1):
132
+ * the effective registry is settings-extensible, so any key of GET /roles is
133
+ * valid — the backend validates against the registry. {@link WorkspaceRole}
134
+ * still names the builtin four for literals. */
127
135
  export interface MemberRoleChange {
128
136
  readonly userId: string;
129
- readonly role: WorkspaceRole;
137
+ readonly role: string;
130
138
  }
131
139
 
132
140
  /**
@@ -171,6 +179,194 @@ export function useRemoveMember(
171
179
  return useMutation(options);
172
180
  }
173
181
 
182
+ /**
183
+ * Withdraw a live invitation (POST /{id}/invitations/{invitationId}/revoke,
184
+ * #109). The variable is the invitation id; the reply is the UPDATED row, so
185
+ * it is written straight into the page cache the table reads — a revoke is
186
+ * the mirror of the invitee's decline and both must stay distinguishable in
187
+ * `status`, which a blind refetch-and-hope would blur. Only a `pending`
188
+ * invitation is revocable: the backend answers
189
+ * `error.400.invitation_already_used` / `_revoked` / `_expired` otherwise
190
+ * (each its own key, not a shrug). Also refreshes the member list — the seat
191
+ * the invitation reserved is freed on commit.
192
+ */
193
+ export function useRevokeInvitation(
194
+ workspaceId: string
195
+ ): UseMutationResult<Invitation, StapelApiError, string> {
196
+ const api = useWorkspacesApi();
197
+ const queryClient = useQueryClient();
198
+ const options: UseMutationOptions<Invitation, StapelApiError, string> = {
199
+ mutationFn: (invitationId) =>
200
+ api.revokeInvitation(workspaceId, invitationId),
201
+ onSuccess: () => {
202
+ void queryClient.invalidateQueries({
203
+ queryKey: workspacesQueryKeys.invitations(workspaceId),
204
+ });
205
+ void queryClient.invalidateQueries({
206
+ queryKey: workspacesQueryKeys.members(workspaceId),
207
+ });
208
+ },
209
+ };
210
+ return useMutation(options);
211
+ }
212
+
213
+ /**
214
+ * Send the invitation email again (POST /{id}/invitations/{invitationId}/resend,
215
+ * #109). The variable is the invitation id.
216
+ *
217
+ * This is NOT an idempotent "poke": the backend rotates the token and
218
+ * restarts the TTL, so every earlier link — including the one already in the
219
+ * invitee's inbox — stops working, and the row's `expires_at` moves. Hence
220
+ * the invalidation: a table still showing the old expiry would be lying about
221
+ * a live credential. An EXPIRED invitation is accepted on purpose (a dead TTL
222
+ * is the commonest reason to resend); reviving it re-reserves a seat, so a
223
+ * plan ceiling can answer 402 here.
224
+ */
225
+ export function useResendInvitation(
226
+ workspaceId: string
227
+ ): UseMutationResult<Invitation, StapelApiError, string> {
228
+ const api = useWorkspacesApi();
229
+ const queryClient = useQueryClient();
230
+ const options: UseMutationOptions<Invitation, StapelApiError, string> = {
231
+ mutationFn: (invitationId) =>
232
+ api.resendInvitation(workspaceId, invitationId),
233
+ onSuccess: () => {
234
+ void queryClient.invalidateQueries({
235
+ queryKey: workspacesQueryKeys.invitations(workspaceId),
236
+ });
237
+ },
238
+ };
239
+ return useMutation(options);
240
+ }
241
+
242
+ /** The variable for {@link useResetMemberPassword}: whose password, and the
243
+ * (optional) body — omit `password` to have the server generate one. */
244
+ export interface MemberPasswordResetVars {
245
+ readonly userId: string;
246
+ readonly body?: MemberPasswordReset;
247
+ }
248
+
249
+ /**
250
+ * Reset a member's password on the organization's order
251
+ * (POST /{id}/members/{userId}/password/reset, #110).
252
+ *
253
+ * **Step-up before the button.** Capability `members.password.reset` is
254
+ * declared `high`, so the backend wraps the endpoint in
255
+ * `requires_verification(scope="sensitive")`. Ask
256
+ * {@link "./queries.js".useCapabilityGate}(workspaceId,
257
+ * "members.password.reset") BEFORE offering the affordance: it answers both
258
+ * "may this caller" and "will a step-up be demanded". A step-up CHALLENGE is
259
+ * then driven by core's client and replayed transparently; the one case that
260
+ * is not transparent is the ENROLLMENT demand (the caller holds no factor to
261
+ * challenge) — read it off `error` with
262
+ * {@link "./stepUp.js".readVerificationEnrollment} and route to enrollment.
263
+ *
264
+ * **The one-shot credential.** `generated_password` comes back exactly once,
265
+ * only when the request omitted `password`, and can never be re-fetched. It
266
+ * is therefore deliberately kept OUT of the query cache: nothing here writes
267
+ * the response with `setQueryData`, and `gcTime: 0` drops the mutation result
268
+ * the moment the screen showing it unmounts. Writing it to a query would
269
+ * mean writing a live credential into every devtools panel AND into
270
+ * `localStorage`, because core's query runtime persists the whole per-user
271
+ * query cache (`createStapelQueryClient`). Hand it to the member out of band.
272
+ *
273
+ * Refreshes the member list (the reset moves the member's first-login state);
274
+ * a target that is not a resettable member of THIS workspace answers one
275
+ * byte-identical 404, never an existence oracle.
276
+ */
277
+ export function useResetMemberPassword(
278
+ workspaceId: string
279
+ ): UseMutationResult<
280
+ MemberPasswordResetResult,
281
+ StapelApiError,
282
+ MemberPasswordResetVars
283
+ > {
284
+ const api = useWorkspacesApi();
285
+ const queryClient = useQueryClient();
286
+ const options: UseMutationOptions<
287
+ MemberPasswordResetResult,
288
+ StapelApiError,
289
+ MemberPasswordResetVars
290
+ > = {
291
+ mutationFn: ({ userId, body }) =>
292
+ api.resetMemberPassword(workspaceId, userId, body),
293
+ // The result carries a live credential: never retained past the screen
294
+ // that displays it, and never persisted.
295
+ gcTime: 0,
296
+ onSuccess: () => {
297
+ void queryClient.invalidateQueries({
298
+ queryKey: workspacesQueryKeys.members(workspaceId),
299
+ });
300
+ },
301
+ };
302
+ return useMutation(options);
303
+ }
304
+
305
+ /**
306
+ * Update a workspace's SECURITY settings (the `security` block of
307
+ * `PATCH /{id}`, org-program §C3) — `require_mfa` and the first-login
308
+ * `provisioned_user_policies`.
309
+ *
310
+ * Separate from {@link useUpdateWorkspace} because the backend treats this
311
+ * block as a different endpoint in all but the URL: an extra capability
312
+ * (`workspace.security.manage`, declared `high`) plus a step-up on the same
313
+ * `sensitive` scope as the password reset, and turning `require_mfa` on
314
+ * sweeps the current members. See {@link useResetMemberPassword} for how to
315
+ * wire that in front of the control.
316
+ *
317
+ * `provisioned_user_policies` is a LIST since stapel-workspaces 0.13.0 (#90)
318
+ * — independent checkboxes, not alternatives. An explicit `[]` is a
319
+ * deliberate, auditable choice ("demand nothing"), so it is sent as such and
320
+ * never dropped as "empty means unset".
321
+ *
322
+ * MERGES, because the backend does not: `PATCH` assigns `settings` wholesale
323
+ * (`ws.settings = data.settings`), so sending a bare `{security: …}` would
324
+ * silently drop every other key the workspace holds. The merge reads the
325
+ * workspace fresh rather than trusting the detail cache — a stale snapshot
326
+ * here would resurrect settings somebody else has since removed.
327
+ */
328
+ export function useUpdateSecuritySettings(
329
+ workspaceId: string
330
+ ): UseMutationResult<Workspace, StapelApiError, WorkspaceSecuritySettings> {
331
+ const api = useWorkspacesApi();
332
+ const queryClient = useQueryClient();
333
+ const options: UseMutationOptions<
334
+ Workspace,
335
+ StapelApiError,
336
+ WorkspaceSecuritySettings
337
+ > = {
338
+ mutationFn: async (patch) => {
339
+ const current = await api.getWorkspace(workspaceId);
340
+ const settings = (current.settings ?? {}) as Record<string, unknown>;
341
+ const security = (settings["security"] ?? {}) as Record<string, unknown>;
342
+ // `policies_configured` is derived and read-only on the wire — never
343
+ // sent back, whatever a caller spread into the patch.
344
+ const { policies_configured: _derived, ...writable } = {
345
+ ...security,
346
+ ...patch,
347
+ };
348
+ return api.updateWorkspace(workspaceId, {
349
+ settings: { ...settings, security: writable },
350
+ });
351
+ },
352
+ onSuccess: (updated) => {
353
+ queryClient.setQueryData(
354
+ workspacesQueryKeys.detail(workspaceId),
355
+ updated
356
+ );
357
+ void queryClient.invalidateQueries({
358
+ queryKey: workspacesQueryKeys.list(),
359
+ });
360
+ // require_mfa ON suspends members without a strong factor; OFF lifts
361
+ // those suspensions. Either way the roster just moved.
362
+ void queryClient.invalidateQueries({
363
+ queryKey: workspacesQueryKeys.members(workspaceId),
364
+ });
365
+ },
366
+ };
367
+ return useMutation(options);
368
+ }
369
+
174
370
  /**
175
371
  * Accept an invitation by its token — the caller joins the workspace. A dead
176
372
  * token surfaces a localizable `StapelApiError` (`error.400.invitation_expired`
@@ -186,10 +382,58 @@ export function useAcceptInvitation(): UseMutationResult<
186
382
  const queryClient = useQueryClient();
187
383
  const options: UseMutationOptions<Member, StapelApiError, InvitationAccept> = {
188
384
  mutationFn: (body) => api.acceptInvitation(body),
189
- onSuccess: () => {
385
+ onSuccess: (_member, body) => {
190
386
  void queryClient.invalidateQueries({
191
387
  queryKey: workspacesQueryKeys.list(),
192
388
  });
389
+ // The preview's `status` moved pending → accepted.
390
+ void queryClient.invalidateQueries({
391
+ queryKey: workspacesQueryKeys.invitationPreview(body.token),
392
+ });
393
+ },
394
+ };
395
+ return useMutation(options);
396
+ }
397
+
398
+ /**
399
+ * Mint a login grant for a NOT-yet-registered invitee (POST
400
+ * /invitations/{token}/claim, AllowAny — org-program §B2/§B3). The variable
401
+ * is the invite token; the result carries the single-use `grant_token` to
402
+ * exchange at AUTH's `POST /grant/exchange/` (the auth-react api — this pair
403
+ * deliberately does not call it; see `InviteAcceptFlow`'s `onLoginGrant`
404
+ * seam). 409 `email_already_registered` → switch to login. The invitation is
405
+ * NOT consumed here. The grant token is a credential: never log it.
406
+ */
407
+ export function useClaimInvitation(): UseMutationResult<
408
+ InvitationClaim,
409
+ StapelApiError,
410
+ string
411
+ > {
412
+ const api = useWorkspacesApi();
413
+ const options: UseMutationOptions<InvitationClaim, StapelApiError, string> = {
414
+ mutationFn: (token) => api.claimInvitation(token),
415
+ };
416
+ return useMutation(options);
417
+ }
418
+
419
+ /**
420
+ * Decline an invitation (POST /invitations/{token}/decline, authenticated +
421
+ * email-match — org-program §B2). The variable is the invite token. Decline ≠
422
+ * revoke — the preview's `status` becomes `declined`.
423
+ */
424
+ export function useDeclineInvitation(): UseMutationResult<
425
+ void,
426
+ StapelApiError,
427
+ string
428
+ > {
429
+ const api = useWorkspacesApi();
430
+ const queryClient = useQueryClient();
431
+ const options: UseMutationOptions<void, StapelApiError, string> = {
432
+ mutationFn: (token) => api.declineInvitation(token),
433
+ onSuccess: (_result, token) => {
434
+ void queryClient.invalidateQueries({
435
+ queryKey: workspacesQueryKeys.invitationPreview(token),
436
+ });
193
437
  },
194
438
  };
195
439
  return useMutation(options);
@@ -1,15 +1,26 @@
1
- import { useQuery } from "@tanstack/react-query";
2
- import type { UseQueryResult } from "@tanstack/react-query";
1
+ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
2
+ import type {
3
+ InfiniteData,
4
+ UseInfiniteQueryResult,
5
+ UseQueryResult,
6
+ } from "@tanstack/react-query";
3
7
  import { useActiveSessionReady } from "@stapel/core";
4
8
  import type { StapelApiError } from "@stapel/core";
5
9
  import type {
10
+ InvitationPage,
11
+ InvitationPreview,
12
+ InvitationsParams,
6
13
  MemberPage,
7
14
  MembersParams,
15
+ RoleInfo,
8
16
  Workspace,
9
17
  WorkspaceList,
10
18
  } from "../api/types.js";
11
19
  import { useWorkspacesApi } from "./context.js";
12
20
  import { workspacesQueryKeys } from "./queryKeys.js";
21
+ import { hasCapability } from "./capabilities.js";
22
+ import { SENSITIVE_SCOPE, capabilityLevel } from "./stepUp.js";
23
+ import type { CapabilityLevel } from "./stepUp.js";
13
24
 
14
25
  /**
15
26
  * Read hooks over the workspaces API. Staleness follows core's query defaults;
@@ -77,3 +88,195 @@ export function useMembers(
77
88
  enabled: sessionReady && workspaceId !== null && workspaceId !== "",
78
89
  });
79
90
  }
91
+
92
+ /**
93
+ * One page of a workspace's invitations (GET /{id}/invitations, #109) — the
94
+ * admin's "who has not accepted yet" table. Disabled until a `workspaceId` is
95
+ * given (and the session is ready — see {@link useWorkspace}).
96
+ *
97
+ * ANCHOR pagination, exactly like {@link useMembers}: pass a page's
98
+ * `next_anchor` as `anchor` to walk forward. There is no page NUMBER — an
99
+ * offset would skew the moment an invitation is revoked or accepted while the
100
+ * admin is reading, which on this screen is the normal case, not the edge one.
101
+ * For scroll-to-load-more use {@link useInfiniteInvitations}.
102
+ *
103
+ * `status` defaults to the backend's `pending` (live, seat-reserving
104
+ * invitations); pass `never_accepted` to include declined/revoked/expired, or
105
+ * `all` for the full history.
106
+ */
107
+ export function useInvitations(
108
+ workspaceId: string | null,
109
+ params?: InvitationsParams
110
+ ): UseQueryResult<InvitationPage, StapelApiError> {
111
+ const api = useWorkspacesApi();
112
+ const sessionReady = useActiveSessionReady();
113
+ const p = params ?? {};
114
+ return useQuery({
115
+ queryKey: workspacesQueryKeys.invitationsPage(workspaceId ?? "", p),
116
+ queryFn: () => api.listInvitations(workspaceId as string, p),
117
+ enabled: sessionReady && workspaceId !== null && workspaceId !== "",
118
+ });
119
+ }
120
+
121
+ /**
122
+ * A workspace's invitations as an infinite (load-more) list. Follows the
123
+ * backend's ANCHOR pagination and nothing else: the page param IS the
124
+ * previous page's `next_anchor`, and the walk stops when `has_next` goes
125
+ * false. `data.pages.flatMap(p => p.items)` is the flat row list.
126
+ *
127
+ * `filters` (`status` / `search` / `limit`) are part of the query key, so
128
+ * flipping the status tab starts a fresh anchor walk rather than appending
129
+ * rows of one filter to the pages of another.
130
+ */
131
+ export function useInfiniteInvitations(
132
+ workspaceId: string | null,
133
+ filters?: Omit<InvitationsParams, "anchor" | "direction">
134
+ ): UseInfiniteQueryResult<
135
+ InfiniteData<InvitationPage, string | undefined>,
136
+ StapelApiError
137
+ > {
138
+ const api = useWorkspacesApi();
139
+ const sessionReady = useActiveSessionReady();
140
+ const f = filters ?? {};
141
+ return useInfiniteQuery({
142
+ queryKey: workspacesQueryKeys.invitationsInfinite(workspaceId ?? "", f),
143
+ queryFn: ({ pageParam }) =>
144
+ api.listInvitations(workspaceId as string, {
145
+ ...f,
146
+ direction: "next",
147
+ ...(pageParam !== undefined ? { anchor: pageParam } : {}),
148
+ }),
149
+ initialPageParam: undefined as string | undefined,
150
+ getNextPageParam: (last) =>
151
+ last.has_next ? (last.next_anchor ?? undefined) : undefined,
152
+ enabled: sessionReady && workspaceId !== null && workspaceId !== "",
153
+ });
154
+ }
155
+
156
+ /**
157
+ * The effective role registry (GET /roles, org-program §A2): builtin four +
158
+ * the deployment's `STAPEL_WORKSPACES["ROLES"]` overlay, capability strings
159
+ * verbatim, rank-descending. Deployment-static data — role UI (RoleSelect)
160
+ * reads this instead of hardcoding the builtin four. Session-ready-gated like
161
+ * {@link useWorkspaces} (IsAuthenticated endpoint, mounts at screen top).
162
+ */
163
+ export function useRoles(): UseQueryResult<
164
+ readonly RoleInfo[],
165
+ StapelApiError
166
+ > {
167
+ const api = useWorkspacesApi();
168
+ const sessionReady = useActiveSessionReady();
169
+ return useQuery({
170
+ queryKey: workspacesQueryKeys.roles(),
171
+ queryFn: async () => (await api.listRoles()).roles ?? [],
172
+ enabled: sessionReady,
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Public invitation preview (GET /invitations/{token}, AllowAny — org-program
178
+ * §B2): what the `/invite/{token}` page renders BEFORE any auth decision.
179
+ * Deliberately NOT session-gated — the whole point is that the invitee may
180
+ * have no session at all; the token in the URL is the bearer secret.
181
+ */
182
+ export function useInvitationPreview(
183
+ token: string | null
184
+ ): UseQueryResult<InvitationPreview, StapelApiError> {
185
+ const api = useWorkspacesApi();
186
+ return useQuery({
187
+ queryKey: workspacesQueryKeys.invitationPreview(token ?? ""),
188
+ queryFn: () => api.getInvitationPreview(token as string),
189
+ enabled: token !== null && token !== "",
190
+ });
191
+ }
192
+
193
+ /** What {@link useCapabilities} returns: the caller's granted capability
194
+ * strings in one workspace plus the wildcard-aware `can()` check. */
195
+ export interface CapabilitiesResult {
196
+ /** Verbatim registry strings of the caller's role (wildcards included);
197
+ * empty while loading or when the caller is not a member. */
198
+ readonly capabilities: readonly string[];
199
+ /** Wildcard-aware check (`*` / `prefix.*` — the backend matcher, ported).
200
+ * UI convenience only: the backend re-checks on every operation. */
201
+ can(capability: string): boolean;
202
+ readonly isLoading: boolean;
203
+ readonly isError: boolean;
204
+ readonly error: StapelApiError | null;
205
+ }
206
+
207
+ /**
208
+ * The caller's capabilities in one workspace (org-program §A2): reads
209
+ * `my_capabilities` off the workspace detail (`WorkspaceResponse`, additive
210
+ * field since stapel-workspaces 0.6.0) and exposes the ported wildcard
211
+ * matcher. Deny-by-default: `can()` is false while loading, on error, or when
212
+ * the backend predates the field.
213
+ */
214
+ export function useCapabilities(workspaceId: string | null): CapabilitiesResult {
215
+ const query = useWorkspace(workspaceId);
216
+ const capabilities = query.data?.my_capabilities ?? [];
217
+ return {
218
+ capabilities,
219
+ can: (capability) => hasCapability(capabilities, capability),
220
+ isLoading: query.isLoading,
221
+ isError: query.isError,
222
+ error: query.error ?? null,
223
+ };
224
+ }
225
+
226
+ /** What {@link useCapabilityGate} answers about ONE capability, BEFORE the
227
+ * operation is attempted. */
228
+ export interface CapabilityGate {
229
+ /** The capability asked about, verbatim. */
230
+ readonly capability: string;
231
+ /** Does the caller's role carry it? Deny-by-default while loading. */
232
+ readonly allowed: boolean;
233
+ /** Its declared step-up level (ported registry — see `./stepUp.js`). */
234
+ readonly level: CapabilityLevel;
235
+ /** True when `level === "high"`: the backend ALSO demands a fresh
236
+ * verification (scope {@link SENSITIVE_SCOPE}) on top of the capability. */
237
+ readonly requiresStepUp: boolean;
238
+ /** The verification scope of that demand, or `null` at `standard`. */
239
+ readonly stepUpScope: string | null;
240
+ readonly isLoading: boolean;
241
+ }
242
+
243
+ /**
244
+ * Ask about one capability BEFORE offering the operation (org-program §A3).
245
+ *
246
+ * Two different answers, both needed in front of the button rather than
247
+ * behind it:
248
+ *
249
+ * * `allowed` — the caller's role carries the mandate. UI convenience only,
250
+ * deny-by-default; the backend re-checks. This is what replaces guessing
251
+ * access from the ROLE NAME: a deployment role like `secretary` can hold
252
+ * `members.invite` and would still fail a `role === "admin"` test forever,
253
+ * no matter what the registry says.
254
+ * * `requiresStepUp` — the operation is declared `high`, so a fresh step-up
255
+ * verification is demanded on top. Knowing this up front is what lets a
256
+ * screen say "you'll be asked to confirm" (or pre-drive enrollment)
257
+ * instead of showing a button that answers 403.
258
+ *
259
+ * Example — the administrative password reset:
260
+ * ```tsx
261
+ * const gate = useCapabilityGate(workspaceId, "members.password.reset");
262
+ * // gate.allowed === false → do not render the button at all
263
+ * // gate.requiresStepUp → render it with the "we will ask you to
264
+ * // confirm" affordance, wired to the app's
265
+ * // verification controller
266
+ * ```
267
+ */
268
+ export function useCapabilityGate(
269
+ workspaceId: string | null,
270
+ capability: string
271
+ ): CapabilityGate {
272
+ const { can, isLoading } = useCapabilities(workspaceId);
273
+ const level = capabilityLevel(capability);
274
+ return {
275
+ capability,
276
+ allowed: can(capability),
277
+ level,
278
+ requiresStepUp: level === "high",
279
+ stepUpScope: level === "high" ? SENSITIVE_SCOPE : null,
280
+ isLoading,
281
+ };
282
+ }