@stapel/workspaces-react 0.17.0 → 0.19.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 +183 -0
- package/README.md +39 -8
- package/dist/api/generated/schema.d.ts +187 -60
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/ActiveWorkspace.d.ts +43 -0
- package/dist/default/ActiveWorkspace.d.ts.map +1 -0
- package/dist/default/ActiveWorkspace.js +33 -0
- package/dist/default/ActiveWorkspace.js.map +1 -0
- package/dist/default/AuditTrailPane.d.ts +12 -0
- package/dist/default/AuditTrailPane.d.ts.map +1 -0
- package/dist/default/AuditTrailPane.js +154 -0
- package/dist/default/AuditTrailPane.js.map +1 -0
- package/dist/default/InvitationsPane.d.ts +19 -0
- package/dist/default/InvitationsPane.d.ts.map +1 -0
- package/dist/default/InvitationsPane.js +217 -0
- package/dist/default/InvitationsPane.js.map +1 -0
- package/dist/default/InviteAcceptPage.d.ts +7 -0
- package/dist/default/InviteAcceptPage.d.ts.map +1 -1
- package/dist/default/InviteAcceptPage.js +74 -38
- package/dist/default/InviteAcceptPage.js.map +1 -1
- package/dist/default/MembersManager.d.ts +12 -4
- package/dist/default/MembersManager.d.ts.map +1 -1
- package/dist/default/MembersManager.js +320 -84
- package/dist/default/MembersManager.js.map +1 -1
- package/dist/default/RoleSelectField.d.ts +64 -0
- package/dist/default/RoleSelectField.d.ts.map +1 -0
- package/dist/default/RoleSelectField.js +78 -0
- package/dist/default/RoleSelectField.js.map +1 -0
- package/dist/default/WorkspaceSettings.d.ts +7 -1
- package/dist/default/WorkspaceSettings.d.ts.map +1 -1
- package/dist/default/WorkspaceSettings.js +203 -45
- package/dist/default/WorkspaceSettings.js.map +1 -1
- package/dist/default/WorkspacesPage.d.ts +13 -0
- package/dist/default/WorkspacesPage.d.ts.map +1 -0
- package/dist/default/WorkspacesPage.js +108 -0
- package/dist/default/WorkspacesPage.js.map +1 -0
- package/dist/default/index.d.ts +28 -7
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +24 -7
- package/dist/default/index.js.map +1 -1
- package/dist/default/parts.d.ts +104 -0
- package/dist/default/parts.d.ts.map +1 -0
- package/dist/default/parts.js +140 -0
- package/dist/default/parts.js.map +1 -0
- package/dist/headless/Members.d.ts +59 -14
- package/dist/headless/Members.d.ts.map +1 -1
- package/dist/headless/Members.js +35 -10
- package/dist/headless/Members.js.map +1 -1
- package/dist/headless/RoleSelect.d.ts +9 -3
- package/dist/headless/RoleSelect.d.ts.map +1 -1
- package/dist/headless/RoleSelect.js +3 -2
- package/dist/headless/RoleSelect.js.map +1 -1
- package/dist/i18n/es.d.ts +11 -12
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +267 -14
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.es.gen.js +5 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +30 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +15 -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 +5 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +209 -1
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +410 -4
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +7 -2
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +209 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/format.d.ts +56 -0
- package/dist/model/format.d.ts.map +1 -0
- package/dist/model/format.js +125 -0
- package/dist/model/format.js.map +1 -0
- package/dist/model/selection.d.ts +15 -0
- package/dist/model/selection.d.ts.map +1 -1
- package/dist/model/selection.js +18 -1
- package/dist/model/selection.js.map +1 -1
- package/dist/nav/manifest.d.ts +45 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +77 -0
- package/dist/nav/manifest.js.map +1 -0
- package/llms.txt +11 -10
- package/manifest.json +281 -69
- package/package.json +18 -12
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +187 -60
- package/src/default/ActiveWorkspace.tsx +97 -0
- package/src/default/AuditTrailPane.tsx +309 -0
- package/src/default/InvitationsPane.tsx +501 -0
- package/src/default/InviteAcceptPage.tsx +187 -90
- package/src/default/MembersManager.tsx +832 -222
- package/src/default/RoleSelectField.tsx +187 -0
- package/src/default/WorkspaceSettings.tsx +540 -125
- package/src/default/WorkspacesPage.tsx +387 -0
- package/src/default/index.ts +28 -7
- package/src/default/parts.tsx +308 -0
- package/src/headless/Members.tsx +84 -16
- package/src/headless/RoleSelect.tsx +12 -5
- package/src/i18n/es.ts +326 -14
- package/src/i18n/generated/errors.es.gen.ts +5 -0
- package/src/i18n/generated/errors.gen.ts +15 -0
- package/src/i18n/generated/errors.json +37 -0
- package/src/i18n/generated/errors.ru.gen.ts +5 -0
- package/src/i18n/keys.ts +462 -5
- package/src/i18n/ru.ts +259 -5
- package/src/index.ts +1 -0
- package/src/model/format.ts +158 -0
- package/src/model/selection.tsx +19 -1
- package/src/nav/manifest.ts +120 -0
- package/dist/default/ErrorAlert.d.ts +0 -8
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -30
- package/dist/default/ErrorAlert.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -47
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<InvitationsPane/>` — the admin's "who has not joined yet" console
|
|
3
|
+
* (stapel-workspaces #109). Four endpoints that had four hooks and zero
|
|
4
|
+
* pixels: list, revoke, resend, rename.
|
|
5
|
+
*
|
|
6
|
+
* ## Why each control is gated the way it is
|
|
7
|
+
*
|
|
8
|
+
* The three writes are refused by the backend on the invitation's own state,
|
|
9
|
+
* not on the caller's role, so the state is what the screen gates on and the
|
|
10
|
+
* reason is the one the endpoint would give:
|
|
11
|
+
*
|
|
12
|
+
* - **Revoke** and **rename** need a `pending` row. A terminal invitation
|
|
13
|
+
* answers `error.400.invitation_already_used` / `_revoked` / `_declined` /
|
|
14
|
+
* `_expired`; offering the control and letting the person discover that is
|
|
15
|
+
* the "button that leads to a refusal" defect.
|
|
16
|
+
* - **Resend** additionally accepts an `expired` row on purpose — a dead TTL
|
|
17
|
+
* is the commonest reason to resend — and it is NOT an idempotent poke: it
|
|
18
|
+
* rotates the token, so every earlier link (including the one in the
|
|
19
|
+
* invitee's inbox) stops working. The confirm says that, because an admin
|
|
20
|
+
* who does not know it will send two links and wonder which one works.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here shows the invite TOKEN: it is a bearer credential that only
|
|
23
|
+
* ever leaves the backend inside the invitation email, and the response shape
|
|
24
|
+
* deliberately does not carry it.
|
|
25
|
+
*/
|
|
26
|
+
import { useState } from "react";
|
|
27
|
+
import type { ReactElement } from "react";
|
|
28
|
+
import { Card, Flex, Input, Segmented, Typography, theme as antdTheme } from "antd";
|
|
29
|
+
import {
|
|
30
|
+
actionAvailable,
|
|
31
|
+
actionBlocked,
|
|
32
|
+
loadStateFromQuery,
|
|
33
|
+
mapLoad,
|
|
34
|
+
useT,
|
|
35
|
+
useTPlural,
|
|
36
|
+
} from "@stapel/core";
|
|
37
|
+
import type { ActionAvailability } from "@stapel/core";
|
|
38
|
+
import {
|
|
39
|
+
EmptyState,
|
|
40
|
+
ErrorAlert,
|
|
41
|
+
GatedButton,
|
|
42
|
+
LoadList,
|
|
43
|
+
SkinConfirm,
|
|
44
|
+
SkinDialog,
|
|
45
|
+
SkinTheme,
|
|
46
|
+
} from "@stapel/tokens-antd/skin";
|
|
47
|
+
import { spacing } from "@stapel/tokens";
|
|
48
|
+
import { useInvitations } from "../model/queries.js";
|
|
49
|
+
import {
|
|
50
|
+
useRenameInvitation,
|
|
51
|
+
useResendInvitation,
|
|
52
|
+
useRevokeInvitation,
|
|
53
|
+
} from "../model/mutations.js";
|
|
54
|
+
import { useWorkspaceFormat } from "../model/format.js";
|
|
55
|
+
import type { Invitation, InvitationStatusFilter, InvitationsParams } from "../api/types.js";
|
|
56
|
+
import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
|
|
57
|
+
import { AnchorPager, Muted, PersonLine, RowActions, StatusTag } from "./parts.js";
|
|
58
|
+
import { ActiveWorkspaceBoundary } from "./ActiveWorkspace.js";
|
|
59
|
+
import { RoleLabel } from "./RoleSelectField.js";
|
|
60
|
+
|
|
61
|
+
export interface InvitationsPaneProps {
|
|
62
|
+
/**
|
|
63
|
+
* The workspace whose invitations these are. OPTIONAL: omitted (the way the
|
|
64
|
+
* nav contract mounts this screen), the active workspace comes from the
|
|
65
|
+
* runtime selection, and a screen with none renders the designed "choose a
|
|
66
|
+
* workspace" state.
|
|
67
|
+
*/
|
|
68
|
+
workspaceId?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Whether the caller may administer invitations (capability
|
|
71
|
+
* `members.invite`). The host knows its own verdict — pass `false` for a
|
|
72
|
+
* read-only view, which says so instead of silently dropping the controls.
|
|
73
|
+
* Default `true`.
|
|
74
|
+
*/
|
|
75
|
+
canManage?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The status a row is in, as the backend derives it. */
|
|
79
|
+
const STATUS_KEY: Record<string, string> = {
|
|
80
|
+
pending: WORKSPACES_I18N_KEYS.invitationsStatusPending,
|
|
81
|
+
accepted: WORKSPACES_I18N_KEYS.invitationsStatusAccepted,
|
|
82
|
+
declined: WORKSPACES_I18N_KEYS.invitationsStatusDeclined,
|
|
83
|
+
revoked: WORKSPACES_I18N_KEYS.invitationsStatusRevoked,
|
|
84
|
+
expired: WORKSPACES_I18N_KEYS.invitationsStatusExpired,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const STATUS_TONE: Record<string, "neutral" | "success" | "warning" | "danger" | "info"> = {
|
|
88
|
+
pending: "info",
|
|
89
|
+
accepted: "success",
|
|
90
|
+
declined: "neutral",
|
|
91
|
+
revoked: "neutral",
|
|
92
|
+
expired: "warning",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
interface Walk {
|
|
96
|
+
readonly anchor: string | undefined;
|
|
97
|
+
readonly direction: "next" | "prev" | undefined;
|
|
98
|
+
readonly index: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const FIRST_PAGE: Walk = { anchor: undefined, direction: undefined, index: 1 };
|
|
102
|
+
|
|
103
|
+
export function InvitationsPane(props: InvitationsPaneProps): ReactElement {
|
|
104
|
+
return (
|
|
105
|
+
<SkinTheme data-testid="invitations-pane">
|
|
106
|
+
<ActiveWorkspaceBoundary
|
|
107
|
+
workspaceId={props.workspaceId}
|
|
108
|
+
testId="invitations-pane-workspace"
|
|
109
|
+
>
|
|
110
|
+
{(workspaceId) => (
|
|
111
|
+
<InvitationsBody
|
|
112
|
+
workspaceId={workspaceId}
|
|
113
|
+
canManage={props.canManage ?? true}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
</ActiveWorkspaceBoundary>
|
|
117
|
+
</SkinTheme>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function InvitationsBody(props: {
|
|
122
|
+
readonly workspaceId: string;
|
|
123
|
+
readonly canManage: boolean;
|
|
124
|
+
}): ReactElement {
|
|
125
|
+
const t = useT();
|
|
126
|
+
const tPlural = useTPlural();
|
|
127
|
+
const canManage = props.canManage;
|
|
128
|
+
const [status, setStatus] = useState<InvitationStatusFilter>("pending");
|
|
129
|
+
const [search, setSearch] = useState("");
|
|
130
|
+
const [walk, setWalk] = useState<Walk>(FIRST_PAGE);
|
|
131
|
+
const [revoking, setRevoking] = useState<Invitation | null>(null);
|
|
132
|
+
const [resending, setResending] = useState<Invitation | null>(null);
|
|
133
|
+
const [renaming, setRenaming] = useState<Invitation | null>(null);
|
|
134
|
+
|
|
135
|
+
const params: InvitationsParams = {
|
|
136
|
+
status,
|
|
137
|
+
...(walk.anchor !== undefined ? { anchor: walk.anchor } : {}),
|
|
138
|
+
...(walk.direction !== undefined ? { direction: walk.direction } : {}),
|
|
139
|
+
...(search.trim() !== "" ? { search: search.trim() } : {}),
|
|
140
|
+
};
|
|
141
|
+
const query = useInvitations(props.workspaceId, params);
|
|
142
|
+
const revokeMutation = useRevokeInvitation(props.workspaceId);
|
|
143
|
+
const resendMutation = useResendInvitation(props.workspaceId);
|
|
144
|
+
const renameMutation = useRenameInvitation(props.workspaceId);
|
|
145
|
+
const page = query.data ?? null;
|
|
146
|
+
|
|
147
|
+
/** A new filter or a new search is a new walk — an anchor from the old one
|
|
148
|
+
* points into a list that no longer exists. */
|
|
149
|
+
function restart(apply: () => void): void {
|
|
150
|
+
apply();
|
|
151
|
+
setWalk(FIRST_PAGE);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Card>
|
|
156
|
+
<Flex vertical gap={spacing["1"]}>
|
|
157
|
+
<Typography.Title level={4} style={{ marginTop: 0, marginBottom: 0 }}>
|
|
158
|
+
{t(WORKSPACES_I18N_KEYS.invitationsTitle)}
|
|
159
|
+
</Typography.Title>
|
|
160
|
+
<Typography.Text type="secondary">
|
|
161
|
+
{t(WORKSPACES_I18N_KEYS.invitationsSubtitle)}
|
|
162
|
+
</Typography.Text>
|
|
163
|
+
{page !== null && (
|
|
164
|
+
<Muted testId="invitations-count">
|
|
165
|
+
{tPlural(WORKSPACES_I18N_KEYS.invitationsCount, { count: page.count })}
|
|
166
|
+
</Muted>
|
|
167
|
+
)}
|
|
168
|
+
</Flex>
|
|
169
|
+
|
|
170
|
+
<Flex gap={spacing["3"]} wrap align="center" style={{ marginTop: spacing["4"] }}>
|
|
171
|
+
<Segmented<InvitationStatusFilter>
|
|
172
|
+
value={status}
|
|
173
|
+
onChange={(next) => restart(() => setStatus(next))}
|
|
174
|
+
aria-label={t(WORKSPACES_I18N_KEYS.invitationsFilterLabel)}
|
|
175
|
+
data-testid="invitations-filter"
|
|
176
|
+
options={[
|
|
177
|
+
{ value: "pending", label: t(WORKSPACES_I18N_KEYS.invitationsFilterPending) },
|
|
178
|
+
{
|
|
179
|
+
value: "never_accepted",
|
|
180
|
+
label: t(WORKSPACES_I18N_KEYS.invitationsFilterNeverAccepted),
|
|
181
|
+
},
|
|
182
|
+
{ value: "all", label: t(WORKSPACES_I18N_KEYS.invitationsFilterAll) },
|
|
183
|
+
]}
|
|
184
|
+
/>
|
|
185
|
+
<Input
|
|
186
|
+
value={search}
|
|
187
|
+
onChange={(event) => restart(() => setSearch(event.target.value))}
|
|
188
|
+
placeholder={t(WORKSPACES_I18N_KEYS.invitationsSearchPlaceholder)}
|
|
189
|
+
aria-label={t(WORKSPACES_I18N_KEYS.invitationsSearchPlaceholder)}
|
|
190
|
+
allowClear
|
|
191
|
+
style={{ flex: "1 1 12rem" }}
|
|
192
|
+
data-testid="invitations-search"
|
|
193
|
+
/>
|
|
194
|
+
</Flex>
|
|
195
|
+
|
|
196
|
+
<ErrorAlert
|
|
197
|
+
thrown={revokeMutation.error ?? resendMutation.error ?? renameMutation.error}
|
|
198
|
+
style={{ marginTop: spacing["3"] }}
|
|
199
|
+
testId="invitations-write-error"
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
{!canManage && (
|
|
203
|
+
<div style={{ marginTop: spacing["2"] }}>
|
|
204
|
+
<Muted testId="invitations-read-only">
|
|
205
|
+
{t(WORKSPACES_I18N_KEYS.membersBlockedReadOnly)}
|
|
206
|
+
</Muted>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
<div style={{ marginTop: spacing["4"] }}>
|
|
211
|
+
<LoadList
|
|
212
|
+
state={mapLoad(loadStateFromQuery(query), (loaded) => loaded.items)}
|
|
213
|
+
testId="invitations-list"
|
|
214
|
+
onRetry={() => {
|
|
215
|
+
void query.refetch();
|
|
216
|
+
}}
|
|
217
|
+
empty={
|
|
218
|
+
<EmptyState
|
|
219
|
+
title={t(WORKSPACES_I18N_KEYS.invitationsEmpty)}
|
|
220
|
+
testId="invitations-list-empty"
|
|
221
|
+
/>
|
|
222
|
+
}
|
|
223
|
+
>
|
|
224
|
+
{(invitations) => (
|
|
225
|
+
<div role="list" data-testid="invitations-rows">
|
|
226
|
+
{invitations.map((invitation) => (
|
|
227
|
+
<InvitationRow
|
|
228
|
+
key={invitation.id}
|
|
229
|
+
invitation={invitation}
|
|
230
|
+
canManage={canManage}
|
|
231
|
+
onRename={() => setRenaming(invitation)}
|
|
232
|
+
onResend={() => setResending(invitation)}
|
|
233
|
+
onRevoke={() => setRevoking(invitation)}
|
|
234
|
+
/>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</LoadList>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
{page !== null && (
|
|
242
|
+
<AnchorPager
|
|
243
|
+
hasPrev={page.has_prev}
|
|
244
|
+
hasNext={page.has_next}
|
|
245
|
+
prevLabel={t(WORKSPACES_I18N_KEYS.pagerPrev)}
|
|
246
|
+
nextLabel={t(WORKSPACES_I18N_KEYS.pagerNext)}
|
|
247
|
+
position={t(WORKSPACES_I18N_KEYS.pagerPosition, { page: walk.index })}
|
|
248
|
+
testId="invitations-pager"
|
|
249
|
+
onPrev={() =>
|
|
250
|
+
setWalk({
|
|
251
|
+
anchor: page.prev_anchor ?? undefined,
|
|
252
|
+
direction: "prev",
|
|
253
|
+
index: Math.max(1, walk.index - 1),
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
onNext={() =>
|
|
257
|
+
setWalk({
|
|
258
|
+
anchor: page.next_anchor ?? undefined,
|
|
259
|
+
direction: "next",
|
|
260
|
+
index: walk.index + 1,
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
|
|
266
|
+
<SkinConfirm
|
|
267
|
+
open={revoking !== null}
|
|
268
|
+
danger
|
|
269
|
+
title={t(WORKSPACES_I18N_KEYS.invitationsRevokeConfirm)}
|
|
270
|
+
body={t(WORKSPACES_I18N_KEYS.invitationsRevokeConfirmBody, {
|
|
271
|
+
email: revoking?.email ?? "",
|
|
272
|
+
})}
|
|
273
|
+
confirmLabel={t(WORKSPACES_I18N_KEYS.invitationsRevoke)}
|
|
274
|
+
cancelLabel={t(WORKSPACES_I18N_KEYS.cancel)}
|
|
275
|
+
confirming={revokeMutation.isPending}
|
|
276
|
+
onConfirm={() => {
|
|
277
|
+
if (revoking !== null) revokeMutation.mutate(revoking.id);
|
|
278
|
+
setRevoking(null);
|
|
279
|
+
}}
|
|
280
|
+
onCancel={() => setRevoking(null)}
|
|
281
|
+
data-testid="invitations-revoke-confirm"
|
|
282
|
+
/>
|
|
283
|
+
|
|
284
|
+
<SkinConfirm
|
|
285
|
+
open={resending !== null}
|
|
286
|
+
title={t(WORKSPACES_I18N_KEYS.invitationsResendConfirm)}
|
|
287
|
+
body={t(WORKSPACES_I18N_KEYS.invitationsResendConfirmBody, {
|
|
288
|
+
email: resending?.email ?? "",
|
|
289
|
+
})}
|
|
290
|
+
confirmLabel={t(WORKSPACES_I18N_KEYS.invitationsResend)}
|
|
291
|
+
cancelLabel={t(WORKSPACES_I18N_KEYS.cancel)}
|
|
292
|
+
confirming={resendMutation.isPending}
|
|
293
|
+
onConfirm={() => {
|
|
294
|
+
if (resending !== null) resendMutation.mutate(resending.id);
|
|
295
|
+
setResending(null);
|
|
296
|
+
}}
|
|
297
|
+
onCancel={() => setResending(null)}
|
|
298
|
+
data-testid="invitations-resend-confirm"
|
|
299
|
+
/>
|
|
300
|
+
|
|
301
|
+
<RenameInvitationDialog
|
|
302
|
+
invitation={renaming}
|
|
303
|
+
onClose={() => setRenaming(null)}
|
|
304
|
+
isRenaming={renameMutation.isPending}
|
|
305
|
+
onRename={(displayName) => {
|
|
306
|
+
if (renaming !== null) {
|
|
307
|
+
renameMutation.mutate({ invitationId: renaming.id, displayName });
|
|
308
|
+
}
|
|
309
|
+
setRenaming(null);
|
|
310
|
+
}}
|
|
311
|
+
/>
|
|
312
|
+
</Card>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Only a live invitation can be withdrawn or corrected. */
|
|
317
|
+
function pendingOnly(invitation: Invitation): ActionAvailability {
|
|
318
|
+
return invitation.status === "pending"
|
|
319
|
+
? actionAvailable()
|
|
320
|
+
: actionBlocked(WORKSPACES_I18N_KEYS.invitationsBlockedTerminal);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Resend also accepts an expired row — reviving a dead TTL is what it is
|
|
324
|
+
* mostly for — and refuses the three terminal states. */
|
|
325
|
+
function resendable(invitation: Invitation): ActionAvailability {
|
|
326
|
+
return invitation.status === "pending" || invitation.status === "expired"
|
|
327
|
+
? actionAvailable()
|
|
328
|
+
: actionBlocked(WORKSPACES_I18N_KEYS.invitationsBlockedResendTerminal);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The row's refusal as ONE sentence, or `null` when nothing on it is refused.
|
|
333
|
+
*
|
|
334
|
+
* Both gates above answer the same question — what became of this invitation —
|
|
335
|
+
* and the row's status tag has already named it. So a row states the
|
|
336
|
+
* consequence once, in the shape a person reads it ("nothing more can be done
|
|
337
|
+
* with it" / "only resending is left"), instead of repeating a four-way
|
|
338
|
+
* disjunction under every button.
|
|
339
|
+
*/
|
|
340
|
+
function rowReasonKey(invitation: Invitation): string | null {
|
|
341
|
+
const liveRefused = !pendingOnly(invitation).available;
|
|
342
|
+
const resendRefused = !resendable(invitation).available;
|
|
343
|
+
if (liveRefused && resendRefused) {
|
|
344
|
+
return WORKSPACES_I18N_KEYS.invitationsBlockedRowClosed;
|
|
345
|
+
}
|
|
346
|
+
if (liveRefused) return WORKSPACES_I18N_KEYS.invitationsBlockedRowResendOnly;
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function InvitationRow(props: {
|
|
351
|
+
readonly invitation: Invitation;
|
|
352
|
+
readonly canManage: boolean;
|
|
353
|
+
readonly onRename: () => void;
|
|
354
|
+
readonly onResend: () => void;
|
|
355
|
+
readonly onRevoke: () => void;
|
|
356
|
+
}): ReactElement {
|
|
357
|
+
const t = useT();
|
|
358
|
+
const format = useWorkspaceFormat();
|
|
359
|
+
const { token } = antdTheme.useToken();
|
|
360
|
+
const { invitation } = props;
|
|
361
|
+
const statusKey = STATUS_KEY[invitation.status];
|
|
362
|
+
const expires = format.relative(invitation.expires_at);
|
|
363
|
+
const lastSent = format.relative(invitation.last_sent_at);
|
|
364
|
+
const reasonKey = rowReasonKey(invitation);
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<div
|
|
368
|
+
role="listitem"
|
|
369
|
+
data-testid={`invitation-row-${invitation.id}`}
|
|
370
|
+
style={{
|
|
371
|
+
display: "flex",
|
|
372
|
+
flexWrap: "wrap",
|
|
373
|
+
alignItems: "center",
|
|
374
|
+
justifyContent: "space-between",
|
|
375
|
+
gap: spacing["3"],
|
|
376
|
+
paddingTop: spacing["3"],
|
|
377
|
+
paddingBottom: spacing["3"],
|
|
378
|
+
borderBottom: `1px solid ${token.colorSplit}`,
|
|
379
|
+
}}
|
|
380
|
+
>
|
|
381
|
+
<PersonLine
|
|
382
|
+
name={invitation.display_name ?? null}
|
|
383
|
+
email={invitation.email}
|
|
384
|
+
tags={
|
|
385
|
+
<StatusTag
|
|
386
|
+
tone={STATUS_TONE[invitation.status] ?? "neutral"}
|
|
387
|
+
testId={`invitation-status-${invitation.id}`}
|
|
388
|
+
>
|
|
389
|
+
{statusKey === undefined ? invitation.status : t(statusKey)}
|
|
390
|
+
</StatusTag>
|
|
391
|
+
}
|
|
392
|
+
caption={
|
|
393
|
+
<>
|
|
394
|
+
<RoleLabel role={invitation.role} />
|
|
395
|
+
{expires !== null && (
|
|
396
|
+
<>
|
|
397
|
+
{" · "}
|
|
398
|
+
{t(WORKSPACES_I18N_KEYS.invitationsExpiresLabel)}: {expires}
|
|
399
|
+
</>
|
|
400
|
+
)}
|
|
401
|
+
{" · "}
|
|
402
|
+
{lastSent === null
|
|
403
|
+
? t(WORKSPACES_I18N_KEYS.invitationsSentNever)
|
|
404
|
+
: `${t(WORKSPACES_I18N_KEYS.invitationsSentLabel)}: ${lastSent}`}
|
|
405
|
+
</>
|
|
406
|
+
}
|
|
407
|
+
/>
|
|
408
|
+
{props.canManage && (
|
|
409
|
+
<RowActions
|
|
410
|
+
testId={`invitation-blocked-${invitation.id}`}
|
|
411
|
+
{...(reasonKey !== null ? { reason: t(reasonKey) } : {})}
|
|
412
|
+
actions={[
|
|
413
|
+
{
|
|
414
|
+
key: "rename",
|
|
415
|
+
gate: pendingOnly(invitation),
|
|
416
|
+
label: t(WORKSPACES_I18N_KEYS.invitationsRename),
|
|
417
|
+
onClick: props.onRename,
|
|
418
|
+
testId: `invitation-rename-${invitation.id}`,
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
key: "resend",
|
|
422
|
+
gate: resendable(invitation),
|
|
423
|
+
label: t(WORKSPACES_I18N_KEYS.invitationsResend),
|
|
424
|
+
onClick: props.onResend,
|
|
425
|
+
testId: `invitation-resend-${invitation.id}`,
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
key: "revoke",
|
|
429
|
+
gate: pendingOnly(invitation),
|
|
430
|
+
label: t(WORKSPACES_I18N_KEYS.invitationsRevoke),
|
|
431
|
+
onClick: props.onRevoke,
|
|
432
|
+
danger: true,
|
|
433
|
+
testId: `invitation-revoke-${invitation.id}`,
|
|
434
|
+
},
|
|
435
|
+
]}
|
|
436
|
+
/>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The name correction one step before the member rename: the invitee has not
|
|
444
|
+
* accepted, so there is no profile to write and the hint lives on the
|
|
445
|
+
* invitation. `accept_invitation` copies it onto the membership.
|
|
446
|
+
*/
|
|
447
|
+
function RenameInvitationDialog(props: {
|
|
448
|
+
readonly invitation: Invitation | null;
|
|
449
|
+
readonly onClose: () => void;
|
|
450
|
+
readonly isRenaming: boolean;
|
|
451
|
+
readonly onRename: (displayName: string | null) => void;
|
|
452
|
+
}): ReactElement {
|
|
453
|
+
const t = useT();
|
|
454
|
+
const current = props.invitation?.display_name ?? "";
|
|
455
|
+
const [value, setValue] = useState(current);
|
|
456
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
457
|
+
|
|
458
|
+
if (props.invitation !== null && editingId !== props.invitation.id) {
|
|
459
|
+
setEditingId(props.invitation.id);
|
|
460
|
+
setValue(props.invitation.display_name ?? "");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const gate =
|
|
464
|
+
value.trim() === current.trim()
|
|
465
|
+
? actionBlocked(WORKSPACES_I18N_KEYS.membersRenameBlockedUnchanged)
|
|
466
|
+
: actionAvailable();
|
|
467
|
+
|
|
468
|
+
return (
|
|
469
|
+
<SkinDialog
|
|
470
|
+
open={props.invitation !== null}
|
|
471
|
+
onClose={props.onClose}
|
|
472
|
+
title={t(WORKSPACES_I18N_KEYS.invitationsRenameDialogTitle)}
|
|
473
|
+
dismissLabel={t(WORKSPACES_I18N_KEYS.dialogClose)}
|
|
474
|
+
data-testid="invitations-rename-dialog"
|
|
475
|
+
footer={
|
|
476
|
+
<GatedButton
|
|
477
|
+
gate={gate}
|
|
478
|
+
type="primary"
|
|
479
|
+
loading={props.isRenaming}
|
|
480
|
+
onClick={() => props.onRename(value.trim() === "" ? null : value.trim())}
|
|
481
|
+
testId="invitations-rename-submit"
|
|
482
|
+
data-analytics="none"
|
|
483
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
484
|
+
>
|
|
485
|
+
{t(WORKSPACES_I18N_KEYS.membersRenameSubmit)}
|
|
486
|
+
</GatedButton>
|
|
487
|
+
}
|
|
488
|
+
>
|
|
489
|
+
<Flex vertical gap={spacing["2"]}>
|
|
490
|
+
<Typography.Text>{t(WORKSPACES_I18N_KEYS.membersRenameLabel)}</Typography.Text>
|
|
491
|
+
<Input
|
|
492
|
+
value={value}
|
|
493
|
+
onChange={(event) => setValue(event.target.value)}
|
|
494
|
+
placeholder={t(WORKSPACES_I18N_KEYS.membersRenamePlaceholder)}
|
|
495
|
+
aria-label={t(WORKSPACES_I18N_KEYS.membersRenameLabel)}
|
|
496
|
+
data-testid="invitations-rename-input"
|
|
497
|
+
/>
|
|
498
|
+
</Flex>
|
|
499
|
+
</SkinDialog>
|
|
500
|
+
);
|
|
501
|
+
}
|