@stapel/workspaces-react 0.7.0 → 0.9.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 (53) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/api/generated/schema.d.ts +497 -5
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +91 -2
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/workspacesApi.d.ts +35 -1
  7. package/dist/api/workspacesApi.d.ts.map +1 -1
  8. package/dist/api/workspacesApi.js +20 -0
  9. package/dist/api/workspacesApi.js.map +1 -1
  10. package/dist/default/MembersManager.js +2 -2
  11. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  12. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  13. package/dist/i18n/generated/errors.gen.js +3 -0
  14. package/dist/i18n/generated/errors.gen.js.map +1 -1
  15. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  16. package/dist/i18n/generated/errors.ru.gen.js +1 -0
  17. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  18. package/dist/index.d.ts +7 -5
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/model/mutations.d.ts +86 -2
  23. package/dist/model/mutations.d.ts.map +1 -1
  24. package/dist/model/mutations.js +152 -1
  25. package/dist/model/mutations.js.map +1 -1
  26. package/dist/model/queries.d.ts +90 -2
  27. package/dist/model/queries.d.ts.map +1 -1
  28. package/dist/model/queries.js +115 -1
  29. package/dist/model/queries.js.map +1 -1
  30. package/dist/model/queryKeys.d.ts +11 -1
  31. package/dist/model/queryKeys.d.ts.map +1 -1
  32. package/dist/model/queryKeys.js +21 -1
  33. package/dist/model/queryKeys.js.map +1 -1
  34. package/dist/model/stepUp.d.ts +25 -0
  35. package/dist/model/stepUp.d.ts.map +1 -0
  36. package/dist/model/stepUp.js +79 -0
  37. package/dist/model/stepUp.js.map +1 -0
  38. package/llms.txt +15 -3
  39. package/manifest.json +147 -2
  40. package/package.json +4 -4
  41. package/src/analytics/generated/events.json +1 -1
  42. package/src/api/generated/schema.ts +497 -5
  43. package/src/api/types.ts +101 -2
  44. package/src/api/workspacesApi.ts +93 -0
  45. package/src/default/MembersManager.tsx +2 -2
  46. package/src/i18n/generated/errors.gen.ts +3 -0
  47. package/src/i18n/generated/errors.json +9 -0
  48. package/src/i18n/generated/errors.ru.gen.ts +1 -0
  49. package/src/index.ts +30 -2
  50. package/src/model/mutations.ts +193 -1
  51. package/src/model/queries.ts +162 -2
  52. package/src/model/queryKeys.ts +38 -2
  53. package/src/model/stepUp.ts +96 -0
package/src/api/types.ts CHANGED
@@ -9,11 +9,15 @@
9
9
  * openapi-typescript under-describe the runtime, apply a small documented
10
10
  * correction (see auth-react `api/types.ts` for the three canonical patterns).
11
11
  */
12
- import type { components } from "./generated/schema.js";
12
+ import type { components, operations } from "./generated/schema.js";
13
13
 
14
14
  /** The generated schema table — the one source of truth for wire shapes. */
15
15
  export type Schemas = components["schemas"];
16
16
 
17
+ /** The generated operation table — the source of truth for QUERY parameters
18
+ * (drf-spectacular declares them per-operation, not as a component schema). */
19
+ export type Operations = operations;
20
+
17
21
  // ── aliases (the stapel-workspaces schemas this pair uses) ────────────────────
18
22
 
19
23
  /** One workspace — identity, storage, my role, member count (GET/POST/PATCH). */
@@ -34,8 +38,21 @@ export type MemberInvite = Schemas["MemberInviteRequest"];
34
38
  export type MemberInviteResult = Schemas["MemberInviteResponse"];
35
39
  /** PATCH /{id}/members/{userId} request body — the new role. */
36
40
  export type MemberRoleUpdate = Schemas["PatchedMemberUpdateRequest"];
