@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
@@ -6,10 +6,13 @@
6
6
  * - `renderLoginPanel` — the sign-in surface for a registered invitee
7
7
  * without a session (drop auth-react's `<AuthPanel/>` in; call
8
8
  * `onLoggedIn(email)` — or simply let the `sessionEmail` prop update,
9
- * the flow re-routes on it automatically).
9
+ * the flow re-routes on it automatically). Unfilled, it renders a
10
+ * `SlotPlaceholder` in dev rather than a hole: an accept page with no way
11
+ * to sign in looks finished, which is the one defect nobody reports.
10
12
  * - `renderInitialSetup` — the basic-data step for a freshly created
11
13
  * account (drop profiles-react's `InitialSetupPrompt`/`InitialSetupModal`
12
- * in; call `onDone()`). Omitted → the step auto-skips.
14
+ * in; call `onDone()`). Omitted → the step auto-skips, which is a
15
+ * deliberate journey decision, not an unfilled hole.
13
16
  *
14
17
  * THE GRANT SEAM (§B4): for a NEW user the flow mints a login grant and this
15
18
  * page hands it to YOUR `onLoginGrant(grantToken)` — wire it to auth-react's
@@ -18,18 +21,39 @@
18
21
  * (`grantExchanged`); a rejection shows a retry. The pairs stay decoupled:
19
22
  * this package never imports auth-react.
