@stapel/workspaces-react 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/api/generated/schema.d.ts +774 -7
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +104 -7
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/workspacesApi.d.ts +55 -1
  7. package/dist/api/workspacesApi.d.ts.map +1 -1
  8. package/dist/api/workspacesApi.js +23 -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 +17 -4
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +10 -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 +107 -3
  65. package/dist/model/mutations.d.ts.map +1 -1
  66. package/dist/model/mutations.js +190 -1
  67. package/dist/model/mutations.js.map +1 -1
  68. package/dist/model/queries.d.ts +108 -2
  69. package/dist/model/queries.d.ts.map +1 -1
  70. package/dist/model/queries.js +139 -1
  71. package/dist/model/queries.js.map +1 -1
  72. package/dist/model/queryKeys.d.ts +12 -1
  73. package/dist/model/queryKeys.d.ts.map +1 -1
  74. package/dist/model/queryKeys.js +25 -0
  75. package/dist/model/queryKeys.js.map +1 -1
  76. package/dist/model/stepUp.d.ts +25 -0
  77. package/dist/model/stepUp.d.ts.map +1 -0
  78. package/dist/model/stepUp.js +79 -0
  79. package/dist/model/stepUp.js.map +1 -0
  80. package/llms.txt +49 -15
  81. package/manifest.json +346 -3
  82. package/package.json +5 -5
  83. package/src/analytics/generated/events.json +1 -1
  84. package/src/api/generated/schema.ts +774 -7
  85. package/src/api/types.ts +113 -7
  86. package/src/api/workspacesApi.ts +137 -0
  87. package/src/default/InviteAcceptPage.tsx +373 -0
  88. package/src/default/MembersManager.tsx +155 -145
  89. package/src/default/index.ts +2 -0
  90. package/src/flows/inviteAcceptFlow.ts +289 -0
  91. package/src/headless/Can.tsx +49 -0
  92. package/src/headless/InviteAcceptFlow.tsx +105 -0
  93. package/src/headless/RoleSelect.tsx +58 -0
  94. package/src/i18n/generated/errors.gen.ts +24 -0
  95. package/src/i18n/generated/errors.json +62 -0
  96. package/src/i18n/generated/errors.ru.gen.ts +8 -0
  97. package/src/i18n/keys.ts +68 -0
  98. package/src/i18n/ru.ts +34 -0
  99. package/src/index.ts +54 -1
  100. package/src/model/capabilities.ts +37 -0
  101. package/src/model/emailMask.ts +37 -0
  102. package/src/model/mutations.ts +248 -4
  103. package/src/model/queries.ts +205 -2
  104. package/src/model/queryKeys.ts +45 -1
  105. package/src/model/stepUp.ts +96 -0
@@ -4,6 +4,14 @@
4
4
  * инвайты)"). Built entirely on this pair's EXISTING `Members` headless
5
5
  * wrapper (`useMembers`/`useInviteMembers`/`useUpdateMemberRole`/
6
6
  * `useRemoveMember`) — no new backend surface.
7
+ *
8
+ * Roles come from the EFFECTIVE registry via the `RoleSelect` headless
9
+ * (GET /roles, org-program §A2) — not a hardcoded builtin four: a deployment
10
+ * that overlays `STAPEL_WORKSPACES["ROLES"]` (e.g. a `secretary`) sees its
11
+ * roles here with `workspaces.role.<key>` labels (client-bundle merge,
12
+ * raw-name fallback). `owner` is offered only in the member-row select (the
13
+ * backend enforces "only an owner grants owner" and last-owner protection);
14
+ * the invite dialog filters it out, as before.
7
15
  */
8
16
  import { useState } from "react";
9
17
  import type { ReactElement } from "react";