37
- /** One pending/accepted invitation (a row of {@link MemberInviteResult}). */
41
+ /** One pending/accepted invitation (a row of {@link MemberInviteResult}, and
42
+ * a row of the admin's invitation table — GET /{id}/invitations). The invite
43
+ * TOKEN is deliberately absent from this shape: it is a bearer credential
44
+ * that only ever leaves the backend inside the invitation email. */
38
45
  export type Invitation = Schemas["InvitationResponse"];
46
+ /** GET /{id}/invitations 200 body — an anchor-paginated page of {@link Invitation}s. */
47
+ export type InvitationPage = Schemas["PaginatedInvitationResponseList"];
48
+ /** POST /{id}/members/{userId}/password/reset request body — an optional
49
+ * admin-chosen password, the first-login demands to raise, and an audit note. */
50
+ export type MemberPasswordReset = Schemas["MemberPasswordResetRequest"];
51
+ /** POST /{id}/members/{userId}/password/reset 200 body. `generated_password`
52
+ * is a ONE-SHOT credential (present only when the request omitted `password`)
53
+ * — see {@link "../model/mutations.js".useResetMemberPassword}, which keeps it
54
+ * out of the query cache. */
55
+ export type MemberPasswordResetResult = Schemas["MemberPasswordResetResponse"];
39
56
  /** POST /invitations/accept request body — the token from the email link. */
40
57
  export type InvitationAccept = Schemas["InvitationAcceptRequest"];
41
58
  /** GET /invitations/{token} 200 body — the public (AllowAny) preview the
@@ -51,6 +68,14 @@ export type RoleInfo = Schemas["RoleResponse"];
51
68
  /** GET /roles 200 body — the effective role registry, rank-descending. */
52
69
  export type RoleList = Schemas["RoleListResponse"];
53
70
 
71
+ /**
72
+ * Deployment shape: what a person landing "off the street" gets, and
73
+ * whether self-serve signup is open. A property of the INSTANCE, not of a
74
+ * workspace — and not a setting the customer can toggle: it's fixed at
75
+ * deployment time.
76
+ */
77
+ export type InstanceShape = Schemas["InstanceShapeResponse"];
78
+
54
79
  // ── documented corrections (drf-spectacular under-describes) ──────────────────
55
80
 
56
81
  /**
@@ -88,3 +113,77 @@ export interface MembersParams {
88
113
  /** Case-insensitive substring filter matched against a member's email or display name. */
89
114
  readonly search?: string;
90
115
  }
