orcheo-studio 0.22.2 → 0.23.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.
- package/.bumpversion.cfg +1 -1
- package/dist/assets/{arc-De9dLqyl.js → arc-CqnxfCHp.js} +1 -1
- package/dist/assets/{architectureDiagram-VXUJARFQ-hEslfmYm.js → architectureDiagram-VXUJARFQ-UGPIhA1j.js} +1 -1
- package/dist/assets/{blockDiagram-VD42YOAC-CyWSMWJa.js → blockDiagram-VD42YOAC-Cn6wka40.js} +1 -1
- package/dist/assets/{c4Diagram-YG6GDRKO-CxoTVJ47.js → c4Diagram-YG6GDRKO-BvPRNdur.js} +1 -1
- package/dist/assets/channel-FjxqoZWk.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-twftUw9o.js → chunk-4BX2VUAB-F_o1AmTw.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DQZDNQUo.js → chunk-55IACEB6-Cl075es2.js} +1 -1
- package/dist/assets/{chunk-B4BG7PRW-BYGOoNB5.js → chunk-B4BG7PRW-DcErQhy9.js} +1 -1
- package/dist/assets/{chunk-DI55MBZ5-qyPhcZF3.js → chunk-DI55MBZ5-CFxCndOp.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BgUDHGEG.js → chunk-FMBD7UC4-XOcF0fgy.js} +1 -1
- package/dist/assets/{chunk-QN33PNHL-DHlMh8NC.js → chunk-QN33PNHL-CjJH_Bvj.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Bfc8IV0O.js → chunk-QZHKN3VN-nLb-Go8u.js} +1 -1
- package/dist/assets/{chunk-TZMSLE5B-BqwdftTm.js → chunk-TZMSLE5B-L07ovmT0.js} +1 -1
- package/dist/assets/classDiagram-2ON5EDUG-8JYNY9Ic.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-8JYNY9Ic.js +1 -0
- package/dist/assets/clone-NPVQC0Uc.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-g5agH7fS.js → cose-bilkent-S5V4N54A-B6IcYcMk.js} +1 -1
- package/dist/assets/{dagre-6UL2VRFP-ajLMaXPP.js → dagre-6UL2VRFP-CN7ADhef.js} +1 -1
- package/dist/assets/{diagram-PSM6KHXK-BndVcBhB.js → diagram-PSM6KHXK-3chqLyzk.js} +1 -1
- package/dist/assets/{diagram-QEK2KX5R-BFiIYeu7.js → diagram-QEK2KX5R-MrbKZWaG.js} +1 -1
- package/dist/assets/{diagram-S2PKOQOG-DXy-A9pn.js → diagram-S2PKOQOG-Dr7BijIx.js} +1 -1
- package/dist/assets/{erDiagram-Q2GNP2WA-D_Bac_58.js → erDiagram-Q2GNP2WA-Ck-v9a6N.js} +1 -1
- package/dist/assets/{flowDiagram-NV44I4VS-DhEi8qtz.js → flowDiagram-NV44I4VS-D662eMD1.js} +1 -1
- package/dist/assets/{ganttDiagram-JELNMOA3-BtpUvOuc.js → ganttDiagram-JELNMOA3-c2mXK47M.js} +1 -1
- package/dist/assets/{gitGraphDiagram-V2S2FVAM-MBES60cx.js → gitGraphDiagram-V2S2FVAM-DUMhfhsS.js} +1 -1
- package/dist/assets/{graph-BcwvIN-m.js → graph-DkWlmVun.js} +1 -1
- package/dist/assets/index-BVY_PYJU.css +1 -0
- package/dist/assets/index-kH6EkBsZ.js +790 -0
- package/dist/assets/{infoDiagram-HS3SLOUP-vaIFYyC9.js → infoDiagram-HS3SLOUP-OTL7qvkP.js} +1 -1
- package/dist/assets/{isUndefined-DVO6xakl.js → isUndefined-BmZvH1PX.js} +1 -1
- package/dist/assets/{journeyDiagram-XKPGCS4Q-D-_q99If.js → journeyDiagram-XKPGCS4Q-BYzqE5D8.js} +1 -1
- package/dist/assets/{kanban-definition-3W4ZIXB7-i_AZcs_J.js → kanban-definition-3W4ZIXB7-NtzQ4y9p.js} +1 -1
- package/dist/assets/{layout-CPJ9nR_z.js → layout-CwhyLMkP.js} +1 -1
- package/dist/assets/{linear-KBGpPBiH.js → linear-Cirf2V1x.js} +1 -1
- package/dist/assets/{min-By5f7YdD.js → min-C81vLJYh.js} +1 -1
- package/dist/assets/{mindmap-definition-VGOIOE7T-DRjtRxFS.js → mindmap-definition-VGOIOE7T-TDnLQIKZ.js} +1 -1
- package/dist/assets/{pieDiagram-ADFJNKIX-BkkSglWM.js → pieDiagram-ADFJNKIX-d0-JhjNZ.js} +1 -1
- package/dist/assets/{quadrantDiagram-AYHSOK5B-DY-Ng2qt.js → quadrantDiagram-AYHSOK5B-wCoihp8a.js} +1 -1
- package/dist/assets/{requirementDiagram-UZGBJVZJ-B5w0etXG.js → requirementDiagram-UZGBJVZJ-BqiCIjcI.js} +1 -1
- package/dist/assets/{sankeyDiagram-TZEHDZUN-BWntUp8b.js → sankeyDiagram-TZEHDZUN-BPH0Pd1D.js} +1 -1
- package/dist/assets/{sequenceDiagram-WL72ISMW-Cl77fd7y.js → sequenceDiagram-WL72ISMW-B8FfJraH.js} +1 -1
- package/dist/assets/{stateDiagram-FKZM4ZOC-B8_DoPW6.js → stateDiagram-FKZM4ZOC-ZQR3Q9_8.js} +1 -1
- package/dist/assets/stateDiagram-v2-4FDKWEC3-uDYn1wPF.js +1 -0
- package/dist/assets/{timeline-definition-IT6M3QCI-udvNLTe_.js → timeline-definition-IT6M3QCI-D-arYxX5.js} +1 -1
- package/dist/assets/{treemap-GDKQZRPO-BSvoMl74.js → treemap-GDKQZRPO-Dw5BHp_B.js} +1 -1
- package/dist/assets/{xychartDiagram-PRI3JC2R-CTdy-FCX.js → xychartDiagram-PRI3JC2R-Dt1zH6V2.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/App.tsx +7 -2
- package/src/features/account/pages/invitation-accept.tsx +92 -0
- package/src/features/account/pages/workspace-members.tsx +153 -42
- package/src/features/auth/components/require-auth.tsx +13 -18
- package/src/features/auth/lib/auth-api.ts +196 -0
- package/src/features/auth/lib/auth-session.ts +0 -1
- package/src/features/auth/pages/login.tsx +169 -16
- package/src/features/auth/pages/verify.tsx +89 -0
- package/src/features/chatkit/components/public-chat-widget.tsx +89 -8
- package/src/features/chatkit/lib/chatkit-client.ts +66 -0
- package/src/features/chatkit/pages/public-chat.tsx +17 -30
- package/src/features/shared/components/top-navigation/account-menu.tsx +20 -7
- package/src/features/shared/components/top-navigation/studio-brand.tsx +14 -3
- package/src/features/workflow/components/dialogs/publish-workflow-dialog.tsx +121 -0
- package/src/features/workflow/data/templates/candidate-badges.ts +28 -0
- package/src/features/workflow/data/workflow-types.ts +6 -0
- package/src/features/workflow/lib/workflow-storage-helpers.ts +5 -3
- package/src/features/workflow/lib/workflow-storage-versioning.ts +4 -0
- package/src/features/workflow/lib/workflow-storage.types.ts +1 -0
- package/src/features/workflow/pages/workflow/components/workflow-tab-content.tsx +122 -37
- package/src/features/workflow/pages/workflow/hooks/controller/build-layout-props.ts +2 -0
- package/src/features/workflow/pages/workflow/hooks/controller/use-workflow-lifecycle.ts +1 -0
- package/src/features/workflow/pages/workflow/hooks/use-workflow-loader.ts +5 -0
- package/src/features/workflow/pages/workflow/hooks/use-workflow-metadata-state.ts +3 -0
- package/src/features/workflow/pages/workflow/hooks/use-workflow-saver.ts +11 -2
- package/src/features/workflow/pages/workflow-gallery/workflow-gallery-tabs.tsx +47 -4
- package/src/lib/api.ts +78 -1
- package/src/vite-env.d.ts +1 -0
- package/dist/assets/channel-CzCzGn0f.js +0 -1
- package/dist/assets/classDiagram-2ON5EDUG-ekNI5d_4.js +0 -1
- package/dist/assets/classDiagram-v2-WZHVMYZB-ekNI5d_4.js +0 -1
- package/dist/assets/clone-DowBu1Kh.js +0 -1
- package/dist/assets/index-DLYzf0K-.js +0 -775
- package/dist/assets/index-DebsXgfj.css +0 -1
- package/dist/assets/stateDiagram-v2-4FDKWEC3-DHL7p-md.js +0 -1
- package/src/features/MODULES.md +0 -199
- package/src/features/auth/components/auto-login.tsx +0 -51
- package/src/features/auth/lib/oidc-client.ts +0 -565
- package/src/features/auth/pages/oauth-callback.tsx +0 -63
|
@@ -22,35 +22,41 @@ import { getAuthenticatedUserProfile } from "@features/auth/lib/auth-session";
|
|
|
22
22
|
import {
|
|
23
23
|
getActiveWorkspace,
|
|
24
24
|
listWorkspaceMembers,
|
|
25
|
-
|
|
25
|
+
listWorkspaceInvitations,
|
|
26
|
+
createWorkspaceInvitation,
|
|
27
|
+
revokeWorkspaceInvitation,
|
|
26
28
|
updateWorkspaceMemberRole,
|
|
27
29
|
removeWorkspaceMember,
|
|
28
30
|
type WorkspaceMember,
|
|
31
|
+
type WorkspaceInvitation,
|
|
29
32
|
} from "@/lib/api";
|
|
30
33
|
import { getSelectedWorkspaceSlug } from "@/lib/workspace-session";
|
|
31
34
|
|
|
32
35
|
type MemberRole = "owner" | "admin" | "editor" | "viewer";
|
|
33
36
|
|
|
34
37
|
const ROLE_OPTIONS: MemberRole[] = ["owner", "admin", "editor", "viewer"];
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
"Enter a valid user ID using letters, numbers, dots, underscores, colons, at signs, plus signs, or hyphens.";
|
|
38
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
39
|
+
const EMAIL_VALIDATION_MESSAGE = "Enter a valid email address.";
|
|
38
40
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
+
const isValidEmail = (email: string): boolean =>
|
|
42
|
+
EMAIL_PATTERN.test(email.trim());
|
|
41
43
|
|
|
42
44
|
export default function WorkspaceMembers() {
|
|
43
45
|
const [members, setMembers] = useState<WorkspaceMember[]>([]);
|
|
46
|
+
const [invitations, setInvitations] = useState<WorkspaceInvitation[]>([]);
|
|
44
47
|
const [isLoading, setIsLoading] = useState(true);
|
|
45
48
|
const [canManage, setCanManage] = useState(false);
|
|
46
49
|
const [isForbidden, setIsForbidden] = useState(false);
|
|
47
50
|
|
|
48
|
-
const [
|
|
49
|
-
const [
|
|
51
|
+
const [newEmail, setNewEmail] = useState("");
|
|
52
|
+
const [newEmailError, setNewEmailError] = useState<string | null>(null);
|
|
50
53
|
const [newRole, setNewRole] = useState<MemberRole>("editor");
|
|
51
54
|
const [isAdding, setIsAdding] = useState(false);
|
|
52
55
|
const [removingUserId, setRemovingUserId] = useState<string | null>(null);
|
|
53
56
|
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null);
|
|
57
|
+
const [revokingInvitationId, setRevokingInvitationId] = useState<
|
|
58
|
+
string | null
|
|
59
|
+
>(null);
|
|
54
60
|
|
|
55
61
|
const slug = getSelectedWorkspaceSlug();
|
|
56
62
|
const currentUserId = getAuthenticatedUserProfile()?.subject ?? null;
|
|
@@ -76,11 +82,15 @@ export default function WorkspaceMembers() {
|
|
|
76
82
|
setIsForbidden(true);
|
|
77
83
|
return;
|
|
78
84
|
}
|
|
79
|
-
const memberList = await
|
|
85
|
+
const [memberList, invitationList] = await Promise.all([
|
|
86
|
+
listWorkspaceMembers(slug),
|
|
87
|
+
listWorkspaceInvitations(slug),
|
|
88
|
+
]);
|
|
80
89
|
if (!active) {
|
|
81
90
|
return;
|
|
82
91
|
}
|
|
83
92
|
setMembers(memberList);
|
|
93
|
+
setInvitations(invitationList);
|
|
84
94
|
} catch {
|
|
85
95
|
if (active) {
|
|
86
96
|
toast({
|
|
@@ -102,29 +112,32 @@ export default function WorkspaceMembers() {
|
|
|
102
112
|
};
|
|
103
113
|
}, [slug]);
|
|
104
114
|
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
if (!slug || !
|
|
115
|
+
const handleInvite = async () => {
|
|
116
|
+
const normalizedEmail = newEmail.trim();
|
|
117
|
+
if (!slug || !normalizedEmail) {
|
|
108
118
|
return;
|
|
109
119
|
}
|
|
110
|
-
if (!
|
|
111
|
-
|
|
120
|
+
if (!isValidEmail(normalizedEmail)) {
|
|
121
|
+
setNewEmailError(EMAIL_VALIDATION_MESSAGE);
|
|
112
122
|
return;
|
|
113
123
|
}
|
|
114
124
|
setIsAdding(true);
|
|
115
125
|
try {
|
|
116
|
-
const
|
|
117
|
-
|
|
126
|
+
const invitation = await createWorkspaceInvitation(slug, {
|
|
127
|
+
email: normalizedEmail,
|
|
118
128
|
role: newRole,
|
|
119
129
|
});
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
130
|
+
setInvitations((prev) => [invitation, ...prev]);
|
|
131
|
+
setNewEmail("");
|
|
132
|
+
setNewEmailError(null);
|
|
123
133
|
setNewRole("editor");
|
|
124
|
-
toast({
|
|
134
|
+
toast({
|
|
135
|
+
title: "Invitation sent",
|
|
136
|
+
description: `An invite was emailed to ${invitation.email}.`,
|
|
137
|
+
});
|
|
125
138
|
} catch (err) {
|
|
126
139
|
toast({
|
|
127
|
-
title: "Failed to
|
|
140
|
+
title: "Failed to send invitation",
|
|
128
141
|
description: err instanceof Error ? err.message : undefined,
|
|
129
142
|
variant: "destructive",
|
|
130
143
|
});
|
|
@@ -133,6 +146,28 @@ export default function WorkspaceMembers() {
|
|
|
133
146
|
}
|
|
134
147
|
};
|
|
135
148
|
|
|
149
|
+
const handleRevokeInvitation = async (invitationId: string) => {
|
|
150
|
+
if (!slug) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setRevokingInvitationId(invitationId);
|
|
154
|
+
try {
|
|
155
|
+
const revoked = await revokeWorkspaceInvitation(slug, invitationId);
|
|
156
|
+
setInvitations((prev) =>
|
|
157
|
+
prev.map((i) => (i.id === invitationId ? revoked : i)),
|
|
158
|
+
);
|
|
159
|
+
toast({ title: "Invitation revoked" });
|
|
160
|
+
} catch (err) {
|
|
161
|
+
toast({
|
|
162
|
+
title: "Failed to revoke invitation",
|
|
163
|
+
description: err instanceof Error ? err.message : undefined,
|
|
164
|
+
variant: "destructive",
|
|
165
|
+
});
|
|
166
|
+
} finally {
|
|
167
|
+
setRevokingInvitationId(null);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
136
171
|
const handleRoleChange = async (userId: string, role: MemberRole) => {
|
|
137
172
|
if (!slug) {
|
|
138
173
|
return;
|
|
@@ -186,27 +221,30 @@ export default function WorkspaceMembers() {
|
|
|
186
221
|
|
|
187
222
|
{canManage && (
|
|
188
223
|
<div className="rounded-lg border p-4">
|
|
189
|
-
<h3 className="mb-
|
|
224
|
+
<h3 className="mb-1 text-lg font-medium">Invite Member</h3>
|
|
225
|
+
<p className="mb-4 text-sm text-muted-foreground">
|
|
226
|
+
We'll email an invitation link. The member joins once they
|
|
227
|
+
accept while signed in with a verified email that matches.
|
|
228
|
+
</p>
|
|
190
229
|
<div className="flex items-end gap-3">
|
|
191
230
|
<div className="flex-1 space-y-1.5">
|
|
192
|
-
<Label htmlFor="new-
|
|
231
|
+
<Label htmlFor="new-email">Email</Label>
|
|
193
232
|
<Input
|
|
194
|
-
id="new-
|
|
195
|
-
|
|
196
|
-
|
|
233
|
+
id="new-email"
|
|
234
|
+
type="email"
|
|
235
|
+
placeholder="person@example.com"
|
|
236
|
+
value={newEmail}
|
|
197
237
|
onChange={(e) => {
|
|
198
|
-
|
|
199
|
-
|
|
238
|
+
setNewEmail(e.target.value);
|
|
239
|
+
setNewEmailError(null);
|
|
200
240
|
}}
|
|
201
241
|
disabled={isAdding}
|
|
202
|
-
aria-invalid={
|
|
203
|
-
aria-describedby={
|
|
204
|
-
newUserIdError ? "new-user-id-error" : undefined
|
|
205
|
-
}
|
|
242
|
+
aria-invalid={newEmailError ? true : undefined}
|
|
243
|
+
aria-describedby={newEmailError ? "new-email-error" : undefined}
|
|
206
244
|
/>
|
|
207
|
-
{
|
|
208
|
-
<p id="new-
|
|
209
|
-
{
|
|
245
|
+
{newEmailError && (
|
|
246
|
+
<p id="new-email-error" className="text-xs text-destructive">
|
|
247
|
+
{newEmailError}
|
|
210
248
|
</p>
|
|
211
249
|
)}
|
|
212
250
|
</div>
|
|
@@ -234,16 +272,80 @@ export default function WorkspaceMembers() {
|
|
|
234
272
|
Action
|
|
235
273
|
</Label>
|
|
236
274
|
<Button
|
|
237
|
-
onClick={() => void
|
|
238
|
-
disabled={isAdding || !
|
|
275
|
+
onClick={() => void handleInvite()}
|
|
276
|
+
disabled={isAdding || !newEmail.trim()}
|
|
239
277
|
>
|
|
240
|
-
{isAdding ? "
|
|
278
|
+
{isAdding ? "Sending..." : "Send invite"}
|
|
241
279
|
</Button>
|
|
242
280
|
</div>
|
|
243
281
|
</div>
|
|
244
282
|
</div>
|
|
245
283
|
)}
|
|
246
284
|
|
|
285
|
+
{canManage &&
|
|
286
|
+
invitations.some((i) => i.status === "pending") && (
|
|
287
|
+
<div className="rounded-lg border">
|
|
288
|
+
<div className="border-b px-4 py-3">
|
|
289
|
+
<h3 className="text-sm font-medium">Pending Invitations</h3>
|
|
290
|
+
</div>
|
|
291
|
+
<Table>
|
|
292
|
+
<TableHeader>
|
|
293
|
+
<TableRow>
|
|
294
|
+
<TableHead>Email</TableHead>
|
|
295
|
+
<TableHead>Role</TableHead>
|
|
296
|
+
<TableHead>Expires</TableHead>
|
|
297
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
298
|
+
</TableRow>
|
|
299
|
+
</TableHeader>
|
|
300
|
+
<TableBody>
|
|
301
|
+
{invitations
|
|
302
|
+
.filter((i) => i.status === "pending")
|
|
303
|
+
.map((invitation) => {
|
|
304
|
+
const isRevoking =
|
|
305
|
+
revokingInvitationId === invitation.id;
|
|
306
|
+
const isExpired =
|
|
307
|
+
new Date(invitation.expires_at) < new Date();
|
|
308
|
+
return (
|
|
309
|
+
<TableRow
|
|
310
|
+
key={invitation.id}
|
|
311
|
+
className={isExpired ? "opacity-60" : undefined}
|
|
312
|
+
>
|
|
313
|
+
<TableCell className="text-sm">
|
|
314
|
+
{invitation.email}
|
|
315
|
+
</TableCell>
|
|
316
|
+
<TableCell className="capitalize">
|
|
317
|
+
{invitation.role}
|
|
318
|
+
</TableCell>
|
|
319
|
+
<TableCell className="text-sm text-muted-foreground">
|
|
320
|
+
{isExpired ? (
|
|
321
|
+
<span className="text-destructive">Expired</span>
|
|
322
|
+
) : (
|
|
323
|
+
new Date(
|
|
324
|
+
invitation.expires_at,
|
|
325
|
+
).toLocaleDateString()
|
|
326
|
+
)}
|
|
327
|
+
</TableCell>
|
|
328
|
+
<TableCell className="text-right">
|
|
329
|
+
<Button
|
|
330
|
+
variant="ghost"
|
|
331
|
+
size="sm"
|
|
332
|
+
className="text-destructive hover:text-destructive"
|
|
333
|
+
onClick={() =>
|
|
334
|
+
void handleRevokeInvitation(invitation.id)
|
|
335
|
+
}
|
|
336
|
+
disabled={isRevoking}
|
|
337
|
+
>
|
|
338
|
+
{isRevoking ? "Revoking..." : "Revoke"}
|
|
339
|
+
</Button>
|
|
340
|
+
</TableCell>
|
|
341
|
+
</TableRow>
|
|
342
|
+
);
|
|
343
|
+
})}
|
|
344
|
+
</TableBody>
|
|
345
|
+
</Table>
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
|
|
247
349
|
{isForbidden ? null : isLoading ? (
|
|
248
350
|
<p className="text-sm text-muted-foreground">Loading members...</p>
|
|
249
351
|
) : members.length === 0 ? (
|
|
@@ -254,7 +356,7 @@ export default function WorkspaceMembers() {
|
|
|
254
356
|
<TableHeader>
|
|
255
357
|
<TableRow>
|
|
256
358
|
<TableHead>Name</TableHead>
|
|
257
|
-
<TableHead>
|
|
359
|
+
<TableHead>Email / ID</TableHead>
|
|
258
360
|
<TableHead>Role</TableHead>
|
|
259
361
|
<TableHead>Joined</TableHead>
|
|
260
362
|
{canManage && (
|
|
@@ -271,10 +373,19 @@ export default function WorkspaceMembers() {
|
|
|
271
373
|
return (
|
|
272
374
|
<TableRow key={member.id}>
|
|
273
375
|
<TableCell className="text-sm">
|
|
274
|
-
{member.user_name ?? "—"}
|
|
376
|
+
{member.user_name ?? member.email ?? "—"}
|
|
275
377
|
</TableCell>
|
|
276
|
-
<TableCell className="
|
|
277
|
-
{member.
|
|
378
|
+
<TableCell className="text-sm">
|
|
379
|
+
{member.email ? (
|
|
380
|
+
<div className="flex flex-col">
|
|
381
|
+
<span>{member.email}</span>
|
|
382
|
+
<span className="font-mono text-xs text-muted-foreground">
|
|
383
|
+
{member.user_id}
|
|
384
|
+
</span>
|
|
385
|
+
</div>
|
|
386
|
+
) : (
|
|
387
|
+
<span className="font-mono">{member.user_id}</span>
|
|
388
|
+
)}
|
|
278
389
|
</TableCell>
|
|
279
390
|
<TableCell>
|
|
280
391
|
{canManage && !isSelf ? (
|
|
@@ -1,29 +1,24 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
-
import { Outlet, useLocation } from "react-router-dom";
|
|
2
|
+
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
|
3
3
|
import { Loader2 } from "lucide-react";
|
|
4
4
|
import {
|
|
5
5
|
isAuthenticated,
|
|
6
6
|
getAuthTokens,
|
|
7
7
|
} from "@features/auth/lib/auth-session";
|
|
8
|
-
import {
|
|
9
|
-
import AutoLogin from "@features/auth/components/auto-login";
|
|
10
|
-
|
|
11
|
-
const isValidHttpUrl = (value: unknown): boolean => {
|
|
12
|
-
if (!value || typeof value !== "string") return false;
|
|
13
|
-
try {
|
|
14
|
-
const url = new URL(value.trim());
|
|
15
|
-
return url.protocol === "https:" || url.protocol === "http:";
|
|
16
|
-
} catch {
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const authEnabled = isValidHttpUrl(import.meta.env.VITE_ORCHEO_AUTH_ISSUER);
|
|
8
|
+
import { refreshSession } from "@features/auth/lib/auth-api";
|
|
22
9
|
|
|
23
10
|
type AuthState = "authenticated" | "refreshing" | "unauthenticated";
|
|
24
11
|
|
|
12
|
+
// Self-host/dev deployments that run the backend with auth disabled opt out of
|
|
13
|
+
// the login gate via VITE_ORCHEO_AUTH_DISABLED=true. Hosted deployments leave
|
|
14
|
+
// it unset so first-party login is required.
|
|
15
|
+
const authDisabled = (): boolean =>
|
|
16
|
+
String(import.meta.env?.VITE_ORCHEO_AUTH_DISABLED ?? "")
|
|
17
|
+
.trim()
|
|
18
|
+
.toLowerCase() === "true";
|
|
19
|
+
|
|
25
20
|
const resolveInitialAuthState = (): AuthState => {
|
|
26
|
-
if (
|
|
21
|
+
if (authDisabled() || isAuthenticated()) return "authenticated";
|
|
27
22
|
if (getAuthTokens()?.refreshToken) return "refreshing";
|
|
28
23
|
return "unauthenticated";
|
|
29
24
|
};
|
|
@@ -37,7 +32,7 @@ export default function RequireAuth() {
|
|
|
37
32
|
|
|
38
33
|
useEffect(() => {
|
|
39
34
|
if (authState !== "refreshing") return;
|
|
40
|
-
|
|
35
|
+
void refreshSession().then((refreshed) => {
|
|
41
36
|
setAuthState(refreshed ? "authenticated" : "unauthenticated");
|
|
42
37
|
});
|
|
43
38
|
}, [authState]);
|
|
@@ -52,5 +47,5 @@ export default function RequireAuth() {
|
|
|
52
47
|
);
|
|
53
48
|
}
|
|
54
49
|
|
|
55
|
-
return <
|
|
50
|
+
return <Navigate to="/login" replace state={{ from: redirectTo }} />;
|
|
56
51
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { buildBackendHttpUrl } from "@/lib/config";
|
|
2
|
+
import {
|
|
3
|
+
clearAuthSession,
|
|
4
|
+
getAuthTokens,
|
|
5
|
+
setAuthTokens,
|
|
6
|
+
} from "@features/auth/lib/auth-session";
|
|
7
|
+
|
|
8
|
+
export interface AuthUserProfile {
|
|
9
|
+
id: string;
|
|
10
|
+
email: string;
|
|
11
|
+
email_verified: boolean;
|
|
12
|
+
name: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SessionPayload {
|
|
16
|
+
access_token: string;
|
|
17
|
+
refresh_token: string;
|
|
18
|
+
expires_in: number;
|
|
19
|
+
user?: AuthUserProfile;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface TokenPayload {
|
|
23
|
+
access_token: string;
|
|
24
|
+
refresh_token: string;
|
|
25
|
+
expires_in: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const authUrl = (path: string): string =>
|
|
29
|
+
buildBackendHttpUrl(`/api/auth${path}`);
|
|
30
|
+
|
|
31
|
+
const persistTokens = (payload: TokenPayload): void => {
|
|
32
|
+
setAuthTokens({
|
|
33
|
+
accessToken: payload.access_token,
|
|
34
|
+
refreshToken: payload.refresh_token,
|
|
35
|
+
expiresAt:
|
|
36
|
+
typeof payload.expires_in === "number"
|
|
37
|
+
? Date.now() + payload.expires_in * 1000
|
|
38
|
+
: undefined,
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const readErrorMessage = async (
|
|
43
|
+
response: Response,
|
|
44
|
+
fallback: string,
|
|
45
|
+
): Promise<string> => {
|
|
46
|
+
try {
|
|
47
|
+
const body = (await response.json()) as { detail?: unknown };
|
|
48
|
+
const detail = body.detail;
|
|
49
|
+
if (typeof detail === "string") {
|
|
50
|
+
return detail;
|
|
51
|
+
}
|
|
52
|
+
if (detail && typeof detail === "object" && "message" in detail) {
|
|
53
|
+
const message = (detail as { message?: unknown }).message;
|
|
54
|
+
if (typeof message === "string" && message.trim()) {
|
|
55
|
+
return message;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// fall through to the fallback message
|
|
60
|
+
}
|
|
61
|
+
return fallback;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Request a passwordless challenge for an email. The backend responds
|
|
66
|
+
* identically whether or not the account exists, so a resolved promise only
|
|
67
|
+
* means the request was accepted — never that an email was definitely sent.
|
|
68
|
+
*/
|
|
69
|
+
export const startEmailChallenge = async (
|
|
70
|
+
email: string,
|
|
71
|
+
intent: "login" | "signup" = "login",
|
|
72
|
+
redirectTo?: string,
|
|
73
|
+
): Promise<void> => {
|
|
74
|
+
const response = await fetch(authUrl("/email/start"), {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
email,
|
|
79
|
+
intent,
|
|
80
|
+
...(redirectTo ? { redirect_to: redirectTo } : {}),
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
if (response.status === 429) {
|
|
84
|
+
throw new Error("Too many attempts. Please wait a moment and try again.");
|
|
85
|
+
}
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
await readErrorMessage(response, "Unable to send the sign-in email."),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const verify = async (
|
|
94
|
+
body: Record<string, string>,
|
|
95
|
+
): Promise<AuthUserProfile | undefined> => {
|
|
96
|
+
const response = await fetch(authUrl("/email/verify"), {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
await readErrorMessage(
|
|
104
|
+
response,
|
|
105
|
+
"This sign-in link or code is invalid or has expired.",
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const payload = (await response.json()) as SessionPayload;
|
|
110
|
+
persistTokens(payload);
|
|
111
|
+
return payload.user;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Verify a magic-link token and start an authenticated session. */
|
|
115
|
+
export const verifyEmailToken = (
|
|
116
|
+
token: string,
|
|
117
|
+
): Promise<AuthUserProfile | undefined> => verify({ token });
|
|
118
|
+
|
|
119
|
+
/** Verify an OTP code for an email and start an authenticated session. */
|
|
120
|
+
export const verifyEmailCode = (
|
|
121
|
+
email: string,
|
|
122
|
+
code: string,
|
|
123
|
+
): Promise<AuthUserProfile | undefined> => verify({ email, code });
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Rotate the stored refresh token into a fresh access token. Returns true on
|
|
127
|
+
* success; clears the local session and returns false when the refresh token
|
|
128
|
+
* is missing, invalid, or revoked.
|
|
129
|
+
*/
|
|
130
|
+
export const refreshSession = async (): Promise<boolean> => {
|
|
131
|
+
const tokens = getAuthTokens();
|
|
132
|
+
if (!tokens?.refreshToken) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
let response: Response;
|
|
136
|
+
try {
|
|
137
|
+
response = await fetch(authUrl("/refresh"), {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/json" },
|
|
140
|
+
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
clearAuthSession();
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
persistTokens((await response.json()) as TokenPayload);
|
|
151
|
+
} catch {
|
|
152
|
+
clearAuthSession();
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Revoke the server-side session (log out everywhere) and clear local tokens.
|
|
160
|
+
* Always clears the local session, even if the network call fails.
|
|
161
|
+
*/
|
|
162
|
+
export const logoutSession = async (): Promise<void> => {
|
|
163
|
+
let tokens = getAuthTokens();
|
|
164
|
+
const isExpired =
|
|
165
|
+
typeof tokens?.expiresAt === "number" && Date.now() >= tokens.expiresAt;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
if (isExpired && tokens?.refreshToken && (await refreshSession())) {
|
|
169
|
+
tokens = getAuthTokens();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (tokens?.accessToken) {
|
|
173
|
+
const response = await fetch(authUrl("/logout"), {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
176
|
+
});
|
|
177
|
+
if (
|
|
178
|
+
response.status === 401 &&
|
|
179
|
+
tokens.refreshToken &&
|
|
180
|
+
(await refreshSession())
|
|
181
|
+
) {
|
|
182
|
+
tokens = getAuthTokens();
|
|
183
|
+
if (tokens?.accessToken) {
|
|
184
|
+
await fetch(authUrl("/logout"), {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// Best effort — the local session is cleared regardless.
|
|
193
|
+
} finally {
|
|
194
|
+
clearAuthSession();
|
|
195
|
+
}
|
|
196
|
+
};
|