20
23
  *
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
+ * ## One primary per screen
25
+ *
26
+ * The visual pass photographed five solid blue buttons on this page, with
27
+ * "Decline" — irreversible, and the one the workspace is told about —
28
+ * indistinguishable from "Accept". Every state here now has at most ONE
29
+ * primary: joining. Declining is a quiet danger link behind a confirm that
30
+ * says what it costs. Failures come from the substrate's `ErrorAlert`, so a
31
+ * 429 on the claim reads "you can request another one in 42 seconds" (the
32
+ * backend's keyed `error.429.invitation_grant_pending` with its `retry_after`)
33
+ * instead of a generic shrug.
24
34
  */
25
35
  import { useEffect, useRef, useState } from "react";
26
36
  import type { ReactElement, ReactNode } from "react";
27
- import { Alert, Button, Card, Flex, Result, Spin, Typography } from "antd";
28
- import { useFormatFlowError, useT } from "@stapel/core";
37
+ import { Button, Card, Flex, Result, Skeleton, Typography } from "antd";
38
+ import {
39
+ SlotPlaceholder,
40
+ actionAvailable,
41
+ actionBlocked,
42
+ useDescribeFlowError,
43
+ useT,
44
+ } from "@stapel/core";
45
+ import {
46
+ ErrorAlert,
47
+ GatedButton,
48
+ SkinConfirm,
49
+ SkinTheme,
50
+ } from "@stapel/tokens-antd/skin";
51
+ import { spacing } from "@stapel/tokens";
29
52
  import { InviteAcceptFlow } from "../headless/InviteAcceptFlow.js";
30
53
  import type { InviteAcceptFlowBag } from "../headless/InviteAcceptFlow.js";
31
54
  import type { InvitationPreview, Member } from "../api/types.js";
32
55
  import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
56
+ import { useRoleLabel } from "./RoleSelectField.js";
33
57
 
34
58
  export interface InviteAcceptPageProps {
35
59
  /** The invite token from the route — a bearer secret, never log it. */
@@ -50,37 +74,47 @@ export interface InviteAcceptPageProps {
50
74
  readonly renderInitialSetup?: (ctx: { onDone: () => void }) => ReactNode;
51
75
  /** Wrong-account CTA (sign out / switch). Omitted → CTA hidden. */
52
76
  readonly onSwitchAccount?: () => void;
77
+ /**
78
+ * Leave the invitation page — the door out of a terminal state (expired,
79
+ * revoked, already used), where there is nothing left to do here. The pair
80
+ * owns no router, so omitting it draws no button and the page states the
81
+ * next step in words instead of offering one that goes nowhere.
82
+ */
83
+ readonly onExit?: () => void;
53
84
  readonly onAccepted?: (member: Member) => void;
54
85
  readonly onDeclined?: () => void;
55
86
  }
56
87
 
57
88
  export function InviteAcceptPage(props: InviteAcceptPageProps): ReactElement {
58
89
  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>
90
+ <SkinTheme surface="base" data-testid="invite-accept-root">
91
+ <InviteAcceptFlow
92
+ token={props.token}
93
+ sessionEmail={props.sessionEmail ?? null}
94
+ {...(props.onAccepted !== undefined ? { onAccepted: props.onAccepted } : {})}
95
+ {...(props.onDeclined !== undefined ? { onDeclined: props.onDeclined } : {})}
96
+ >
97
+ {(bag) => (
98
+ <PageBody
99
+ bag={bag}
100
+ sessionEmail={props.sessionEmail ?? null}
101
+ {...(props.onLoginGrant !== undefined
102
+ ? { onLoginGrant: props.onLoginGrant }
103
+ : {})}
104
+ {...(props.renderLoginPanel !== undefined
105
+ ? { renderLoginPanel: props.renderLoginPanel }
106
+ : {})}
107
+ {...(props.renderInitialSetup !== undefined
108
+ ? { renderInitialSetup: props.renderInitialSetup }
109
+ : {})}
110
+ {...(props.onSwitchAccount !== undefined
111
+ ? { onSwitchAccount: props.onSwitchAccount }
112
+ : {})}
113
+ {...(props.onExit !== undefined ? { onExit: props.onExit } : {})}
114
+ />
115
+ )}
116
+ </InviteAcceptFlow>
117
+ </SkinTheme>
84
118
  );
85
119
  }
86
120
 
@@ -93,15 +127,21 @@ const UNAVAILABLE_KEY: Record<string, string> = {
93
127
 
94
128
  function InviteHeader(props: { preview: InvitationPreview }): ReactElement {
95
129
  const t = useT();
130
+ const labelFor = useRoleLabel();
96
131
  return (
97
- <Flex vertical gap={4}>
132
+ <Flex vertical gap={spacing["1"]}>
98
133
  <Typography.Title level={3} style={{ margin: 0 }}>
99
134
  {t(WORKSPACES_I18N_KEYS.inviteAcceptTitle, {
100
135
  workspace: props.preview.workspace_name,
101
136
  })}
102
137
  </Typography.Title>
138
+ {/* The registry's word for the role, never the wire's key: `secretary`
139
+ lowercase beside title-cased builtins is the same defect the roster
140
+ fixed, and this is the screen a person meets FIRST. */}
103
141
  <Typography.Text type="secondary">
104
- {t(WORKSPACES_I18N_KEYS.inviteRoleLine, { role: props.preview.role })}
142
+ {t(WORKSPACES_I18N_KEYS.inviteRoleLine, {
143
+ role: labelFor(props.preview.role),
144
+ })}
105
145
  </Typography.Text>
106
146
  <Typography.Text type="secondary">
107
147
  {t(WORKSPACES_I18N_KEYS.inviteEmailLine, {
@@ -121,11 +161,13 @@ function PageBody(props: {
121
161
  renderLoginPanel?: (ctx: { onLoggedIn: (email: string) => void }) => ReactNode;
122
162
  renderInitialSetup?: (ctx: { onDone: () => void }) => ReactNode;
123
163
  onSwitchAccount?: () => void;
164
+ onExit?: () => void;
124
165
  }): ReactElement {
125
166
  const t = useT();
126
- const formatError = useFormatFlowError();
167
+ const describeError = useDescribeFlowError();
127
168
  const { bag } = props;
128
169
  const s = bag.state;
170
+ const [confirmingDecline, setConfirmingDecline] = useState(false);
129
171
 
130
172
  // ── the grant seam: run the host's exchange when the grant is issued ──────
131
173
  const { onLoginGrant, renderInitialSetup } = props;
@@ -162,11 +204,11 @@ function PageBody(props: {
162
204
  case "loadingPreview":
163
205
  return (
164
206
  <Card data-testid="invite-accept-page">
165
- <Flex vertical align="center" gap="middle">
207
+ <Flex vertical gap={spacing["3"]} role="status" aria-busy="true">
166
208
  <Typography.Text type="secondary">
167
209
  {t(WORKSPACES_I18N_KEYS.inviteLoading)}
168
210
  </Typography.Text>
169
- <Spin />
211
+ <Skeleton active paragraph={{ rows: 2 }} />
170
212
  </Flex>
171
213
  </Card>
172
214
  );
@@ -174,20 +216,45 @@ function PageBody(props: {
174
216
  case "previewError":
175
217
  return (
176
218
  <Card data-testid="invite-accept-page">
177
- <Alert type="error" showIcon message={formatError(s.error)} />
219
+ <ErrorAlert error={describeError(s.error)} testId="invite-preview-error" />
178
220
  </Card>
179
221
  );
180
222
 
223
+ // A dead end, and drawn as one. It used to be antd's INFO result — a
224
+ // blue 88px "!" disc, the same pictogram a tip uses — with nothing to do
225
+ // next. The link cannot be revived from here, so the screen says what
226
+ // became of it, in the warning tone that means "this did not work", and
227
+ // names the one thing that does help: asking for another one.
181
228
  case "unavailable":
182
229
  return (
183
230
  <Card data-testid="invite-accept-page">
184
231
  <Result
185
- status="info"
232
+ status="warning"
186
233
  title={s.preview.workspace_name}
187
234
  subTitle={t(
188
235
  UNAVAILABLE_KEY[s.status] ??
189
236
  WORKSPACES_I18N_KEYS.inviteUnavailableRevoked
190
237
  )}
238
+ extra={
239
+ <Flex vertical gap={spacing["3"]} align="center">
240
+ <Typography.Text type="secondary">
241
+ {t(WORKSPACES_I18N_KEYS.inviteUnavailableNextStep, {
242
+ workspace: s.preview.workspace_name,
243
+ })}
244
+ </Typography.Text>
245
+ {props.onExit !== undefined && (
246
+ <Button
247
+ type="primary"
248
+ onClick={props.onExit}
249
+ data-analytics="none"
250
+ data-analytics-reason="navigation — the host owns the router"
251
+ data-testid="invite-exit"
252
+ >
253
+ {t(WORKSPACES_I18N_KEYS.inviteExitCta)}
254
+ </Button>
255
+ )}
256
+ </Flex>
257
+ }
191
258
  />
192
259
  </Card>
193
260
  );
@@ -195,22 +262,29 @@ function PageBody(props: {
195
262
  case "wrongAccount":
196
263
  return (
197
264
  <Card data-testid="invite-accept-page">
198
- <Flex vertical gap="middle">
265
+ <Flex vertical gap={spacing["4"]}>
199
266
  <InviteHeader preview={s.preview} />
200
- <Alert
201
- type="warning"
202
- showIcon
267
+ <ErrorAlert
203
268
  message={t(WORKSPACES_I18N_KEYS.inviteWrongAccount)}
204
- description={t(WORKSPACES_I18N_KEYS.inviteWrongAccountHint, {
269
+ detail={t(WORKSPACES_I18N_KEYS.inviteWrongAccountHint, {
205
270
  email: props.sessionEmail ?? "",
206
271
  invited: s.preview.email_masked,
207
272
  })}
273
+ testId="invite-wrong-account"
274
+ {...(props.onSwitchAccount !== undefined
275
+ ? {
276
+ action: (
277
+ <Button
278
+ onClick={props.onSwitchAccount}
279
+ data-analytics="flow"
280
+ data-testid="invite-switch-account"
281
+ >
282
+ {t(WORKSPACES_I18N_KEYS.inviteSwitchAccountCta)}
283
+ </Button>
284
+ ),
285
+ }
286
+ : {})}
208
287
  />
209
- {props.onSwitchAccount && (
210
- <Button onClick={props.onSwitchAccount} data-analytics="flow">
211
- {t(WORKSPACES_I18N_KEYS.inviteSwitchAccountCta)}
212
- </Button>
213
- )}
214
288
  </Flex>
215
289
  </Card>
216
290
  );
@@ -218,14 +292,18 @@ function PageBody(props: {
218
292
  case "loginRequired":
219
293
  return (
220
294
  <Card data-testid="invite-accept-page">
221
- <Flex vertical gap="middle">
295
+ <Flex vertical gap={spacing["4"]}>
222
296
  <InviteHeader preview={s.preview} />
223
297
  <Typography.Text strong>
224
298
  {t(WORKSPACES_I18N_KEYS.inviteLoginTitle)}
225
299
  </Typography.Text>
226
- {props.renderLoginPanel?.({
227
- onLoggedIn: (email) => bag.sessionEstablished(email),
228
- })}
300
+ {props.renderLoginPanel !== undefined ? (
301
+ props.renderLoginPanel({
302
+ onLoggedIn: (email) => bag.sessionEstablished(email),
303
+ })
304
+ ) : (
305
+ <SlotPlaceholder name="renderLoginPanel" data-testid="invite-login-slot" />
306
+ )}
229
307
  </Flex>
230
308
  </Card>
231
309
  );
@@ -235,7 +313,7 @@ function PageBody(props: {
235
313
  case "claimError":
236
314
  return (
237
315
  <Card data-testid="invite-accept-page">
238
- <Flex vertical gap="middle">
316
+ <Flex vertical gap={spacing["4"]}>
239
317
  <InviteHeader preview={s.preview} />
240
318
  <Typography.Text type="secondary">
241
319
  {t(WORKSPACES_I18N_KEYS.inviteNewUserHint, {
@@ -243,13 +321,14 @@ function PageBody(props: {
243
321
  })}
244
322
  </Typography.Text>
245
323
  {s.step === "claimError" && (
246
- <Alert type="error" showIcon message={formatError(s.error)} />
324
+ <ErrorAlert error={describeError(s.error)} testId="invite-claim-error" />
247
325
  )}
248
326
  <Button
249
327
  type="primary"
250
328
  loading={s.step === "claiming"}
251
329
  onClick={() => bag.claim()}
252
330
  data-analytics="flow"
331
+ data-testid="invite-create-account"
253
332
  >
254
333
  {t(
255
334
  s.step === "claiming"
@@ -264,33 +343,25 @@ function PageBody(props: {
264
343
  case "grantIssued":
265
344
  return (
266
345
  <Card data-testid="invite-accept-page">
267
- <Flex vertical align="center" gap="middle">
346
+ <Flex vertical gap={spacing["3"]}>
268
347
  {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
- </>
348
+ <ErrorAlert
349
+ message={t(WORKSPACES_I18N_KEYS.inviteExchangeFailed)}
350
+ retryLabel={t(WORKSPACES_I18N_KEYS.inviteRetryCta)}
351
+ testId="invite-exchange-error"
352
+ onRetry={() => {
353
+ // Re-arm the effect for the same grant token.
354
+ exchangedRef.current = null;
355
+ setExchangeFailed(false);
356
+ }}
357
+ />
287
358
  ) : (
288
- <>
359
+ <Flex vertical gap={spacing["3"]} role="status" aria-busy="true">
289
360
  <Typography.Text type="secondary">
290
361
  {t(WORKSPACES_I18N_KEYS.inviteExchanging)}
291
362
  </Typography.Text>
292
- <Spin />
293
- </>
363
+ <Skeleton active paragraph={{ rows: 1 }} />
364
+ </Flex>
294
365
  )}
295
366
  </Flex>
296
367
  </Card>
@@ -299,14 +370,14 @@ function PageBody(props: {
299
370
  case "basicData":
300
371
  return (
301
372
  <Card data-testid="invite-accept-page">
302
- <Flex vertical gap="middle">
373
+ <Flex vertical gap={spacing["4"]}>
303
374
  <Typography.Title level={4} style={{ margin: 0 }}>
304
375
  {t(WORKSPACES_I18N_KEYS.inviteBasicDataTitle)}
305
376
  </Typography.Title>
306
- {renderInitialSetup ? (
377
+ {renderInitialSetup !== undefined ? (
307
378
  renderInitialSetup({ onDone: () => bag.completeBasicData() })
308
379
  ) : (
309
- <Spin />
380
+ <Skeleton active paragraph={{ rows: 1 }} />
310
381
  )}
311
382
  </Flex>
312
383
  </Card>
@@ -315,37 +386,63 @@ function PageBody(props: {
315
386
  case "acceptPrompt":
316
387
  case "accepting":
317
388
  case "declining":
318
- case "acceptError":
389
+ case "acceptError": {
390
+ const busy = s.step === "accepting" || s.step === "declining";
391
+ const busyGate = busy
392
+ ? actionBlocked(WORKSPACES_I18N_KEYS.inviteBlockedBusy)
393
+ : actionAvailable();
319
394
  return (
320
395
  <Card data-testid="invite-accept-page">
321
- <Flex vertical gap="middle">
396
+ <Flex vertical gap={spacing["4"]}>
322
397
  <InviteHeader preview={s.preview} />
323
398
  {s.step === "acceptError" && (
324
- <Alert type="error" showIcon message={formatError(s.error)} />
399
+ <ErrorAlert error={describeError(s.error)} testId="invite-accept-error" />
325
400
  )}
326
- <Flex gap="small">
401
+ {/* One primary — joining. Declining is the quiet, irreversible
402
+ path and sits under it behind a confirm. */}
403
+ <Flex vertical gap={spacing["2"]} align="flex-start">
327
404
  <Button
328
405
  type="primary"
406
+ block
329
407
  loading={s.step === "accepting"}
330
- disabled={s.step === "declining"}
331
408
  onClick={() => bag.accept()}
332
409
  data-analytics="flow"
410
+ data-testid="invite-join"
333
411
  >
334
412
  {t(WORKSPACES_I18N_KEYS.inviteJoinCta)}
335
413
  </Button>
336
- <Button
414
+ <GatedButton
415
+ gate={busyGate}
337
416
  danger
417
+ type="link"
338
418
  loading={s.step === "declining"}
339
- disabled={s.step === "accepting"}
340
- onClick={() => bag.decline()}
341
- data-analytics="flow"
419
+ onClick={() => setConfirmingDecline(true)}
420
+ testId="invite-decline"
421
+ data-analytics="none"
422
+ data-analytics-reason="opens the decline confirm"
342
423
  >
343
424
  {t(WORKSPACES_I18N_KEYS.inviteDeclineCta)}
344
- </Button>
425
+ </GatedButton>
345
426
  </Flex>
427
+ <SkinConfirm
428
+ open={confirmingDecline}
429
+ danger
430
+ title={t(WORKSPACES_I18N_KEYS.inviteDeclineConfirm)}
431
+ body={t(WORKSPACES_I18N_KEYS.inviteDeclineConfirmBody)}
432
+ confirmLabel={t(WORKSPACES_I18N_KEYS.inviteDeclineCta)}
433
+ cancelLabel={t(WORKSPACES_I18N_KEYS.cancel)}
434
+ confirming={s.step === "declining"}
435
+ onConfirm={() => {
436
+ bag.decline();
437
+ setConfirmingDecline(false);
438
+ }}
439
+ onCancel={() => setConfirmingDecline(false)}
440
+ data-testid="invite-decline-confirm"
441
+ />
346
442
  </Flex>
347
443
  </Card>
348
444
  );
445
+ }
349
446
 
350
447
  case "accepted":
351
448
  return (