@@ -11,175 +19,177 @@ import { Alert, Button, Card, Input, Modal, Popconfirm, Select, Table, Typograph
11
19
  import type { TableProps } from "antd";
12
20
  import { useT } from "@stapel/core";
13
21
  import { Members } from "../headless/Members.js";
14
- import type { Member, WorkspaceRole } from "../api/types.js";
22
+ import { RoleSelect } from "../headless/RoleSelect.js";
23
+ import type { RoleSelectBag } from "../headless/RoleSelect.js";
24
+ import type { Member } from "../api/types.js";
15
25
  import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
16
26
 
17
27
  export interface MembersManagerProps {
18
28
  workspaceId: string;
19
29
  /**
20
30
  * Whether the caller may invite, change roles, and remove members. The
21
- * host already knows the caller's own role in this workspace (e.g. from
22
- * `useWorkspace(workspaceId).data?.my_role`); this component doesn't
23
- * re-derive it — pass `false` for a read-only roster (e.g. a `viewer`/
24
- * `member` looking at their teammates). Default `true`.
31
+ * host already knows the caller's own verdict in this workspace (e.g.
32
+ * `useCapabilities(workspaceId).can("members.invite")`, or the coarser
33
+ * `my_role`); this component doesn't re-derive it — pass `false` for a
34
+ * read-only roster. Default `true`.
25
35
  */
26
36
  canManage?: boolean;
27
37
  }
28
38
 
29
- const ROLES: readonly WorkspaceRole[] = ["owner", "admin", "member", "viewer"];
30
-
31
- const ROLE_KEY: Record<
32
- WorkspaceRole,
33
- "roleOwner" | "roleAdmin" | "roleMember" | "roleViewer"
34
- > = {
35
- owner: "roleOwner",
36
- admin: "roleAdmin",
37
- member: "roleMember",
38
- viewer: "roleViewer",
39
- };
40
-
41
- function isWorkspaceRole(value: string): value is WorkspaceRole {
42
- return (ROLES as readonly string[]).includes(value);
43
- }
39
+ const DEFAULT_INVITE_ROLE = "member";
44
40
 
45
41
  export function MembersManager(props: MembersManagerProps): ReactElement {
46
42
  const t = useT();
47
43
  const canManage = props.canManage ?? true;
48
44
  const [inviteOpen, setInviteOpen] = useState(false);
49
45
  const [emailsText, setEmailsText] = useState("");
50
- const [inviteRole, setInviteRole] = useState<WorkspaceRole>("member");
46
+ const [inviteRole, setInviteRole] = useState<string>(DEFAULT_INVITE_ROLE);
51
47
 
52
48
  return (
53
- <Members workspaceId={props.workspaceId}>
54
- {({ members, isLoading, isError, error, invite, isInviting, updateRole, remove }) => {
55
- function openInvite(): void {
56
- setEmailsText("");
57
- setInviteRole("member");
58
- setInviteOpen(true);
59
- }
49
+ <RoleSelect>
50
+ {(rolesBag: RoleSelectBag) => (
51
+ <Members workspaceId={props.workspaceId}>
52
+ {({ members, isLoading, isError, error, invite, isInviting, updateRole, remove }) => {
53
+ const { labelFor } = rolesBag;
54
+ const roleOptions = rolesBag.roles.map((r) => ({
55
+ value: r.role,
56
+ label: labelFor(r.role),
57
+ }));
58
+ const inviteRoleOptions = roleOptions.filter(
59
+ (o) => o.value !== "owner"
60
+ );
60
61
 
61
- function submitInvite(): void {
62
- const emails = emailsText
63
- .split(/[,\s]+/)
64
- .map((e) => e.trim())
65
- .filter((e) => e.length > 0);
66
- if (emails.length === 0) return;
67
- invite({ emails, role: inviteRole });
68
- setInviteOpen(false);
69
- }
62
+ function openInvite(): void {
63
+ setEmailsText("");
64
+ setInviteRole(DEFAULT_INVITE_ROLE);
65
+ setInviteOpen(true);
66
+ }
70
67
 
71
- const columns: TableProps<Member>["columns"] = [
72
- {
73
- title: "Email",
74
- dataIndex: "email",
75
- key: "email",
76
- render: (value: string | null) => value ?? "—",
77
- },
78
- {
79
- title: t(WORKSPACES_I18N_KEYS.membersInviteRoleLabel),
80
- key: "role",
81
- render: (_: unknown, member: Member) => {
82
- const role = isWorkspaceRole(member.role) ? member.role : "member";
83
- return canManage ? (
84
- <Select<WorkspaceRole>
85
- value={role}
86
- style={{ width: 140 }}
87
- onChange={(next) => updateRole({ userId: member.user_id, role: next })}
88
- options={ROLES.map((r) => ({ value: r, label: t(WORKSPACES_I18N_KEYS[ROLE_KEY[r]]) }))}
89
- />
90
- ) : (
91
- <span>{t(WORKSPACES_I18N_KEYS[ROLE_KEY[role]])}</span>
92
- );
93
- },
94
- },
95
- ...(canManage
96
- ? [
97
- {
98
- title: "",
99
- key: "actions",
100
- render: (_: unknown, member: Member) => (
101
- <Popconfirm
102
- title={t(WORKSPACES_I18N_KEYS.membersRemoveConfirm)}
103
- onConfirm={() => remove(member.user_id)}
104
- >
105
- <Button danger type="link">
106
- {t(WORKSPACES_I18N_KEYS.membersRemove)}
107
- </Button>
108
- </Popconfirm>
68
+ function submitInvite(): void {
69
+ const emails = emailsText
70
+ .split(/[,\s]+/)
71
+ .map((e) => e.trim())
72
+ .filter((e) => e.length > 0);
73
+ if (emails.length === 0) return;
74
+ invite({ emails, role: inviteRole });
75
+ setInviteOpen(false);
76
+ }
77
+
78
+ const columns: TableProps<Member>["columns"] = [
79
+ {
80
+ title: "Email",
81
+ dataIndex: "email",
82
+ key: "email",
83
+ render: (value: string | null) => value ?? "—",
84
+ },
85
+ {
86
+ title: t(WORKSPACES_I18N_KEYS.membersInviteRoleLabel),
87
+ key: "role",
88
+ render: (_: unknown, member: Member) =>
89
+ canManage ? (
90
+ <Select<string>
91
+ value={member.role}
92
+ style={{ width: 160 }}
93
+ loading={rolesBag.isLoading}
94
+ onChange={(next) => updateRole({ userId: member.user_id, role: next })}
95
+ // A member's CURRENT role may be missing from the
96
+ // registry options (a deployment removed an overlay
97
+ // role) — antd shows the raw value then, which matches
98
+ // labelFor's raw-name fallback contract.
99
+ options={roleOptions}
100
+ />
101
+ ) : (
102
+ <span>{labelFor(member.role)}</span>
109
103
  ),
110
- },
111
- ]
112
- : []),
113
- ];
104
+ },
105
+ ...(canManage
106
+ ? [
107
+ {
108
+ title: "",
109
+ key: "actions",
110
+ render: (_: unknown, member: Member) => (
111
+ <Popconfirm
112
+ title={t(WORKSPACES_I18N_KEYS.membersRemoveConfirm)}
113
+ onConfirm={() => remove(member.user_id)}
114
+ >
115
+ <Button danger type="link">
116
+ {t(WORKSPACES_I18N_KEYS.membersRemove)}
117
+ </Button>
118
+ </Popconfirm>
119
+ ),
120
+ },
121
+ ]
122
+ : []),
123
+ ];
114
124
 
115
- return (
116
- <Card data-testid="members-manager">
117
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16 }}>
118
- <div>
119
- <Typography.Title level={4} style={{ marginTop: 0 }}>
120
- {t(WORKSPACES_I18N_KEYS.membersTitle)}
121
- </Typography.Title>
122
- <Typography.Text type="secondary">{t(WORKSPACES_I18N_KEYS.membersSubtitle)}</Typography.Text>
123
- </div>
124
- {canManage && (
125
- <Button
126
- type="primary"
127
- onClick={openInvite}
128
- data-analytics="none"
129
- data-analytics-reason="local-ui-open-invite-dialog"
130
- >
131
- {t(WORKSPACES_I18N_KEYS.membersInvite)}
132
- </Button>
133
- )}
134
- </div>
125
+ return (
126
+ <Card data-testid="members-manager">
127
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16 }}>
128
+ <div>
129
+ <Typography.Title level={4} style={{ marginTop: 0 }}>
130
+ {t(WORKSPACES_I18N_KEYS.membersTitle)}
131
+ </Typography.Title>
132
+ <Typography.Text type="secondary">{t(WORKSPACES_I18N_KEYS.membersSubtitle)}</Typography.Text>
133
+ </div>
134
+ {canManage && (
135
+ <Button
136
+ type="primary"
137
+ onClick={openInvite}
138
+ data-analytics="none"
139
+ data-analytics-reason="local-ui-open-invite-dialog"
140
+ >
141
+ {t(WORKSPACES_I18N_KEYS.membersInvite)}
142
+ </Button>
143
+ )}
144
+ </div>
135
145
 
136
- {isError && error && (
137
- <Alert style={{ marginTop: 12 }} type="error" showIcon message={error.message} />
138
- )}
146
+ {isError && error && (
147
+ <Alert style={{ marginTop: 12 }} type="error" showIcon message={error.message} />
148
+ )}
139
149
 
140
- <Table<Member>
141
- style={{ marginTop: 16 }}
142
- size="small"
143
- loading={isLoading}
144
- rowKey={(member) => member.id}
145
- dataSource={members as Member[]}
146
- columns={columns}
147
- pagination={false}
148
- />
150
+ <Table<Member>
151
+ style={{ marginTop: 16 }}
152
+ size="small"
153
+ loading={isLoading}
154
+ rowKey={(member) => member.id}
155
+ dataSource={members as Member[]}
156
+ columns={columns}
157
+ pagination={false}
158
+ />
149
159
 
150
- <Modal
151
- title={t(WORKSPACES_I18N_KEYS.membersInviteDialogTitle)}
152
- open={inviteOpen}
153
- onCancel={() => setInviteOpen(false)}
154
- onOk={submitInvite}
155
- okButtonProps={{ loading: isInviting, disabled: emailsText.trim().length === 0 }}
156
- >
157
- <div style={{ display: "grid", gap: 12 }}>
158
- <div>
159
- <Typography.Text>{t(WORKSPACES_I18N_KEYS.membersInviteEmailsLabel)}</Typography.Text>
160
- <Input
161
- value={emailsText}
162
- onChange={(e) => setEmailsText(e.target.value)}
163
- placeholder={t(WORKSPACES_I18N_KEYS.membersInviteEmailsPlaceholder)}
164
- />
165
- </div>
166
- <div>
167
- <Typography.Text>{t(WORKSPACES_I18N_KEYS.membersInviteRoleLabel)}</Typography.Text>
168
- <Select<WorkspaceRole>
169
- value={inviteRole}
170
- onChange={setInviteRole}
171
- style={{ width: "100%" }}
172
- options={ROLES.filter((r) => r !== "owner").map((r) => ({
173
- value: r,
174
- label: t(WORKSPACES_I18N_KEYS[ROLE_KEY[r]]),
175
- }))}
176
- />
177
- </div>
178
- </div>
179
- </Modal>
180
- </Card>
181
- );
182
- }}
183
- </Members>
160
+ <Modal
161
+ title={t(WORKSPACES_I18N_KEYS.membersInviteDialogTitle)}
162
+ open={inviteOpen}
163
+ onCancel={() => setInviteOpen(false)}
164
+ onOk={submitInvite}
165
+ okButtonProps={{ loading: isInviting, disabled: emailsText.trim().length === 0 }}
166
+ >
167
+ <div style={{ display: "grid", gap: 12 }}>
168
+ <div>
169
+ <Typography.Text>{t(WORKSPACES_I18N_KEYS.membersInviteEmailsLabel)}</Typography.Text>
170
+ <Input
171
+ value={emailsText}
172
+ onChange={(e) => setEmailsText(e.target.value)}
173
+ placeholder={t(WORKSPACES_I18N_KEYS.membersInviteEmailsPlaceholder)}
174
+ />
175
+ </div>
176
+ <div>
177
+ <Typography.Text>{t(WORKSPACES_I18N_KEYS.membersInviteRoleLabel)}</Typography.Text>
178
+ <Select<string>
179
+ value={inviteRole}
180
+ onChange={setInviteRole}
181
+ style={{ width: "100%" }}
182
+ loading={rolesBag.isLoading}
183
+ options={inviteRoleOptions}
184
+ />
185
+ </div>
186
+ </div>
187
+ </Modal>
188
+ </Card>
189
+ );
190
+ }}
191
+ </Members>
192
+ )}
193
+ </RoleSelect>
184
194
  );