116
+
117
+ /**
118
+ * Which invitations GET /{id}/invitations returns. Taken VERBATIM from the
119
+ * generated operation table (not re-typed by hand): `pending` (the backend
120
+ * default) — live, seat-reserving invitations, i.e. who has not accepted yet;
121
+ * `never_accepted` — those plus declined / revoked / expired; `all` — the full
122
+ * history, accepted rows included.
123
+ */
124
+ export type InvitationStatusFilter = NonNullable<
125
+ NonNullable<
126
+ Operations["workspaces_api_v1_invitations_list"]["parameters"]["query"]
127
+ >["status"]
128
+ >;
129
+
130
+ /**
131
+ * Anchor-pagination query for GET /{id}/invitations (core `AnchorPagination`,
132
+ * the same shape as {@link MembersParams} plus the `status` filter). All
133
+ * optional: no params fetches the newest page of PENDING invitations
134
+ * (default limit 100, max 500).
135
+ *
136
+ * ANCHOR, not page numbers: a page is addressed by the opaque `next_anchor`
137
+ * of the page before it. There is no "page 3" to jump to and no offset to
138
+ * skew when an invitation is revoked mid-scroll.
139
+ */
140
+ export interface InvitationsParams {
141
+ /** Anchor value to paginate from (exclusive) — a page's `next_anchor`. */
142
+ readonly anchor?: string;
143
+ /** Pagination direction relative to `anchor`. */
144
+ readonly direction?: "next" | "prev" | "center";
145
+ /** Page size (default 100, max 500). */
146
+ readonly limit?: number;
147
+ /** Case-insensitive substring filter on the invited email address. */
148
+ readonly search?: string;
149
+ /** Which invitations to return (default `pending`). */
150
+ readonly status?: InvitationStatusFilter;
151
+ }
152
+
153
+ /**
154
+ * One first-login demand an organization may raise on an account it admits
155
+ * (`settings.security.provisioned_user_policies`, backend dto
156
+ * `PROVISIONED_USER_POLICIES`). INDEPENDENT checkboxes, not alternatives — an
157
+ * org may demand both. NOT GENERATED: the security block lives inside the
158
+ * free-form `Workspace.settings` JSON, so drf-spectacular types it as an
159
+ * untyped object; the backend serializer validates exactly these two values.
160
+ */
161
+ export type ProvisionedUserPolicy = "password_change" | "mfa_enroll";
162
+
163
+ /**
164
+ * Typed shape of `Workspace.settings.security` (backend dto
165
+ * `WorkspaceSecuritySettings`, org-program §C3). A documented correction for
166
+ * the same reason as {@link WorkspaceRole}: the block is stored inside the
167
+ * free-form settings JSON (no schema migration on the backend), so the
168
+ * generated types say `object` and this is the canon of the known keys.
169
+ * Unknown keys are preserved verbatim by the backend — hence the index
170
+ * signature, and hence why the update hook MERGES rather than replaces.
171
+ */
172
+ export interface WorkspaceSecuritySettings {
173
+ /** Whether membership requires a strong second factor. Turning it on
174
+ * sweeps current members and suspends those without one (reason `no_mfa`). */
175
+ readonly require_mfa?: boolean;
176
+ /**
177
+ * First-login steps demanded of accounts the org admits — a LIST since
178
+ * stapel-workspaces 0.13.0 (#90). The pre-0.13 singular
179
+ * `provisioned_user_policy` string is still read by the backend for old
180
+ * rows, but this pair only ever WRITES the list: a client that keeps
181
+ * writing the singular spelling silently caps the org at one demand.
182
+ */
183
+ readonly provisioned_user_policies?: readonly ProvisionedUserPolicy[];
184
+ /** Derived, read-only: whether the org actually set the policies (as
185
+ * opposed to inheriting the `password_change` default). Never sent. */
186
+ readonly policies_configured?: boolean;
187
+ /** Extra keys pass through the backend verbatim (client extension). */
188
+ readonly [extra: string]: unknown;
189
+ }
@@ -1,14 +1,20 @@
1
1
  import type { StapelClient, StapelRequestOptions } from "@stapel/core";
