@stapel/workspaces-react 0.17.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/README.md +39 -8
  3. package/dist/api/generated/schema.d.ts +187 -60
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/ActiveWorkspace.d.ts +43 -0
  6. package/dist/default/ActiveWorkspace.d.ts.map +1 -0
  7. package/dist/default/ActiveWorkspace.js +33 -0
  8. package/dist/default/ActiveWorkspace.js.map +1 -0
  9. package/dist/default/AuditTrailPane.d.ts +12 -0
  10. package/dist/default/AuditTrailPane.d.ts.map +1 -0
  11. package/dist/default/AuditTrailPane.js +154 -0
  12. package/dist/default/AuditTrailPane.js.map +1 -0
  13. package/dist/default/InvitationsPane.d.ts +19 -0
  14. package/dist/default/InvitationsPane.d.ts.map +1 -0
  15. package/dist/default/InvitationsPane.js +217 -0
  16. package/dist/default/InvitationsPane.js.map +1 -0
  17. package/dist/default/InviteAcceptPage.d.ts +7 -0
  18. package/dist/default/InviteAcceptPage.d.ts.map +1 -1
  19. package/dist/default/InviteAcceptPage.js +74 -38
  20. package/dist/default/InviteAcceptPage.js.map +1 -1
  21. package/dist/default/MembersManager.d.ts +12 -4
  22. package/dist/default/MembersManager.d.ts.map +1 -1
  23. package/dist/default/MembersManager.js +320 -84
  24. package/dist/default/MembersManager.js.map +1 -1
  25. package/dist/default/RoleSelectField.d.ts +64 -0
  26. package/dist/default/RoleSelectField.d.ts.map +1 -0
  27. package/dist/default/RoleSelectField.js +78 -0
  28. package/dist/default/RoleSelectField.js.map +1 -0
  29. package/dist/default/WorkspaceSettings.d.ts +7 -1
  30. package/dist/default/WorkspaceSettings.d.ts.map +1 -1
  31. package/dist/default/WorkspaceSettings.js +203 -45
  32. package/dist/default/WorkspaceSettings.js.map +1 -1
  33. package/dist/default/WorkspacesPage.d.ts +13 -0
  34. package/dist/default/WorkspacesPage.d.ts.map +1 -0
  35. package/dist/default/WorkspacesPage.js +109 -0
  36. package/dist/default/WorkspacesPage.js.map +1 -0
  37. package/dist/default/index.d.ts +28 -7
  38. package/dist/default/index.d.ts.map +1 -1
  39. package/dist/default/index.js +24 -7
  40. package/dist/default/index.js.map +1 -1
  41. package/dist/default/parts.d.ts +104 -0
  42. package/dist/default/parts.d.ts.map +1 -0
  43. package/dist/default/parts.js +143 -0
  44. package/dist/default/parts.js.map +1 -0
  45. package/dist/headless/Members.d.ts +59 -14
  46. package/dist/headless/Members.d.ts.map +1 -1
  47. package/dist/headless/Members.js +35 -10
  48. package/dist/headless/Members.js.map +1 -1
  49. package/dist/headless/RoleSelect.d.ts +9 -3
  50. package/dist/headless/RoleSelect.d.ts.map +1 -1
  51. package/dist/headless/RoleSelect.js +3 -2
  52. package/dist/headless/RoleSelect.js.map +1 -1
  53. package/dist/i18n/es.d.ts +11 -12
  54. package/dist/i18n/es.d.ts.map +1 -1
  55. package/dist/i18n/es.js +267 -14
  56. package/dist/i18n/es.js.map +1 -1
  57. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  58. package/dist/i18n/generated/errors.es.gen.js +5 -0
  59. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  60. package/dist/i18n/generated/errors.gen.d.ts +30 -0
  61. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  62. package/dist/i18n/generated/errors.gen.js +15 -0
  63. package/dist/i18n/generated/errors.gen.js.map +1 -1
  64. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  65. package/dist/i18n/generated/errors.ru.gen.js +5 -0
  66. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  67. package/dist/i18n/keys.d.ts +209 -1
  68. package/dist/i18n/keys.d.ts.map +1 -1
  69. package/dist/i18n/keys.js +410 -4
  70. package/dist/i18n/keys.js.map +1 -1
  71. package/dist/i18n/ru.d.ts +7 -2
  72. package/dist/i18n/ru.d.ts.map +1 -1
  73. package/dist/i18n/ru.js +209 -4
  74. package/dist/i18n/ru.js.map +1 -1
  75. package/dist/index.d.ts +1 -1
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/model/format.d.ts +56 -0
  80. package/dist/model/format.d.ts.map +1 -0
  81. package/dist/model/format.js +125 -0
  82. package/dist/model/format.js.map +1 -0
  83. package/dist/model/selection.d.ts +15 -0
  84. package/dist/model/selection.d.ts.map +1 -1
  85. package/dist/model/selection.js +18 -1
  86. package/dist/model/selection.js.map +1 -1
  87. package/dist/nav/manifest.d.ts +45 -0
  88. package/dist/nav/manifest.d.ts.map +1 -0
  89. package/dist/nav/manifest.js +77 -0
  90. package/dist/nav/manifest.js.map +1 -0
  91. package/llms.txt +11 -10
  92. package/manifest.json +281 -69
  93. package/package.json +18 -12
  94. package/src/analytics/generated/events.json +1 -1
  95. package/src/api/generated/schema.ts +187 -60
  96. package/src/default/ActiveWorkspace.tsx +97 -0
  97. package/src/default/AuditTrailPane.tsx +309 -0
  98. package/src/default/InvitationsPane.tsx +501 -0
  99. package/src/default/InviteAcceptPage.tsx +187 -90
  100. package/src/default/MembersManager.tsx +832 -222
  101. package/src/default/RoleSelectField.tsx +187 -0
  102. package/src/default/WorkspaceSettings.tsx +540 -125
  103. package/src/default/WorkspacesPage.tsx +394 -0
  104. package/src/default/index.ts +28 -7
  105. package/src/default/parts.tsx +315 -0
  106. package/src/headless/Members.tsx +84 -16
  107. package/src/headless/RoleSelect.tsx +12 -5
  108. package/src/i18n/es.ts +326 -14
  109. package/src/i18n/generated/errors.es.gen.ts +5 -0
  110. package/src/i18n/generated/errors.gen.ts +15 -0
  111. package/src/i18n/generated/errors.json +37 -0
  112. package/src/i18n/generated/errors.ru.gen.ts +5 -0
  113. package/src/i18n/keys.ts +462 -5
  114. package/src/i18n/ru.ts +259 -5
  115. package/src/index.ts +1 -0
  116. package/src/model/format.ts +158 -0
  117. package/src/model/selection.tsx +19 -1
  118. package/src/nav/manifest.ts +120 -0
  119. package/dist/default/ErrorAlert.d.ts +0 -8
  120. package/dist/default/ErrorAlert.d.ts.map +0 -1
  121. package/dist/default/ErrorAlert.js +0 -30
  122. package/dist/default/ErrorAlert.js.map +0 -1
  123. 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
+ }