185
195
  }
@@ -15,3 +15,5 @@ export { WorkspaceSettings } from "./WorkspaceSettings.js";
15
15
  export type { WorkspaceSettingsProps } from "./WorkspaceSettings.js";
16
16
  export { MembersManager } from "./MembersManager.js";
17
17
  export type { MembersManagerProps } from "./MembersManager.js";
18
+ export { InviteAcceptPage } from "./InviteAcceptPage.js";
19
+ export type { InviteAcceptPageProps } from "./InviteAcceptPage.js";
@@ -0,0 +1,289 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import { createFlowMachine } from "@stapel/core";
3
+ import type { FlowMachine } from "@stapel/core";
4
+ import type { WorkspacesApi } from "../api/workspacesApi.js";
5
+ import type { InvitationPreview, Member } from "../api/types.js";
6
+ import { emailMatchesMask } from "../model/emailMask.js";
7
+ import { toFlowError } from "./errors.js";
8
+ import type { FlowError } from "./errors.js";
9
+
10
+ /**
11
+ * The `/invite/{token}` journey (org-program §B4) as a flow machine:
12
+ *
13
+ * ```
14
+ * preview → (session && email match) → acceptPrompt [accept/decline]
15
+ * → (session && email mismatch) → wrongAccount (logout CTA)
16
+ * → (no session, email_registered)→ loginRequired (host's login slot)
17
+ * → (no session, new user) → newUser → claim →
18
+ * grantIssued (grant_token handed OUT) → [host exchanges at auth]
19
+ * → basicData (host's initial-setup slot) → acceptPrompt
20
+ * ```
21
+ *
22
+ * Non-`pending` invitations (expired / revoked / accepted / declined) park in
23
+ * the terminal `unavailable` step — the page renders the matching copy.
24
+ *
25
+ * ## The grant_token seam (deliberate pair boundary)
26
+ *
27
+ * `claim` mints a login grant that must be exchanged at AUTH's
28
+ * `POST /grant/exchange/` — an endpoint of the auth pair, and pairs do NOT
29
+ * depend on each other (no auth-react peer dependency here). The flow
30
+ * therefore parks in `grantIssued` and hands `grantToken` OUT via the
31
+ * `onLoginGrant` callback; the HOST connects the two pairs:
32
+ *
33
+ * ```tsx
34
+ * <InviteAcceptFlow token={token}
35
+ * onLoginGrant={async (grant) => {
36
+ * const r = await authApi.exchangeLoginGrant(grant); // auth-react
37
+ * session.adopt(r); // full session
38
+ * }}>
39
+ * ```
40
+ *
41
+ * then calls `grantExchanged()` (the default `InviteAcceptPage` does this
42
+ * automatically when the host's `onLoginGrant` promise resolves), which moves
43
+ * the flow to `basicData` — the host's profiles-react `InitialSetupPrompt`
44
+ * slot — and `completeBasicData()` finally reaches `acceptPrompt`. The
45
+ * account created by the exchange IS the invited email's account (verified by
46
+ * construction), so no re-match happens on that path.
47
+ *
48
+ * ## Session/email detection
49
+ *
50
+ * The pair has no auth dependency, so the CALLER supplies the signed-in
51
+ * email (`sessionEmail`, e.g. auth-react's `useAuthSessionState().user?.email`
52
+ * — or any other session source; null/undefined = no session). Match against
53
+ * the preview's MASKED email is decided client-side by masking the known
54
+ * email with the same algorithm (see `model/emailMask.ts`); the backend's
55
+ * real email-match on accept/decline stays the enforcement point — a mask
56
+ * collision merely shows the prompt, the accept then surfaces the 403.
57
+ */
58
+ export type InviteAcceptState =
59
+ | { readonly step: "idle" }
60
+ | { readonly step: "loadingPreview" }
61
+ | { readonly step: "previewError"; readonly error: FlowError }
62
+ /** Terminal: the invitation is not pending (expired/revoked/accepted/declined). */
63
+ | {
64
+ readonly step: "unavailable";
65
+ readonly preview: InvitationPreview;
66
+ readonly status: string;
67
+ }
68
+ /** Signed in with a non-matching account — logout CTA (host slot). */
69
+ | { readonly step: "wrongAccount"; readonly preview: InvitationPreview }
70
+ /** No session, account exists — the host's login slot; call
71
+ * `sessionEstablished(email)` once signed in. */
72
+ | { readonly step: "loginRequired"; readonly preview: InvitationPreview }
73
+ /** No session, no account — deliberate CTA before minting the grant. */
74
+ | { readonly step: "newUser"; readonly preview: InvitationPreview }
75
+ | { readonly step: "claiming"; readonly preview: InvitationPreview }
76
+ | {
77
+ readonly step: "claimError";
78
+ readonly preview: InvitationPreview;
79
+ readonly error: FlowError;
80
+ }
81
+ /** The grant is out via `onLoginGrant` — waiting for the host to exchange
82
+ * it at auth and call `grantExchanged()`. */
83
+ | {
84
+ readonly step: "grantIssued";
85
+ readonly preview: InvitationPreview;
86
+ readonly grantToken: string;
87
+ }
88
+ /** Host's basic-data slot (profiles-react InitialSetupPrompt canon, §B5). */
89
+ | { readonly step: "basicData"; readonly preview: InvitationPreview }
90
+ | { readonly step: "acceptPrompt"; readonly preview: InvitationPreview }
91
+ | { readonly step: "accepting"; readonly preview: InvitationPreview }
92
+ | { readonly step: "declining"; readonly preview: InvitationPreview }
93
+ | {
94
+ readonly step: "acceptError";
95
+ readonly preview: InvitationPreview;
96
+ readonly error: FlowError;
97
+ }
98
+ | {
99
+ readonly step: "accepted";
100
+ readonly member: Member;
101
+ readonly preview: InvitationPreview;
102
+ }
103
+ | { readonly step: "declined"; readonly preview: InvitationPreview };
104
+
105
+ export interface InviteAcceptFlow {
106
+ readonly machine: FlowMachine<InviteAcceptState>;
107
+ /** Fetch the preview and route per §B4. `sessionEmail` = the signed-in
108
+ * account's email, or null when there is no session. */
109
+ load(sessionEmail: string | null): Promise<void>;
110
+ /** From `newUser`/`claimError`: mint the login grant (`onLoginGrant` fires
111
+ * with the token; the flow parks in `grantIssued`). */
112
+ claim(): Promise<void>;
113
+ /** From `loginRequired`/`wrongAccount`: a session now exists (host's login
114
+ * slot finished, or the user switched accounts) — re-route on its email. */
115
+ sessionEstablished(email: string): void;
116
+ /** From `grantIssued`: the host exchanged the grant at auth — proceed to
117
+ * the basic-data step. */
118
+ grantExchanged(): void;
119
+ /** From `basicData`: the initial-setup slot finished — show the accept prompt. */
120
+ completeBasicData(): void;
121
+ accept(): Promise<void>;
122
+ decline(): Promise<void>;
123
+ reset(): void;
124
+ }
125
+
126
+ export interface InviteAcceptFlowDeps {
127
+ readonly api: WorkspacesApi;
128
+ /** The invite token from the `/invite/{token}` URL — a bearer secret. */
129
+ readonly token: string;
130
+ readonly analytics?: Analytics | null;
131
+ /** The grant_token hand-off seam (see the module doc). A credential:
132
+ * exchange it, never log it. */
133
+ readonly onLoginGrant?: (grantToken: string) => void;
134
+ readonly onAccepted?: (member: Member) => void;
135
+ readonly onDeclined?: () => void;
136
+ }
137
+
138
+ /**
139
+ * Flow id: stapel-workspaces annotates no `@flow_step` yet (its
140
+ * `docs/flows.json` is empty — the pair's flow registry is the zero-flow
141
+ * shim), so this id is pair-local until the backend documents the flow;
142
+ * switch to the generated registry id when it lands.
143
+ */
144
+ const FLOW_ID = "workspaces.invite_accept";
145
+
146
+ function routeSignedIn(
147
+ preview: InvitationPreview,
148
+ email: string
149
+ ): InviteAcceptState {
150
+ return emailMatchesMask(email, preview.email_masked)
151
+ ? { step: "acceptPrompt", preview }
152
+ : { step: "wrongAccount", preview };
153
+ }
154
+
155
+ function routePreview(
156
+ preview: InvitationPreview,
157
+ sessionEmail: string | null
158
+ ): InviteAcceptState {
159
+ if (preview.status !== "pending") {
160
+ return { step: "unavailable", preview, status: preview.status };
161
+ }
162
+ if (sessionEmail !== null && sessionEmail !== "") {
163
+ return routeSignedIn(preview, sessionEmail);
164
+ }
165
+ return preview.email_registered
166
+ ? { step: "loginRequired", preview }
167
+ : { step: "newUser", preview };
168
+ }
169
+
170
+ export function createInviteAcceptFlow(
171
+ deps: InviteAcceptFlowDeps
172
+ ): InviteAcceptFlow {
173
+ const machine = createFlowMachine<InviteAcceptState>({
174
+ id: FLOW_ID,
175
+ initial: { step: "idle" },
176
+ analytics: deps.analytics ?? null,
177
+ });
178
+
179
+ async function load(sessionEmail: string | null): Promise<void> {
180
+ await machine.run(
181
+ { step: "loadingPreview" },
182
+ () => deps.api.getInvitationPreview(deps.token),
183
+ {
184
+ resolve: (preview) => routePreview(preview, sessionEmail),
185
+ reject: (error): InviteAcceptState => ({
186
+ step: "previewError",
187
+ error: toFlowError(error),
188
+ }),
189
+ }
190
+ );
191
+ }
192
+
193
+ async function claim(): Promise<void> {
194
+ const s = machine.getState();
195
+ if (s.step !== "newUser" && s.step !== "claimError") return;
196
+ const { preview } = s;
197
+ await machine.run(
198
+ { step: "claiming", preview },
199
+ () => deps.api.claimInvitation(deps.token),
200
+ {
201
+ resolve: (r): InviteAcceptState => {
202
+ deps.onLoginGrant?.(r.grant_token);
203
+ return { step: "grantIssued", preview, grantToken: r.grant_token };
204
+ },
205
+ reject: (error): InviteAcceptState => ({
206
+ step: "claimError",
207
+ preview,
208
+ error: toFlowError(error),
209
+ }),
210
+ }
211
+ );
212
+ }
213
+
214
+ function sessionEstablished(email: string): void {
215
+ const s = machine.getState();
216
+ if (s.step !== "loginRequired" && s.step !== "wrongAccount") return;
217
+ machine.to(routeSignedIn(s.preview, email));
218
+ }
219
+
220
+ function grantExchanged(): void {
221
+ const s = machine.getState();
222
+ if (s.step !== "grantIssued") return;
223
+ machine.to({ step: "basicData", preview: s.preview });
224
+ }
225
+
226
+ function completeBasicData(): void {
227
+ const s = machine.getState();
228
+ if (s.step !== "basicData") return;
229
+ machine.to({ step: "acceptPrompt", preview: s.preview });
230
+ }
231
+
232
+ async function accept(): Promise<void> {
233
+ const s = machine.getState();
234
+ if (s.step !== "acceptPrompt" && s.step !== "acceptError") return;
235
+ const { preview } = s;
236
+ await machine.run(
237
+ { step: "accepting", preview },
238
+ () => deps.api.acceptInvitation({ token: deps.token }),
239
+ {
240
+ resolve: (member): InviteAcceptState => {
241
+ deps.onAccepted?.(member);
242
+ return { step: "accepted", member, preview };
243
+ },
244
+ reject: (error): InviteAcceptState => ({
245
+ step: "acceptError",
246
+ preview,
247
+ error: toFlowError(error),
248
+ }),
249
+ }
250
+ );
251
+ }
252
+
253
+ async function decline(): Promise<void> {
254
+ const s = machine.getState();
255
+ if (s.step !== "acceptPrompt" && s.step !== "acceptError") return;
256
+ const { preview } = s;
257
+ await machine.run(
258
+ { step: "declining", preview },
259
+ () => deps.api.declineInvitation(deps.token),
260
+ {
261
+ resolve: (): InviteAcceptState => {
262
+ deps.onDeclined?.();
263
+ return { step: "declined", preview };
264
+ },
265
+ reject: (error): InviteAcceptState => ({
266
+ step: "acceptError",
267
+ preview,
268
+ error: toFlowError(error),
269
+ }),
270
+ }
271
+ );
272
+ }
273
+
274
+ function reset(): void {
275
+ machine.to({ step: "idle" });
276
+ }
277
+
278
+ return {
279
+ machine,
280
+ load,
281
+ claim,
282
+ sessionEstablished,
283
+ grantExchanged,
284
+ completeBasicData,
285
+ accept,
286
+ decline,
287
+ reset,
288
+ };
289
+ }