2
2
  import type {
3
+ Invitation,
4
+ InvitationPage,
5
+ InvitationsParams,
3
6
  Member,
4
7
  MemberInvite,
5
8
  MemberInviteResult,
6
9
  MemberPage,
10
+ MemberPasswordReset,
11
+ MemberPasswordResetResult,
7
12
  MemberRoleUpdate,
8
13
  MembersParams,
9
14
  InvitationAccept,
10
15
  InvitationClaim,
11
16
  InvitationPreview,
17
+ InstanceShape,
12
18
  RoleList,
13
19
  Workspace,
14
20
  WorkspaceCreate,
@@ -87,6 +93,55 @@ export interface WorkspacesApi {
87
93
  /** Remove a member from a workspace (admin+; the last owner is protected). */
88
94
  removeMember(workspaceId: string, userId: string): Promise<void>;
89
95
 
96
+ /**
97
+ * Reset a member's password on the organization's order (stapel-workspaces
98
+ * ≥0.14.0, #110). Capability `members.password.reset`, declared **high** —
99
+ * the backend wraps it in `requires_verification(scope="sensitive")`, so an
100
+ * ambient cookie is not enough and a step-up (or factor ENROLLMENT) 403 is
101
+ * the normal first answer. A missing/wrong target answers one
102
+ * byte-identical 404 (never an existence oracle).
103
+ *
104
+ * `generated_password` in the reply is a ONE-SHOT credential: present only
105
+ * when the request omitted `password`, never re-fetchable, never logged.
106
+ */
107
+ resetMemberPassword(
108
+ workspaceId: string,
109
+ userId: string,
110
+ body?: MemberPasswordReset
111
+ ): Promise<MemberPasswordResetResult>;
112
+
113
+ // ── invitation administration (#109, stapel-workspaces ≥0.12.0) ────────────
114
+
115
+ /**
116
+ * The workspace's invitations — the admin's "who has not accepted yet"
117
+ * table (capability `members.invite`). An ANCHOR-paginated page, filtered
118
+ * by `status` (default `pending`) and `search` (invited email). The invite
119
+ * token is never in the response.
120
+ */
121
+ listInvitations(
122
+ workspaceId: string,
123
+ params?: InvitationsParams
124
+ ): Promise<InvitationPage>;
125
+ /**
126
+ * Withdraw a live invitation — the workspace's terminal "no", the mirror of
127
+ * the invitee's decline (the two stay distinguishable in `status` forever).
128
+ * Only a `pending` one is revocable; the reply is the updated row.
129
+ */
130
+ revokeInvitation(
131
+ workspaceId: string,
132
+ invitationId: string
133
+ ): Promise<Invitation>;
134
+ /**
135
+ * Send the invitation email again. Accepts an EXPIRED invitation on purpose
136
+ * (a dead TTL is the commonest reason to resend) and refuses the three
137
+ * terminal states. Rotates the token and restarts the TTL — every earlier
138
+ * link stops working — so the reply is the updated row, not the old one.
139
+ */
140
+ resendInvitation(
141
+ workspaceId: string,
142
+ invitationId: string
143
+ ): Promise<Invitation>;
144
+
90
145
  /** Accept an invitation by its token — returns the caller's new membership. */
91
146
  acceptInvitation(body: InvitationAccept): Promise<Member>;
92
147
 
@@ -117,6 +172,9 @@ export interface WorkspacesApi {
117
172
  /** The effective role registry (builtin + deployment overlay), for
118
173
  * RoleSelect-class UI — capability strings verbatim, rank-descending. */
119
174
  listRoles(): Promise<RoleList>;
175
+
176
+ /** Instance shape (GET /instance, unauthenticated). */
177
+ getInstanceShape(): Promise<InstanceShape>;
120
178
  }
121
179
 
122
180
  export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
@@ -172,6 +230,39 @@ export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
172
230
  mutating()
173
231
  ),
174
232
 
233
+ resetMemberPassword: (workspaceId, userId, body) =>
234
+ client.post(
235
+ `/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(userId)}/password/reset`,
236
+ (body ?? {}) satisfies MemberPasswordReset,
237
+ mutating()
238
+ ),
239
+
240
+ listInvitations: (workspaceId, params) => {
241
+ const query: Record<string, string | number> = {};
242
+ if (params?.anchor !== undefined) query.anchor = params.anchor;
243
+ if (params?.direction !== undefined) query.direction = params.direction;
244
+ if (params?.limit !== undefined) query.limit = params.limit;
245
+ if (params?.search !== undefined) query.search = params.search;
246
+ if (params?.status !== undefined) query.status = params.status;
247
+ return client.get(`/${encodeURIComponent(workspaceId)}/invitations`, {
248
+ query,
249
+ });
250
+ },
251
+
252
+ revokeInvitation: (workspaceId, invitationId) =>
253
+ client.post(
254
+ `/${encodeURIComponent(workspaceId)}/invitations/${encodeURIComponent(invitationId)}/revoke`,
255
+ undefined,
256
+ mutating()
257
+ ),
258
+
259
+ resendInvitation: (workspaceId, invitationId) =>
260
+ client.post(
261
+ `/${encodeURIComponent(workspaceId)}/invitations/${encodeURIComponent(invitationId)}/resend`,
262
+ undefined,
263
+ mutating()
264
+ ),
265
+
175
266
  acceptInvitation: (body) =>
176
267
  client.post(
177
268
  "/invitations/accept",
@@ -197,5 +288,7 @@ export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
197
288
  ),
198
289
 
199
290
  listRoles: () => client.get("/roles"),
291
+
292
+ getInstanceShape: () => client.get("/instance"),
200
293
  };
