@stapel/workspaces-react 0.5.0 → 0.7.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/api/generated/schema.d.ts +384 -4
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +20 -5
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/workspacesApi.d.ts +23 -1
  7. package/dist/api/workspacesApi.d.ts.map +1 -1
  8. package/dist/api/workspacesApi.js +4 -0
  9. package/dist/api/workspacesApi.js.map +1 -1
  10. package/dist/default/InviteAcceptPage.d.ts +28 -0
  11. package/dist/default/InviteAcceptPage.d.ts.map +1 -0
  12. package/dist/default/InviteAcceptPage.js +141 -0
  13. package/dist/default/InviteAcceptPage.js.map +1 -0
  14. package/dist/default/MembersManager.d.ts +4 -4
  15. package/dist/default/MembersManager.d.ts.map +1 -1
  16. package/dist/default/MembersManager.js +61 -56
  17. package/dist/default/MembersManager.js.map +1 -1
  18. package/dist/default/index.d.ts +2 -0
  19. package/dist/default/index.d.ts.map +1 -1
  20. package/dist/default/index.js +1 -0
  21. package/dist/default/index.js.map +1 -1
  22. package/dist/flows/inviteAcceptFlow.d.ts +155 -0
  23. package/dist/flows/inviteAcceptFlow.d.ts.map +1 -0
  24. package/dist/flows/inviteAcceptFlow.js +126 -0
  25. package/dist/flows/inviteAcceptFlow.js.map +1 -0
  26. package/dist/headless/Can.d.ts +40 -0
  27. package/dist/headless/Can.d.ts.map +1 -0
  28. package/dist/headless/Can.js +30 -0
  29. package/dist/headless/Can.js.map +1 -0
  30. package/dist/headless/InviteAcceptFlow.d.ts +44 -0
  31. package/dist/headless/InviteAcceptFlow.d.ts.map +1 -0
  32. package/dist/headless/InviteAcceptFlow.js +67 -0
  33. package/dist/headless/InviteAcceptFlow.js.map +1 -0
  34. package/dist/headless/RoleSelect.d.ts +41 -0
  35. package/dist/headless/RoleSelect.d.ts.map +1 -0
  36. package/dist/headless/RoleSelect.js +34 -0
  37. package/dist/headless/RoleSelect.js.map +1 -0
  38. package/dist/i18n/generated/errors.gen.d.ts +48 -0
  39. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  40. package/dist/i18n/generated/errors.gen.js +24 -0
  41. package/dist/i18n/generated/errors.gen.js.map +1 -1
  42. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  43. package/dist/i18n/generated/errors.ru.gen.js +8 -0
  44. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  45. package/dist/i18n/keys.d.ts +28 -0
  46. package/dist/i18n/keys.d.ts.map +1 -1
  47. package/dist/i18n/keys.js +64 -0
  48. package/dist/i18n/keys.js.map +1 -1
  49. package/dist/i18n/ru.d.ts.map +1 -1
  50. package/dist/i18n/ru.js +30 -0
  51. package/dist/i18n/ru.js.map +1 -1
  52. package/dist/index.d.ts +14 -3
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +9 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/model/capabilities.d.ts +21 -0
  57. package/dist/model/capabilities.d.ts.map +1 -0
  58. package/dist/model/capabilities.js +32 -0
  59. package/dist/model/capabilities.js.map +1 -0
  60. package/dist/model/emailMask.d.ts +22 -0
  61. package/dist/model/emailMask.d.ts.map +1 -0
  62. package/dist/model/emailMask.js +36 -0
  63. package/dist/model/emailMask.js.map +1 -0
  64. package/dist/model/mutations.d.ts +23 -3
  65. package/dist/model/mutations.d.ts.map +1 -1
  66. package/dist/model/mutations.js +39 -1
  67. package/dist/model/mutations.js.map +1 -1
  68. package/dist/model/queries.d.ts +56 -6
  69. package/dist/model/queries.d.ts.map +1 -1
  70. package/dist/model/queries.js +75 -7
  71. package/dist/model/queries.js.map +1 -1
  72. package/dist/model/queryKeys.d.ts +2 -0
  73. package/dist/model/queryKeys.d.ts.map +1 -1
  74. package/dist/model/queryKeys.js +6 -0
  75. package/dist/model/queryKeys.js.map +1 -1
  76. package/llms.txt +40 -15
  77. package/manifest.json +225 -3
  78. package/package.json +5 -5
  79. package/src/analytics/generated/events.json +1 -1
  80. package/src/api/generated/schema.ts +384 -4
  81. package/src/api/types.ts +20 -5
  82. package/src/api/workspacesApi.ts +50 -0
  83. package/src/default/InviteAcceptPage.tsx +373 -0
  84. package/src/default/MembersManager.tsx +155 -145
  85. package/src/default/index.ts +2 -0
  86. package/src/flows/inviteAcceptFlow.ts +289 -0
  87. package/src/headless/Can.tsx +49 -0
  88. package/src/headless/InviteAcceptFlow.tsx +105 -0
  89. package/src/headless/RoleSelect.tsx +58 -0
  90. package/src/i18n/generated/errors.gen.ts +24 -0
  91. package/src/i18n/generated/errors.json +62 -0
  92. package/src/i18n/generated/errors.ru.gen.ts +8 -0
  93. package/src/i18n/keys.ts +68 -0
  94. package/src/i18n/ru.ts +34 -0
  95. package/src/index.ts +26 -0
  96. package/src/model/capabilities.ts +37 -0
  97. package/src/model/emailMask.ts +37 -0
  98. package/src/model/mutations.ts +56 -4
  99. package/src/model/queries.ts +99 -7
  100. package/src/model/queryKeys.ts +10 -0
@@ -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
+ }