@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.
- package/CHANGELOG.md +56 -0
- package/dist/api/generated/schema.d.ts +774 -7
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +104 -7
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/workspacesApi.d.ts +55 -1
- package/dist/api/workspacesApi.d.ts.map +1 -1
- package/dist/api/workspacesApi.js +23 -0
- package/dist/api/workspacesApi.js.map +1 -1
- package/dist/default/InviteAcceptPage.d.ts +28 -0
- package/dist/default/InviteAcceptPage.d.ts.map +1 -0
- package/dist/default/InviteAcceptPage.js +141 -0
- package/dist/default/InviteAcceptPage.js.map +1 -0
- package/dist/default/MembersManager.d.ts +4 -4
- package/dist/default/MembersManager.d.ts.map +1 -1
- package/dist/default/MembersManager.js +61 -56
- package/dist/default/MembersManager.js.map +1 -1
- package/dist/default/index.d.ts +2 -0
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/flows/inviteAcceptFlow.d.ts +155 -0
- package/dist/flows/inviteAcceptFlow.d.ts.map +1 -0
- package/dist/flows/inviteAcceptFlow.js +126 -0
- package/dist/flows/inviteAcceptFlow.js.map +1 -0
- package/dist/headless/Can.d.ts +40 -0
- package/dist/headless/Can.d.ts.map +1 -0
- package/dist/headless/Can.js +30 -0
- package/dist/headless/Can.js.map +1 -0
- package/dist/headless/InviteAcceptFlow.d.ts +44 -0
- package/dist/headless/InviteAcceptFlow.d.ts.map +1 -0
- package/dist/headless/InviteAcceptFlow.js +67 -0
- package/dist/headless/InviteAcceptFlow.js.map +1 -0
- package/dist/headless/RoleSelect.d.ts +41 -0
- package/dist/headless/RoleSelect.d.ts.map +1 -0
- package/dist/headless/RoleSelect.js +34 -0
- package/dist/headless/RoleSelect.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +48 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +24 -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 +8 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +28 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +64 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +30 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/model/capabilities.d.ts +21 -0
- package/dist/model/capabilities.d.ts.map +1 -0
- package/dist/model/capabilities.js +32 -0
- package/dist/model/capabilities.js.map +1 -0
- package/dist/model/emailMask.d.ts +22 -0
- package/dist/model/emailMask.d.ts.map +1 -0
- package/dist/model/emailMask.js +36 -0
- package/dist/model/emailMask.js.map +1 -0
- package/dist/model/mutations.d.ts +107 -3
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +190 -1
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/queries.d.ts +108 -2
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +139 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +12 -1
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +25 -0
- 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 +49 -15
- package/manifest.json +346 -3
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +774 -7
- package/src/api/types.ts +113 -7
- package/src/api/workspacesApi.ts +137 -0
- package/src/default/InviteAcceptPage.tsx +373 -0
- package/src/default/MembersManager.tsx +155 -145
- package/src/default/index.ts +2 -0
- package/src/flows/inviteAcceptFlow.ts +289 -0
- package/src/headless/Can.tsx +49 -0
- package/src/headless/InviteAcceptFlow.tsx +105 -0
- package/src/headless/RoleSelect.tsx +58 -0
- package/src/i18n/generated/errors.gen.ts +24 -0
- package/src/i18n/generated/errors.json +62 -0
- package/src/i18n/generated/errors.ru.gen.ts +8 -0
- package/src/i18n/keys.ts +68 -0
- package/src/i18n/ru.ts +34 -0
- package/src/index.ts +54 -1
- package/src/model/capabilities.ts +37 -0
- package/src/model/emailMask.ts +37 -0
- package/src/model/mutations.ts +248 -4
- package/src/model/queries.ts +205 -2
- package/src/model/queryKeys.ts +45 -1
- 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,19 +38,47 @@ 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"];
|
|
58
|
+
/** GET /invitations/{token} 200 body — the public (AllowAny) preview the
|
|
59
|
+
* `/invite/{token}` page renders before any auth decision (org-program §B2). */
|
|
60
|
+
export type InvitationPreview = Schemas["InvitationPreviewResponse"];
|
|
61
|
+
/** POST /invitations/{token}/claim 200 body — the single-use login grant for a
|
|
62
|
+
* not-yet-registered invitee; exchange it at auth's POST /grant/exchange/. */
|
|
63
|
+
export type InvitationClaim = Schemas["InvitationClaimResponse"];
|
|
64
|
+
/** One role of the effective registry (builtin + `STAPEL_WORKSPACES["ROLES"]`
|
|
65
|
+
* overlay) — GET /roles (org-program §A2). Capability strings are verbatim,
|
|
66
|
+
* wildcards (`*`, `members.*`) included. */
|
|
67
|
+
export type RoleInfo = Schemas["RoleResponse"];
|
|
68
|
+
/** GET /roles 200 body — the effective role registry, rank-descending. */
|
|
69
|
+
export type RoleList = Schemas["RoleListResponse"];
|
|
41
70
|
|
|
42
71
|
// ── documented corrections (drf-spectacular under-describes) ──────────────────
|
|
43
72
|
|
|
44
73
|
/**
|
|
45
|
-
* A workspace membership role, ordered least→most privileged.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
74
|
+
* A BUILTIN workspace membership role, ordered least→most privileged. Since
|
|
75
|
+
* stapel-workspaces 0.6.0 (org-program §A1) the effective registry is
|
|
76
|
+
* settings-extensible — a deployment can add roles (`secretary`, …) via
|
|
77
|
+
* `STAPEL_WORKSPACES["ROLES"]`, so `MemberResponse.role` is genuinely an open
|
|
78
|
+
* `string` on the wire and the registry endpoint (GET /roles, {@link RoleInfo})
|
|
79
|
+
* is the source of truth for what exists. This union names the four builtin
|
|
80
|
+
* keys that are ALWAYS present (with `owner` system-protected); use it for
|
|
81
|
+
* literals, not to validate server data.
|
|
50
82
|
*/
|
|
51
83
|
export type WorkspaceRole = "owner" | "admin" | "member" | "viewer";
|
|
52
84
|
|
|
@@ -73,3 +105,77 @@ export interface MembersParams {
|
|
|
73
105
|
/** Case-insensitive substring filter matched against a member's email or display name. */
|
|
74
106
|
readonly search?: string;
|
|
75
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Which invitations GET /{id}/invitations returns. Taken VERBATIM from the
|
|
111
|
+
* generated operation table (not re-typed by hand): `pending` (the backend
|
|
112
|
+
* default) — live, seat-reserving invitations, i.e. who has not accepted yet;
|
|
113
|
+
* `never_accepted` — those plus declined / revoked / expired; `all` — the full
|
|
114
|
+
* history, accepted rows included.
|
|
115
|
+
*/
|
|
116
|
+
export type InvitationStatusFilter = NonNullable<
|
|
117
|
+
NonNullable<
|
|
118
|
+
Operations["workspaces_api_v1_invitations_list"]["parameters"]["query"]
|
|
119
|
+
>["status"]
|
|
120
|
+
>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Anchor-pagination query for GET /{id}/invitations (core `AnchorPagination`,
|
|
124
|
+
* the same shape as {@link MembersParams} plus the `status` filter). All
|
|
125
|
+
* optional: no params fetches the newest page of PENDING invitations
|
|
126
|
+
* (default limit 100, max 500).
|
|
127
|
+
*
|
|
128
|
+
* ANCHOR, not page numbers: a page is addressed by the opaque `next_anchor`
|
|
129
|
+
* of the page before it. There is no "page 3" to jump to and no offset to
|
|
130
|
+
* skew when an invitation is revoked mid-scroll.
|
|
131
|
+
*/
|
|
132
|
+
export interface InvitationsParams {
|
|
133
|
+
/** Anchor value to paginate from (exclusive) — a page's `next_anchor`. */
|
|
134
|
+
readonly anchor?: string;
|
|
135
|
+
/** Pagination direction relative to `anchor`. */
|
|
136
|
+
readonly direction?: "next" | "prev" | "center";
|
|
137
|
+
/** Page size (default 100, max 500). */
|
|
138
|
+
readonly limit?: number;
|
|
139
|
+
/** Case-insensitive substring filter on the invited email address. */
|
|
140
|
+
readonly search?: string;
|
|
141
|
+
/** Which invitations to return (default `pending`). */
|
|
142
|
+
readonly status?: InvitationStatusFilter;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One first-login demand an organization may raise on an account it admits
|
|
147
|
+
* (`settings.security.provisioned_user_policies`, backend dto
|
|
148
|
+
* `PROVISIONED_USER_POLICIES`). INDEPENDENT checkboxes, not alternatives — an
|
|
149
|
+
* org may demand both. NOT GENERATED: the security block lives inside the
|
|
150
|
+
* free-form `Workspace.settings` JSON, so drf-spectacular types it as an
|
|
151
|
+
* untyped object; the backend serializer validates exactly these two values.
|
|
152
|
+
*/
|
|
153
|
+
export type ProvisionedUserPolicy = "password_change" | "mfa_enroll";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Typed shape of `Workspace.settings.security` (backend dto
|
|
157
|
+
* `WorkspaceSecuritySettings`, org-program §C3). A documented correction for
|
|
158
|
+
* the same reason as {@link WorkspaceRole}: the block is stored inside the
|
|
159
|
+
* free-form settings JSON (no schema migration on the backend), so the
|
|
160
|
+
* generated types say `object` and this is the canon of the known keys.
|
|
161
|
+
* Unknown keys are preserved verbatim by the backend — hence the index
|
|
162
|
+
* signature, and hence why the update hook MERGES rather than replaces.
|
|
163
|
+
*/
|
|
164
|
+
export interface WorkspaceSecuritySettings {
|
|
165
|
+
/** Whether membership requires a strong second factor. Turning it on
|
|
166
|
+
* sweeps current members and suspends those without one (reason `no_mfa`). */
|
|
167
|
+
readonly require_mfa?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* First-login steps demanded of accounts the org admits — a LIST since
|
|
170
|
+
* stapel-workspaces 0.13.0 (#90). The pre-0.13 singular
|
|
171
|
+
* `provisioned_user_policy` string is still read by the backend for old
|
|
172
|
+
* rows, but this pair only ever WRITES the list: a client that keeps
|
|
173
|
+
* writing the singular spelling silently caps the org at one demand.
|
|
174
|
+
*/
|
|
175
|
+
readonly provisioned_user_policies?: readonly ProvisionedUserPolicy[];
|
|
176
|
+
/** Derived, read-only: whether the org actually set the policies (as
|
|
177
|
+
* opposed to inheriting the `password_change` default). Never sent. */
|
|
178
|
+
readonly policies_configured?: boolean;
|
|
179
|
+
/** Extra keys pass through the backend verbatim (client extension). */
|
|
180
|
+
readonly [extra: string]: unknown;
|
|
181
|
+
}
|
package/src/api/workspacesApi.ts
CHANGED
|
@@ -1,12 +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,
|
|
15
|
+
InvitationClaim,
|
|
16
|
+
InvitationPreview,
|
|
17
|
+
RoleList,
|
|
10
18
|
Workspace,
|
|
11
19
|
WorkspaceCreate,
|
|
12
20
|
WorkspaceList,
|
|
@@ -84,8 +92,85 @@ export interface WorkspacesApi {
|
|
|
84
92
|
/** Remove a member from a workspace (admin+; the last owner is protected). */
|
|
85
93
|
removeMember(workspaceId: string, userId: string): Promise<void>;
|
|
86
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Reset a member's password on the organization's order (stapel-workspaces
|
|
97
|
+
* ≥0.14.0, #110). Capability `members.password.reset`, declared **high** —
|
|
98
|
+
* the backend wraps it in `requires_verification(scope="sensitive")`, so an
|
|
99
|
+
* ambient cookie is not enough and a step-up (or factor ENROLLMENT) 403 is
|
|
100
|
+
* the normal first answer. A missing/wrong target answers one
|
|
101
|
+
* byte-identical 404 (never an existence oracle).
|
|
102
|
+
*
|
|
103
|
+
* `generated_password` in the reply is a ONE-SHOT credential: present only
|
|
104
|
+
* when the request omitted `password`, never re-fetchable, never logged.
|
|
105
|
+
*/
|
|
106
|
+
resetMemberPassword(
|
|
107
|
+
workspaceId: string,
|
|
108
|
+
userId: string,
|
|
109
|
+
body?: MemberPasswordReset
|
|
110
|
+
): Promise<MemberPasswordResetResult>;
|
|
111
|
+
|
|
112
|
+
// ── invitation administration (#109, stapel-workspaces ≥0.12.0) ────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The workspace's invitations — the admin's "who has not accepted yet"
|
|
116
|
+
* table (capability `members.invite`). An ANCHOR-paginated page, filtered
|
|
117
|
+
* by `status` (default `pending`) and `search` (invited email). The invite
|
|
118
|
+
* token is never in the response.
|
|
119
|
+
*/
|
|
120
|
+
listInvitations(
|
|
121
|
+
workspaceId: string,
|
|
122
|
+
params?: InvitationsParams
|
|
123
|
+
): Promise<InvitationPage>;
|
|
124
|
+
/**
|
|
125
|
+
* Withdraw a live invitation — the workspace's terminal "no", the mirror of
|
|
126
|
+
* the invitee's decline (the two stay distinguishable in `status` forever).
|
|
127
|
+
* Only a `pending` one is revocable; the reply is the updated row.
|
|
128
|
+
*/
|
|
129
|
+
revokeInvitation(
|
|
130
|
+
workspaceId: string,
|
|
131
|
+
invitationId: string
|
|
132
|
+
): Promise<Invitation>;
|
|
133
|
+
/**
|
|
134
|
+
* Send the invitation email again. Accepts an EXPIRED invitation on purpose
|
|
135
|
+
* (a dead TTL is the commonest reason to resend) and refuses the three
|
|
136
|
+
* terminal states. Rotates the token and restarts the TTL — every earlier
|
|
137
|
+
* link stops working — so the reply is the updated row, not the old one.
|
|
138
|
+
*/
|
|
139
|
+
resendInvitation(
|
|
140
|
+
workspaceId: string,
|
|
141
|
+
invitationId: string
|
|
142
|
+
): Promise<Invitation>;
|
|
143
|
+
|
|
87
144
|
/** Accept an invitation by its token — returns the caller's new membership. */
|
|
88
145
|
acceptInvitation(body: InvitationAccept): Promise<Member>;
|
|
146
|
+
|
|
147
|
+
// ── invite flow (org-program §B2, stapel-workspaces ≥0.7.0) ────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Public (AllowAny) invitation preview — what the `/invite/{token}` page
|
|
151
|
+
* renders before any auth decision. The token in the URL is the bearer
|
|
152
|
+
* secret: a credential, never log it.
|
|
153
|
+
*/
|
|
154
|
+
getInvitationPreview(token: string): Promise<InvitationPreview>;
|
|
155
|
+
/**
|
|
156
|
+
* Mint a login grant for a NOT-yet-registered invitee (AllowAny; only valid
|
|
157
|
+
* while `email_registered === false` — an existing account gets 409
|
|
158
|
+
* `email_already_registered` and the frontend switches to login). The
|
|
159
|
+
* invitation is NOT consumed: accept stays a separate, deliberate step.
|
|
160
|
+
* The returned `grant_token` is exchanged at AUTH's `POST /grant/exchange/`
|
|
161
|
+
* — deliberately not this pair's surface (pairs don't depend on each
|
|
162
|
+
* other); `InviteAcceptFlow` hands it outward via `onLoginGrant`.
|
|
163
|
+
*/
|
|
164
|
+
claimInvitation(token: string): Promise<InvitationClaim>;
|
|
165
|
+
/** Decline an invitation (authenticated + email-match) — the invitee's
|
|
166
|
+
* terminal "no". Decline ≠ revoke; both stay distinguishable in `status`. */
|
|
167
|
+
declineInvitation(token: string): Promise<void>;
|
|
168
|
+
|
|
169
|
+
// ── role registry (org-program §A2, stapel-workspaces ≥0.6.0) ──────────────
|
|
170
|
+
|
|
171
|
+
/** The effective role registry (builtin + deployment overlay), for
|
|
172
|
+
* RoleSelect-class UI — capability strings verbatim, rank-descending. */
|
|
173
|
+
listRoles(): Promise<RoleList>;
|
|
89
174
|
}
|
|
90
175
|
|
|
91
176
|
export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
|
|
@@ -141,11 +226,63 @@ export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
|
|
|
141
226
|
mutating()
|
|
142
227
|
),
|
|
143
228
|
|
|
229
|
+
resetMemberPassword: (workspaceId, userId, body) =>
|
|
230
|
+
client.post(
|
|
231
|
+
`/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(userId)}/password/reset`,
|
|
232
|
+
(body ?? {}) satisfies MemberPasswordReset,
|
|
233
|
+
mutating()
|
|
234
|
+
),
|
|
235
|
+
|
|
236
|
+
listInvitations: (workspaceId, params) => {
|
|
237
|
+
const query: Record<string, string | number> = {};
|
|
238
|
+
if (params?.anchor !== undefined) query.anchor = params.anchor;
|
|
239
|
+
if (params?.direction !== undefined) query.direction = params.direction;
|
|
240
|
+
if (params?.limit !== undefined) query.limit = params.limit;
|
|
241
|
+
if (params?.search !== undefined) query.search = params.search;
|
|
242
|
+
if (params?.status !== undefined) query.status = params.status;
|
|
243
|
+
return client.get(`/${encodeURIComponent(workspaceId)}/invitations`, {
|
|
244
|
+
query,
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
revokeInvitation: (workspaceId, invitationId) =>
|
|
249
|
+
client.post(
|
|
250
|
+
`/${encodeURIComponent(workspaceId)}/invitations/${encodeURIComponent(invitationId)}/revoke`,
|
|
251
|
+
undefined,
|
|
252
|
+
mutating()
|
|
253
|
+
),
|
|
254
|
+
|
|
255
|
+
resendInvitation: (workspaceId, invitationId) =>
|
|
256
|
+
client.post(
|
|
257
|
+
`/${encodeURIComponent(workspaceId)}/invitations/${encodeURIComponent(invitationId)}/resend`,
|
|
258
|
+
undefined,
|
|
259
|
+
mutating()
|
|
260
|
+
),
|
|
261
|
+
|
|
144
262
|
acceptInvitation: (body) =>
|
|
145
263
|
client.post(
|
|
146
264
|
"/invitations/accept",
|
|
147
265
|
body satisfies InvitationAccept,
|
|
148
266
|
mutating()
|
|
149
267
|
),
|
|
268
|
+
|
|
269
|
+
getInvitationPreview: (token) =>
|
|
270
|
+
client.get(`/invitations/${encodeURIComponent(token)}`),
|
|
271
|
+
|
|
272
|
+
claimInvitation: (token) =>
|
|
273
|
+
client.post(
|
|
274
|
+
`/invitations/${encodeURIComponent(token)}/claim`,
|
|
275
|
+
undefined,
|
|
276
|
+
mutating()
|
|
277
|
+
),
|
|
278
|
+
|
|
279
|
+
declineInvitation: (token) =>
|
|
280
|
+
client.post(
|
|
281
|
+
`/invitations/${encodeURIComponent(token)}/decline`,
|
|
282
|
+
undefined,
|
|
283
|
+
mutating()
|
|
284
|
+
),
|
|
285
|
+
|
|
286
|
+
listRoles: () => client.get("/roles"),
|
|
150
287
|
};
|
|
151
288
|
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<InviteAcceptPage/>` — the default `/invite/{token}` route component
|
|
3
|
+
* (org-program §B4): every state of the `InviteAcceptFlow` headless journey
|
|
4
|
+
* rendered with antd, with the two HOST SLOTS the pair cannot own:
|
|
5
|
+
*
|
|
6
|
+
* - `renderLoginPanel` — the sign-in surface for a registered invitee
|
|
7
|
+
* without a session (drop auth-react's `<AuthPanel/>` in; call
|
|
8
|
+
* `onLoggedIn(email)` — or simply let the `sessionEmail` prop update,
|
|
9
|
+
* the flow re-routes on it automatically).
|
|
10
|
+
* - `renderInitialSetup` — the basic-data step for a freshly created
|
|
11
|
+
* account (drop profiles-react's `InitialSetupPrompt`/`InitialSetupModal`
|
|
12
|
+
* in; call `onDone()`). Omitted → the step auto-skips.
|
|
13
|
+
*
|
|
14
|
+
* THE GRANT SEAM (§B4): for a NEW user the flow mints a login grant and this
|
|
15
|
+
* page hands it to YOUR `onLoginGrant(grantToken)` — wire it to auth-react's
|
|
16
|
+
* `api.exchangeLoginGrant(grantToken)` + `session.adopt(...)`. When your
|
|
17
|
+
* callback's promise resolves the page advances the flow automatically
|
|
18
|
+
* (`grantExchanged`); a rejection shows a retry. The pairs stay decoupled:
|
|
19
|
+
* this package never imports auth-react.
|
|
20
|
+
*
|
|
21
|
+
* Like the pair's other default skins (MembersManager/WorkspaceSettings),
|
|
22
|
+
* theming comes from the host's antd ConfigProvider — this is a route BODY,
|
|
23
|
+
* not a self-themed shell.
|
|
24
|
+
*/
|
|
25
|
+
import { useEffect, useRef, useState } from "react";
|
|
26
|
+
import type { ReactElement, ReactNode } from "react";
|
|
27
|
+
import { Alert, Button, Card, Flex, Result, Spin, Typography } from "antd";
|
|
28
|
+
import { useFormatFlowError, useT } from "@stapel/core";
|
|
29
|
+
import { InviteAcceptFlow } from "../headless/InviteAcceptFlow.js";
|
|
30
|
+
import type { InviteAcceptFlowBag } from "../headless/InviteAcceptFlow.js";
|
|
31
|
+
import type { InvitationPreview, Member } from "../api/types.js";
|
|
32
|
+
import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
|
|
33
|
+
|
|
34
|
+
export interface InviteAcceptPageProps {
|
|
35
|
+
/** The invite token from the route — a bearer secret, never log it. */
|
|
36
|
+
readonly token: string;
|
|
37
|
+
/** The signed-in account's email, or null/undefined when no session
|
|
38
|
+
* (e.g. auth-react's `useAuthSessionState().user?.email ?? null`). */
|
|
39
|
+
readonly sessionEmail?: string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Exchange the login grant at auth (the §B4 seam — see the module doc).
|
|
42
|
+
* Resolve = signed in (the page advances); reject = retry offered.
|
|
43
|
+
*/
|
|
44
|
+
readonly onLoginGrant?: (grantToken: string) => void | Promise<void>;
|
|
45
|
+
/** Login slot for `loginRequired` (registered invitee, no session). */
|
|
46
|
+
readonly renderLoginPanel?: (ctx: {
|
|
47
|
+
onLoggedIn: (email: string) => void;
|
|
48
|
+
}) => ReactNode;
|
|
49
|
+
/** Basic-data slot for `basicData` (fresh account). Omitted → auto-skip. */
|
|
50
|
+
readonly renderInitialSetup?: (ctx: { onDone: () => void }) => ReactNode;
|
|
51
|
+
/** Wrong-account CTA (sign out / switch). Omitted → CTA hidden. */
|
|
52
|
+
readonly onSwitchAccount?: () => void;
|
|
53
|
+
readonly onAccepted?: (member: Member) => void;
|
|
54
|
+
readonly onDeclined?: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function InviteAcceptPage(props: InviteAcceptPageProps): ReactElement {
|
|
58
|
+
return (
|
|
59
|
+
<InviteAcceptFlow
|
|
60
|
+
token={props.token}
|
|
61
|
+
sessionEmail={props.sessionEmail ?? null}
|
|
62
|
+
{...(props.onAccepted !== undefined ? { onAccepted: props.onAccepted } : {})}
|
|
63
|
+
{...(props.onDeclined !== undefined ? { onDeclined: props.onDeclined } : {})}
|
|
64
|
+
>
|
|
65
|
+
{(bag) => (
|
|
66
|
+
<PageBody
|
|
67
|
+
bag={bag}
|
|
68
|
+
sessionEmail={props.sessionEmail ?? null}
|
|
69
|
+
{...(props.onLoginGrant !== undefined
|
|
70
|
+
? { onLoginGrant: props.onLoginGrant }
|
|
71
|
+
: {})}
|
|
72
|
+
{...(props.renderLoginPanel !== undefined
|
|
73
|
+
? { renderLoginPanel: props.renderLoginPanel }
|
|
74
|
+
: {})}
|
|
75
|
+
{...(props.renderInitialSetup !== undefined
|
|
76
|
+
? { renderInitialSetup: props.renderInitialSetup }
|
|
77
|
+
: {})}
|
|
78
|
+
{...(props.onSwitchAccount !== undefined
|
|
79
|
+
? { onSwitchAccount: props.onSwitchAccount }
|
|
80
|
+
: {})}
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
</InviteAcceptFlow>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const UNAVAILABLE_KEY: Record<string, string> = {
|
|
88
|
+
expired: WORKSPACES_I18N_KEYS.inviteUnavailableExpired,
|
|
89
|
+
revoked: WORKSPACES_I18N_KEYS.inviteUnavailableRevoked,
|
|
90
|
+
accepted: WORKSPACES_I18N_KEYS.inviteUnavailableAccepted,
|
|
91
|
+
declined: WORKSPACES_I18N_KEYS.inviteUnavailableDeclined,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function InviteHeader(props: { preview: InvitationPreview }): ReactElement {
|
|
95
|
+
const t = useT();
|
|
96
|
+
return (
|
|
97
|
+
<Flex vertical gap={4}>
|
|
98
|
+
<Typography.Title level={3} style={{ margin: 0 }}>
|
|
99
|
+
{t(WORKSPACES_I18N_KEYS.inviteAcceptTitle, {
|
|
100
|
+
workspace: props.preview.workspace_name,
|
|
101
|
+
})}
|
|
102
|
+
</Typography.Title>
|
|
103
|
+
<Typography.Text type="secondary">
|
|
104
|
+
{t(WORKSPACES_I18N_KEYS.inviteRoleLine, { role: props.preview.role })}
|
|
105
|
+
</Typography.Text>
|
|
106
|
+
<Typography.Text type="secondary">
|
|
107
|
+
{t(WORKSPACES_I18N_KEYS.inviteEmailLine, {
|
|
108
|
+
email: props.preview.email_masked,
|
|
109
|
+
})}
|
|
110
|
+
</Typography.Text>
|
|
111
|
+
</Flex>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A real component (not hooks in the render-prop lambda) — owns the
|
|
116
|
+
* grant-exchange effect below. */
|
|
117
|
+
function PageBody(props: {
|
|
118
|
+
bag: InviteAcceptFlowBag;
|
|
119
|
+
sessionEmail: string | null;
|
|
120
|
+
onLoginGrant?: (grantToken: string) => void | Promise<void>;
|
|
121
|
+
renderLoginPanel?: (ctx: { onLoggedIn: (email: string) => void }) => ReactNode;
|
|
122
|
+
renderInitialSetup?: (ctx: { onDone: () => void }) => ReactNode;
|
|
123
|
+
onSwitchAccount?: () => void;
|
|
124
|
+
}): ReactElement {
|
|
125
|
+
const t = useT();
|
|
126
|
+
const formatError = useFormatFlowError();
|
|
127
|
+
const { bag } = props;
|
|
128
|
+
const s = bag.state;
|
|
129
|
+
|
|
130
|
+
// ── the grant seam: run the host's exchange when the grant is issued ──────
|
|
131
|
+
const { onLoginGrant, renderInitialSetup } = props;
|
|
132
|
+
const grantToken = s.step === "grantIssued" ? s.grantToken : null;
|
|
133
|
+
const [exchangeFailed, setExchangeFailed] = useState(false);
|
|
134
|
+
const exchangedRef = useRef<string | null>(null);
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (grantToken === null || onLoginGrant === undefined) return;
|
|
137
|
+
if (exchangedRef.current === grantToken) return; // once per grant
|
|
138
|
+
exchangedRef.current = grantToken;
|
|
139
|
+
setExchangeFailed(false);
|
|
140
|
+
void (async () => {
|
|
141
|
+
try {
|
|
142
|
+
await onLoginGrant(grantToken);
|
|
143
|
+
bag.grantExchanged();
|
|
144
|
+
} catch {
|
|
145
|
+
// The grant is single-use server-side but un-consumed on a network
|
|
146
|
+
// fault; retry re-runs the host callback with the SAME token.
|
|
147
|
+
exchangedRef.current = null;
|
|
148
|
+
setExchangeFailed(true);
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
}, [grantToken, onLoginGrant, bag]);
|
|
152
|
+
|
|
153
|
+
// ── basic-data auto-skip when the host provides no slot ───────────────────
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (s.step === "basicData" && renderInitialSetup === undefined) {
|
|
156
|
+
bag.completeBasicData();
|
|
157
|
+
}
|
|
158
|
+
}, [s.step, renderInitialSetup, bag]);
|
|
159
|
+
|
|
160
|
+
switch (s.step) {
|
|
161
|
+
case "idle":
|
|
162
|
+
case "loadingPreview":
|
|
163
|
+
return (
|
|
164
|
+
<Card data-testid="invite-accept-page">
|
|
165
|
+
<Flex vertical align="center" gap="middle">
|
|
166
|
+
<Typography.Text type="secondary">
|
|
167
|
+
{t(WORKSPACES_I18N_KEYS.inviteLoading)}
|
|
168
|
+
</Typography.Text>
|
|
169
|
+
<Spin />
|
|
170
|
+
</Flex>
|
|
171
|
+
</Card>
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
case "previewError":
|
|
175
|
+
return (
|
|
176
|
+
<Card data-testid="invite-accept-page">
|
|
177
|
+
<Alert type="error" showIcon message={formatError(s.error)} />
|
|
178
|
+
</Card>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
case "unavailable":
|
|
182
|
+
return (
|
|
183
|
+
<Card data-testid="invite-accept-page">
|
|
184
|
+
<Result
|
|
185
|
+
status="info"
|
|
186
|
+
title={s.preview.workspace_name}
|
|
187
|
+
subTitle={t(
|
|
188
|
+
UNAVAILABLE_KEY[s.status] ??
|
|
189
|
+
WORKSPACES_I18N_KEYS.inviteUnavailableRevoked
|
|
190
|
+
)}
|
|
191
|
+
/>
|
|
192
|
+
</Card>
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
case "wrongAccount":
|
|
196
|
+
return (
|
|
197
|
+
<Card data-testid="invite-accept-page">
|
|
198
|
+
<Flex vertical gap="middle">
|
|
199
|
+
<InviteHeader preview={s.preview} />
|
|
200
|
+
<Alert
|
|
201
|
+
type="warning"
|
|
202
|
+
showIcon
|
|
203
|
+
message={t(WORKSPACES_I18N_KEYS.inviteWrongAccount)}
|
|
204
|
+
description={t(WORKSPACES_I18N_KEYS.inviteWrongAccountHint, {
|
|
205
|
+
email: props.sessionEmail ?? "",
|
|
206
|
+
invited: s.preview.email_masked,
|
|
207
|
+
})}
|
|
208
|
+
/>
|
|
209
|
+
{props.onSwitchAccount && (
|
|
210
|
+
<Button onClick={props.onSwitchAccount} data-analytics="flow">
|
|
211
|
+
{t(WORKSPACES_I18N_KEYS.inviteSwitchAccountCta)}
|
|
212
|
+
</Button>
|
|
213
|
+
)}
|
|
214
|
+
</Flex>
|
|
215
|
+
</Card>
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
case "loginRequired":
|
|
219
|
+
return (
|
|
220
|
+
<Card data-testid="invite-accept-page">
|
|
221
|
+
<Flex vertical gap="middle">
|
|
222
|
+
<InviteHeader preview={s.preview} />
|
|
223
|
+
<Typography.Text strong>
|
|
224
|
+
{t(WORKSPACES_I18N_KEYS.inviteLoginTitle)}
|
|
225
|
+
</Typography.Text>
|
|
226
|
+
{props.renderLoginPanel?.({
|
|
227
|
+
onLoggedIn: (email) => bag.sessionEstablished(email),
|
|
228
|
+
})}
|
|
229
|
+
</Flex>
|
|
230
|
+
</Card>
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
case "newUser":
|
|
234
|
+
case "claiming":
|
|
235
|
+
case "claimError":
|
|
236
|
+
return (
|
|
237
|
+
<Card data-testid="invite-accept-page">
|
|
238
|
+
<Flex vertical gap="middle">
|
|
239
|
+
<InviteHeader preview={s.preview} />
|
|
240
|
+
<Typography.Text type="secondary">
|
|
241
|
+
{t(WORKSPACES_I18N_KEYS.inviteNewUserHint, {
|
|
242
|
+
email: s.preview.email_masked,
|
|
243
|
+
})}
|
|
244
|
+
</Typography.Text>
|
|
245
|
+
{s.step === "claimError" && (
|
|
246
|
+
<Alert type="error" showIcon message={formatError(s.error)} />
|
|
247
|
+
)}
|
|
248
|
+
<Button
|
|
249
|
+
type="primary"
|
|
250
|
+
loading={s.step === "claiming"}
|
|
251
|
+
onClick={() => bag.claim()}
|
|
252
|
+
data-analytics="flow"
|
|
253
|
+
>
|
|
254
|
+
{t(
|
|
255
|
+
s.step === "claiming"
|
|
256
|
+
? WORKSPACES_I18N_KEYS.inviteClaiming
|
|
257
|
+
: WORKSPACES_I18N_KEYS.inviteCreateAccountCta
|
|
258
|
+
)}
|
|
259
|
+
</Button>
|
|
260
|
+
</Flex>
|
|
261
|
+
</Card>
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
case "grantIssued":
|
|
265
|
+
return (
|
|
266
|
+
<Card data-testid="invite-accept-page">
|
|
267
|
+
<Flex vertical align="center" gap="middle">
|
|
268
|
+
{exchangeFailed ? (
|
|
269
|
+
<>
|
|
270
|
+
<Alert
|
|
271
|
+
type="error"
|
|
272
|
+
showIcon
|
|
273
|
+
message={t(WORKSPACES_I18N_KEYS.inviteExchangeFailed)}
|
|
274
|
+
/>
|
|
275
|
+
<Button
|
|
276
|
+
type="primary"
|
|
277
|
+
onClick={() => {
|
|
278
|
+
// Re-arm the effect for the same grant token.
|
|
279
|
+
exchangedRef.current = null;
|
|
280
|
+
setExchangeFailed(false);
|
|
281
|
+
}}
|
|
282
|
+
data-analytics="flow"
|
|
283
|
+
>
|
|
284
|
+
{t(WORKSPACES_I18N_KEYS.inviteRetryCta)}
|
|
285
|
+
</Button>
|
|
286
|
+
</>
|
|
287
|
+
) : (
|
|
288
|
+
<>
|
|
289
|
+
<Typography.Text type="secondary">
|
|
290
|
+
{t(WORKSPACES_I18N_KEYS.inviteExchanging)}
|
|
291
|
+
</Typography.Text>
|
|
292
|
+
<Spin />
|
|
293
|
+
</>
|
|
294
|
+
)}
|
|
295
|
+
</Flex>
|
|
296
|
+
</Card>
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
case "basicData":
|
|
300
|
+
return (
|
|
301
|
+
<Card data-testid="invite-accept-page">
|
|
302
|
+
<Flex vertical gap="middle">
|
|
303
|
+
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
304
|
+
{t(WORKSPACES_I18N_KEYS.inviteBasicDataTitle)}
|
|
305
|
+
</Typography.Title>
|
|
306
|
+
{renderInitialSetup ? (
|
|
307
|
+
renderInitialSetup({ onDone: () => bag.completeBasicData() })
|
|
308
|
+
) : (
|
|
309
|
+
<Spin />
|
|
310
|
+
)}
|
|
311
|
+
</Flex>
|
|
312
|
+
</Card>
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
case "acceptPrompt":
|
|
316
|
+
case "accepting":
|
|
317
|
+
case "declining":
|
|
318
|
+
case "acceptError":
|
|
319
|
+
return (
|
|
320
|
+
<Card data-testid="invite-accept-page">
|
|
321
|
+
<Flex vertical gap="middle">
|
|
322
|
+
<InviteHeader preview={s.preview} />
|
|
323
|
+
{s.step === "acceptError" && (
|
|
324
|
+
<Alert type="error" showIcon message={formatError(s.error)} />
|
|
325
|
+
)}
|
|
326
|
+
<Flex gap="small">
|
|
327
|
+
<Button
|
|
328
|
+
type="primary"
|
|
329
|
+
loading={s.step === "accepting"}
|
|
330
|
+
disabled={s.step === "declining"}
|
|
331
|
+
onClick={() => bag.accept()}
|
|
332
|
+
data-analytics="flow"
|
|
333
|
+
>
|
|
334
|
+
{t(WORKSPACES_I18N_KEYS.inviteJoinCta)}
|
|
335
|
+
</Button>
|
|
336
|
+
<Button
|
|
337
|
+
danger
|
|
338
|
+
loading={s.step === "declining"}
|
|
339
|
+
disabled={s.step === "accepting"}
|
|
340
|
+
onClick={() => bag.decline()}
|
|
341
|
+
data-analytics="flow"
|
|
342
|
+
>
|
|
343
|
+
{t(WORKSPACES_I18N_KEYS.inviteDeclineCta)}
|
|
344
|
+
</Button>
|
|
345
|
+
</Flex>
|
|
346
|
+
</Flex>
|
|
347
|
+
</Card>
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
case "accepted":
|
|
351
|
+
return (
|
|
352
|
+
<Card data-testid="invite-accept-page">
|
|
353
|
+
<Result
|
|
354
|
+
status="success"
|
|
355
|
+
title={t(WORKSPACES_I18N_KEYS.inviteAccepted, {
|
|
356
|
+
workspace: s.preview.workspace_name,
|
|
357
|
+
})}
|
|
358
|
+
/>
|
|
359
|
+
</Card>
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
case "declined":
|
|
363
|
+
return (
|
|
364
|
+
<Card data-testid="invite-accept-page">
|
|
365
|
+
<Result
|
|
366
|
+
status="info"
|
|
367
|
+
title={s.preview.workspace_name}
|
|
368
|
+
subTitle={t(WORKSPACES_I18N_KEYS.inviteDeclined)}
|
|
369
|
+
/>
|
|
370
|
+
</Card>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|