201
294
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `<MembersManager/>` — default skin for the "members & roles" settings
3
- * screen (owner directive: "настройки воркспейса (имя/участники/роли/
4
- * инвайты)"). Built entirely on this pair's EXISTING `Members` headless
3
+ * screen (owner directive: workspace settings — name/members/roles/
4
+ * invites). Built entirely on this pair's EXISTING `Members` headless
5
5
  * wrapper (`useMembers`/`useInviteMembers`/`useUpdateMemberRole`/
6
6
  * `useRemoveMember`) — no new backend surface.
7
7
  *
@@ -72,6 +72,7 @@ export const WORKSPACES_ERRORS = {
72
72
  "error.403.membership_suspended": { status: 403, params: ["reason"], remediation: "fix_input", en: "Your membership in this workspace is suspended ({reason})" },
73
73
  "error.403.missing_capability": { status: 403, params: ["capability"], remediation: "contact_support", en: "Your role does not include the {capability} capability in this workspace" },
74
74
  "error.403.network_blocked": { status: 403, params: [], remediation: "contact_support", en: "Requests from this network are not allowed" },
75
+ "error.403.role_exceeds_inviter_rank": { status: 403, params: ["role"], remediation: "fix_input", en: "You cannot grant a role that outranks your own ({role})" },
75
76
  "error.403.verification_enrollment_required": { status: 403, params: [], remediation: "verify", en: "Verification factor enrollment required" },
76
77
  "error.403.verification_required": { status: 403, params: [], remediation: "verify", en: "Additional verification required" },
77
78
  "error.404.ad_not_found": { status: 404, params: [], remediation: "retry", en: "Listing not found" },
@@ -138,6 +139,7 @@ export const WORKSPACES_ERROR_CODES: readonly WorkspacesErrorCode[] = [
138
139
  "error.403.membership_suspended",
139
140
  "error.403.missing_capability",
140
141
  "error.403.network_blocked",
142
+ "error.403.role_exceeds_inviter_rank",
141
143
  "error.403.verification_enrollment_required",
142
144
  "error.403.verification_required",
143
145
  "error.404.ad_not_found",
@@ -206,6 +208,7 @@ export const workspacesErrorBundleEn: Record<WorkspacesErrorCode, string> = {
206
208
  "error.403.membership_suspended": "Your membership in this workspace is suspended ({reason})",
207
209
  "error.403.missing_capability": "Your role does not include the {capability} capability in this workspace",
208
210
  "error.403.network_blocked": "Requests from this network are not allowed",
211
+ "error.403.role_exceeds_inviter_rank": "You cannot grant a role that outranks your own ({role})",
209
212
  "error.403.verification_enrollment_required": "Verification factor enrollment required",
210
213
  "error.403.verification_required": "Additional verification required",
211
214
  "error.404.ad_not_found": "Listing not found",
@@ -290,6 +290,15 @@
290
290
  "remediation": "contact_support",
291
291
  "en": "Requests from this network are not allowed"
292
292
  },
293
+ {
294
+ "code": "error.403.role_exceeds_inviter_rank",
295
+ "status": 403,
296
+ "params": [
297
+ "role"
298
+ ],
299
+ "remediation": "fix_input",
300
+ "en": "You cannot grant a role that outranks your own ({role})"
301
+ },
293
302
  {
294
303
  "code": "error.403.verification_enrollment_required",
295
304
  "status": 403,
@@ -49,6 +49,7 @@ export const workspacesErrorBundleRu: Record<WorkspacesErrorCode, string> = {
49
49
  "error.403.membership_suspended": "Ваше членство в этом рабочем пространстве приостановлено ({reason})",
50
50
  "error.403.missing_capability": "Ваша роль не включает право {capability} в этом рабочем пространстве",
51
51
  "error.403.network_blocked": "Запросы из этой сети не разрешены.",
52
+ "error.403.role_exceeds_inviter_rank": "Вы не можете выдать роль, которая превышает вашу собственную ({role})",
52
53
  "error.403.verification_enrollment_required": "Требуется регистрация фактора подтверждения.",
53
54
  "error.403.verification_required": "Требуется дополнительная проверка",
54
55
  "error.404.ad_not_found": "Объявление не найдено",
package/src/index.ts CHANGED
@@ -57,14 +57,28 @@ export {
57
57
  useWorkspaces,
58
58
  useWorkspace,
59
59
  useMembers,
60
+ useInvitations,
61
+ useInfiniteInvitations,
60
62
  useRoles,
63
+ useInstanceShape,
61
64
  useInvitationPreview,
62
65
  useCapabilities,
66
+ useCapabilityGate,
63
67
  } from "./model/queries.js";
64
- export type { CapabilitiesResult } from "./model/queries.js";
68
+ export type { CapabilitiesResult, CapabilityGate } from "./model/queries.js";
65
69
 
66
70
  // ── model (capability matcher + email-mask ports — backend-synced utils) ─────
67
71
  export { capabilityMatches, hasCapability } from "./model/capabilities.js";
72
+ export {
73
+ BUILTIN_CAPABILITY_LEVELS,
74
+ SENSITIVE_SCOPE,
75
+ capabilityLevel,
76
+ readVerificationEnrollment,
77
+ } from "./model/stepUp.js";
78
+ export type {
79
+ CapabilityLevel,
80
+ VerificationEnrollment,
81
+ } from "./model/stepUp.js";
68
82
  export { maskEmail, emailMatchesMask } from "./model/emailMask.js";
69
83
 
70
84
  // ── model (write hooks) ──────────────────────────────────────────────────────
@@ -78,8 +92,15 @@ export {
78
92
  useAcceptInvitation,
79
93
  useClaimInvitation,
80
94
  useDeclineInvitation,
95
+ useRevokeInvitation,
96
+ useResendInvitation,
97
+ useResetMemberPassword,
98
+ useUpdateSecuritySettings,
99
+ } from "./model/mutations.js";
100
+ export type {
101
+ MemberRoleChange,
102
+ MemberPasswordResetVars,
81
103
  } from "./model/mutations.js";
82
- export type { MemberRoleChange } from "./model/mutations.js";
83
104
 
84
105
  // ── api (wire type aliases) ──────────────────────────────────────────────────
85
106
  export type {
@@ -94,9 +115,16 @@ export type {
94
115
  MemberInviteResult,
95
116
  MemberRoleUpdate,
96
117
  Invitation,
118
+ InvitationPage as InvitationListData,
119
+ InvitationsParams,
120
+ InvitationStatusFilter,
97
121
  InvitationAccept,
98
122
  InvitationPreview,
99
123
  InvitationClaim,
124
+ MemberPasswordReset,
125
+ MemberPasswordResetResult,
126
+ ProvisionedUserPolicy,
127
+ WorkspaceSecuritySettings,
100
128
  RoleInfo,
101
129
  RoleList,
102
130
  WorkspaceRole,
@@ -5,20 +5,24 @@ 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,
12
15
  InvitationClaim,
13
16
  Workspace,
14
17
  WorkspaceCreate,
18
+ WorkspaceSecuritySettings,
15
19
  WorkspaceUpdate,
16
20
  } from "../api/types.js";
17
21
  import { useWorkspacesApi } from "./context.js";
18
22
  import { workspacesQueryKeys } from "./queryKeys.js";
19
23
 
20
24
  /**
21
- * Write hooks (frontend-standard §2 — "мутации с инвалидацией"). Each mutation
25
+ * Write hooks (frontend-standard §2 — mutations invalidate on success). Each mutation
22
26
  * invalidates exactly the server state it can move. Membership and ownership are
23
27
  * server truth (roles gate access in other services via the membership cache),
24
28
  * so NONE of these are optimistic (frontend-core-architecture §2.6: optimistic
@@ -175,6 +179,194 @@ export function useRemoveMember(
175
179
  return useMutation(options);
176
180
  }
177
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
+
178
370
  /**
179
371
  * Accept an invitation by its token — the caller joins the workspace. A dead
180
372
  * token surfaces a localizable `StapelApiError` (`error.400.invitation_expired`