@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.
- package/CHANGELOG.md +42 -0
- package/dist/api/generated/schema.d.ts +497 -5
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +91 -2
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/workspacesApi.d.ts +35 -1
- package/dist/api/workspacesApi.d.ts.map +1 -1
- package/dist/api/workspacesApi.js +20 -0
- package/dist/api/workspacesApi.js.map +1 -1
- package/dist/default/MembersManager.js +2 -2
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/model/mutations.d.ts +86 -2
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +152 -1
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/queries.d.ts +90 -2
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +115 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +11 -1
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +21 -1
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/model/stepUp.d.ts +25 -0
- package/dist/model/stepUp.d.ts.map +1 -0
- package/dist/model/stepUp.js +79 -0
- package/dist/model/stepUp.js.map +1 -0
- package/llms.txt +15 -3
- package/manifest.json +147 -2
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +497 -5
- package/src/api/types.ts +101 -2
- package/src/api/workspacesApi.ts +93 -0
- package/src/default/MembersManager.tsx +2 -2
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -0
- package/src/index.ts +30 -2
- package/src/model/mutations.ts +193 -1
- package/src/model/queries.ts +162 -2
- package/src/model/queryKeys.ts +38 -2
- 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
|
+
}
|
package/src/api/workspacesApi.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
package/src/model/mutations.ts
CHANGED
|
@@ -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 —
|
|
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`
|