@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.
- package/CHANGELOG.md +56 -0
- package/dist/api/generated/schema.d.ts +774 -7
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +104 -7
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/workspacesApi.d.ts +55 -1
- package/dist/api/workspacesApi.d.ts.map +1 -1
- package/dist/api/workspacesApi.js +23 -0
- package/dist/api/workspacesApi.js.map +1 -1
- package/dist/default/InviteAcceptPage.d.ts +28 -0
- package/dist/default/InviteAcceptPage.d.ts.map +1 -0
- package/dist/default/InviteAcceptPage.js +141 -0
- package/dist/default/InviteAcceptPage.js.map +1 -0
- package/dist/default/MembersManager.d.ts +4 -4
- package/dist/default/MembersManager.d.ts.map +1 -1
- package/dist/default/MembersManager.js +61 -56
- package/dist/default/MembersManager.js.map +1 -1
- package/dist/default/index.d.ts +2 -0
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/flows/inviteAcceptFlow.d.ts +155 -0
- package/dist/flows/inviteAcceptFlow.d.ts.map +1 -0
- package/dist/flows/inviteAcceptFlow.js +126 -0
- package/dist/flows/inviteAcceptFlow.js.map +1 -0
- package/dist/headless/Can.d.ts +40 -0
- package/dist/headless/Can.d.ts.map +1 -0
- package/dist/headless/Can.js +30 -0
- package/dist/headless/Can.js.map +1 -0
- package/dist/headless/InviteAcceptFlow.d.ts +44 -0
- package/dist/headless/InviteAcceptFlow.d.ts.map +1 -0
- package/dist/headless/InviteAcceptFlow.js +67 -0
- package/dist/headless/InviteAcceptFlow.js.map +1 -0
- package/dist/headless/RoleSelect.d.ts +41 -0
- package/dist/headless/RoleSelect.d.ts.map +1 -0
- package/dist/headless/RoleSelect.js +34 -0
- package/dist/headless/RoleSelect.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +48 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +24 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +8 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +28 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +64 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +30 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/model/capabilities.d.ts +21 -0
- package/dist/model/capabilities.d.ts.map +1 -0
- package/dist/model/capabilities.js +32 -0
- package/dist/model/capabilities.js.map +1 -0
- package/dist/model/emailMask.d.ts +22 -0
- package/dist/model/emailMask.d.ts.map +1 -0
- package/dist/model/emailMask.js +36 -0
- package/dist/model/emailMask.js.map +1 -0
- package/dist/model/mutations.d.ts +107 -3
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +190 -1
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/queries.d.ts +108 -2
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +139 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +12 -1
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +25 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/model/stepUp.d.ts +25 -0
- package/dist/model/stepUp.d.ts.map +1 -0
- package/dist/model/stepUp.js +79 -0
- package/dist/model/stepUp.js.map +1 -0
- package/llms.txt +49 -15
- package/manifest.json +346 -3
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +774 -7
- package/src/api/types.ts +113 -7
- package/src/api/workspacesApi.ts +137 -0
- package/src/default/InviteAcceptPage.tsx +373 -0
- package/src/default/MembersManager.tsx +155 -145
- package/src/default/index.ts +2 -0
- package/src/flows/inviteAcceptFlow.ts +289 -0
- package/src/headless/Can.tsx +49 -0
- package/src/headless/InviteAcceptFlow.tsx +105 -0
- package/src/headless/RoleSelect.tsx +58 -0
- package/src/i18n/generated/errors.gen.ts +24 -0
- package/src/i18n/generated/errors.json +62 -0
- package/src/i18n/generated/errors.ru.gen.ts +8 -0
- package/src/i18n/keys.ts +68 -0
- package/src/i18n/ru.ts +34 -0
- package/src/index.ts +54 -1
- package/src/model/capabilities.ts +37 -0
- package/src/model/emailMask.ts +37 -0
- package/src/model/mutations.ts +248 -4
- package/src/model/queries.ts +205 -2
- package/src/model/queryKeys.ts +45 -1
- 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
|
|
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
|
|
22
|
-
* `
|
|
23
|
-
* re-derive it — pass `false` for a
|
|
24
|
-
*
|
|
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
|
|
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<
|
|
46
|
+
const [inviteRole, setInviteRole] = useState<string>(DEFAULT_INVITE_ROLE);
|
|
51
47
|
|
|
52
48
|
return (
|
|
53
|
-
<
|
|
54
|
-
{(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
146
|
+
{isError && error && (
|
|
147
|
+
<Alert style={{ marginTop: 12 }} type="error" showIcon message={error.message} />
|
|
148
|
+
)}
|
|
139
149
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
</
|
|
178
|
-
</
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
</
|
|
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
|
}
|
package/src/default/index.ts
CHANGED
|
@@ -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
|
+
}
|