orcheo-studio 0.21.3 → 0.23.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 (99) hide show
  1. package/.bumpversion.cfg +1 -1
  2. package/dist/assets/{arc-CI8iBP22.js → arc-4lqv6Kjm.js} +1 -1
  3. package/dist/assets/{architectureDiagram-VXUJARFQ-9lccnHEL.js → architectureDiagram-VXUJARFQ-DjhiFMDA.js} +1 -1
  4. package/dist/assets/{blockDiagram-VD42YOAC-CJPKWPSp.js → blockDiagram-VD42YOAC-AZ1Rspxy.js} +1 -1
  5. package/dist/assets/{c4Diagram-YG6GDRKO-CuW7nex5.js → c4Diagram-YG6GDRKO-eH7LJ-pB.js} +1 -1
  6. package/dist/assets/channel-BbjHmdR-.js +1 -0
  7. package/dist/assets/{chunk-4BX2VUAB-BVQ3rR7o.js → chunk-4BX2VUAB-DJPBOwLj.js} +1 -1
  8. package/dist/assets/{chunk-55IACEB6-BTpqJ_MA.js → chunk-55IACEB6-BtkroFP3.js} +1 -1
  9. package/dist/assets/{chunk-B4BG7PRW-Duatgdyr.js → chunk-B4BG7PRW-9UuoCVI1.js} +1 -1
  10. package/dist/assets/{chunk-DI55MBZ5-CxEvlbgl.js → chunk-DI55MBZ5-DFossSVg.js} +1 -1
  11. package/dist/assets/{chunk-FMBD7UC4-C07lS0oH.js → chunk-FMBD7UC4-DZoCKsqW.js} +1 -1
  12. package/dist/assets/{chunk-QN33PNHL-C09PhK0w.js → chunk-QN33PNHL-CGFY-glY.js} +1 -1
  13. package/dist/assets/{chunk-QZHKN3VN-Dy3GgrIN.js → chunk-QZHKN3VN-CBx9MjYx.js} +1 -1
  14. package/dist/assets/{chunk-TZMSLE5B-9HPWR_AG.js → chunk-TZMSLE5B-BQ1P9b2p.js} +1 -1
  15. package/dist/assets/classDiagram-2ON5EDUG-B3gA5-gj.js +1 -0
  16. package/dist/assets/classDiagram-v2-WZHVMYZB-B3gA5-gj.js +1 -0
  17. package/dist/assets/clone-Ctw1pu1z.js +1 -0
  18. package/dist/assets/{cose-bilkent-S5V4N54A-B9TWyynz.js → cose-bilkent-S5V4N54A-Dy5ctOeT.js} +1 -1
  19. package/dist/assets/{dagre-6UL2VRFP-DFuJOuNQ.js → dagre-6UL2VRFP-CFPE5-yw.js} +1 -1
  20. package/dist/assets/{diagram-PSM6KHXK-3Jj-OXdd.js → diagram-PSM6KHXK-DmESDfR2.js} +1 -1
  21. package/dist/assets/{diagram-QEK2KX5R-DtAwZnbJ.js → diagram-QEK2KX5R-CumSxfGH.js} +1 -1
  22. package/dist/assets/{diagram-S2PKOQOG-atZg3Snz.js → diagram-S2PKOQOG-C1AaIowU.js} +1 -1
  23. package/dist/assets/{erDiagram-Q2GNP2WA-BoqqRkgy.js → erDiagram-Q2GNP2WA-BKk1ijaf.js} +1 -1
  24. package/dist/assets/{flowDiagram-NV44I4VS-Bmg68IjU.js → flowDiagram-NV44I4VS-BFMJTeTZ.js} +1 -1
  25. package/dist/assets/{ganttDiagram-JELNMOA3-53TZ4oeR.js → ganttDiagram-JELNMOA3-CYobSBkw.js} +1 -1
  26. package/dist/assets/{gitGraphDiagram-V2S2FVAM-D1DddraJ.js → gitGraphDiagram-V2S2FVAM-DYNLBSGu.js} +1 -1
  27. package/dist/assets/{graph-Cb10Gpkt.js → graph-CCH_5x2X.js} +1 -1
  28. package/dist/assets/index-BVY_PYJU.css +1 -0
  29. package/dist/assets/index-CD3NusgQ.js +790 -0
  30. package/dist/assets/{infoDiagram-HS3SLOUP-DxsHJs_0.js → infoDiagram-HS3SLOUP-C8K2nOgc.js} +1 -1
  31. package/dist/assets/{isUndefined-D1qU3_L5.js → isUndefined-Cg-i6rYz.js} +1 -1
  32. package/dist/assets/{journeyDiagram-XKPGCS4Q-CZDE4S5c.js → journeyDiagram-XKPGCS4Q-UjysRuQi.js} +1 -1
  33. package/dist/assets/{kanban-definition-3W4ZIXB7-pRhzMZ6H.js → kanban-definition-3W4ZIXB7-C0mLJbkK.js} +1 -1
  34. package/dist/assets/{layout-C0AW3yEL.js → layout-Ctq7ed-p.js} +1 -1
  35. package/dist/assets/{linear-DobjN4tM.js → linear-MdTfjVMd.js} +1 -1
  36. package/dist/assets/{min-B3Oh8jmi.js → min-CkeT6BIA.js} +1 -1
  37. package/dist/assets/{mindmap-definition-VGOIOE7T-g-SEeftZ.js → mindmap-definition-VGOIOE7T-B1KVkl4d.js} +1 -1
  38. package/dist/assets/{pieDiagram-ADFJNKIX-DiwhMRkx.js → pieDiagram-ADFJNKIX-Cw6TMSJj.js} +1 -1
  39. package/dist/assets/{quadrantDiagram-AYHSOK5B-BIZMdGTC.js → quadrantDiagram-AYHSOK5B-8Lq_anf6.js} +1 -1
  40. package/dist/assets/{requirementDiagram-UZGBJVZJ-D5nzjn-8.js → requirementDiagram-UZGBJVZJ-CXBuMo24.js} +1 -1
  41. package/dist/assets/{sankeyDiagram-TZEHDZUN-CnCESEvf.js → sankeyDiagram-TZEHDZUN-DggxeLrr.js} +1 -1
  42. package/dist/assets/{sequenceDiagram-WL72ISMW-DKBzac_O.js → sequenceDiagram-WL72ISMW-DZqrjzbw.js} +1 -1
  43. package/dist/assets/{stateDiagram-FKZM4ZOC-CcT1-5cM.js → stateDiagram-FKZM4ZOC-D4rhKl_S.js} +1 -1
  44. package/dist/assets/stateDiagram-v2-4FDKWEC3-C81OVjQ3.js +1 -0
  45. package/dist/assets/{timeline-definition-IT6M3QCI-DhFmWcr3.js → timeline-definition-IT6M3QCI-vp3knils.js} +1 -1
  46. package/dist/assets/{treemap-GDKQZRPO-CtMKB2mb.js → treemap-GDKQZRPO-RByzG6o8.js} +1 -1
  47. package/dist/assets/{xychartDiagram-PRI3JC2R-OhMt1B4k.js → xychartDiagram-PRI3JC2R-DOLBdPza.js} +1 -1
  48. package/dist/index.html +2 -7
  49. package/index.html +0 -5
  50. package/package.json +1 -1
  51. package/src/App.tsx +27 -2
  52. package/src/features/account/pages/invitation-accept.tsx +92 -0
  53. package/src/features/account/pages/workspace-members.tsx +153 -42
  54. package/src/features/auth/components/require-auth.tsx +13 -18
  55. package/src/features/auth/lib/auth-api.ts +196 -0
  56. package/src/features/auth/lib/auth-session.ts +0 -1
  57. package/src/features/auth/pages/login.tsx +169 -16
  58. package/src/features/auth/pages/verify.tsx +89 -0
  59. package/src/features/chatkit/components/public-chat-widget.tsx +89 -8
  60. package/src/features/chatkit/lib/chatkit-client.ts +66 -0
  61. package/src/features/chatkit/pages/public-chat.tsx +24 -33
  62. package/src/features/shared/components/top-navigation/account-menu.tsx +20 -7
  63. package/src/features/shared/components/top-navigation/studio-brand.tsx +14 -3
  64. package/src/features/workflow/components/dialogs/publish-workflow-dialog.tsx +121 -0
  65. package/src/features/workflow/data/templates/candidate-badges.ts +5 -0
  66. package/src/features/workflow/data/workflow-types.ts +1 -0
  67. package/src/features/workflow/lib/workflow-storage-api.ts +35 -4
  68. package/src/features/workflow/lib/workflow-storage-helpers.ts +15 -6
  69. package/src/features/workflow/lib/workflow-storage.ts +2 -1
  70. package/src/features/workflow/lib/workflow-storage.types.ts +10 -0
  71. package/src/features/workflow/pages/workflow/components/workflow-tab-content.tsx +122 -37
  72. package/src/features/workflow/pages/workflow/hooks/controller/build-layout-props.ts +2 -0
  73. package/src/features/workflow/pages/workflow/hooks/controller/use-workflow-lifecycle.ts +1 -0
  74. package/src/features/workflow/pages/workflow/hooks/use-workflow-loader.ts +5 -0
  75. package/src/features/workflow/pages/workflow/hooks/use-workflow-metadata-state.ts +3 -0
  76. package/src/features/workflow/pages/workflow-gallery/create-team-dialog.tsx +137 -0
  77. package/src/features/workflow/pages/workflow-gallery/onboard-team-dialog.tsx +57 -0
  78. package/src/features/workflow/pages/workflow-gallery/team-section.tsx +58 -0
  79. package/src/features/workflow/pages/workflow-gallery/use-workflow-gallery-actions.ts +124 -21
  80. package/src/features/workflow/pages/workflow-gallery/use-workflow-gallery-state.ts +22 -2
  81. package/src/features/workflow/pages/workflow-gallery/use-workflow-gallery.ts +2 -0
  82. package/src/features/workflow/pages/workflow-gallery/workflow-card.tsx +26 -5
  83. package/src/features/workflow/pages/workflow-gallery/workflow-gallery-tabs.tsx +92 -17
  84. package/src/features/workflow/pages/workflow-gallery.tsx +45 -1
  85. package/src/lib/api.ts +91 -2
  86. package/src/lib/workspace-routing.ts +15 -0
  87. package/src/main.tsx +7 -0
  88. package/src/vite-env.d.ts +1 -0
  89. package/dist/assets/channel-C-DaAO-p.js +0 -1
  90. package/dist/assets/classDiagram-2ON5EDUG-B2LNkTFb.js +0 -1
  91. package/dist/assets/classDiagram-v2-WZHVMYZB-B2LNkTFb.js +0 -1
  92. package/dist/assets/clone-DcGVOYZT.js +0 -1
  93. package/dist/assets/index-B0_h2wCA.css +0 -1
  94. package/dist/assets/index-DmIcay1T.js +0 -775
  95. package/dist/assets/stateDiagram-v2-4FDKWEC3-Cle1H2JX.js +0 -1
  96. package/src/features/MODULES.md +0 -199
  97. package/src/features/auth/components/auto-login.tsx +0 -51
  98. package/src/features/auth/lib/oidc-client.ts +0 -565
  99. package/src/features/auth/pages/oauth-callback.tsx +0 -63
@@ -0,0 +1,92 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useNavigate, useSearchParams } from "react-router-dom";
3
+ import { Loader2 } from "lucide-react";
4
+ import { Button } from "@/design-system/ui/button";
5
+ import { acceptWorkspaceInvitation } from "@/lib/api";
6
+ import { setSelectedWorkspaceSlug } from "@/lib/workspace-session";
7
+ import { getWorkspaceGalleryPath } from "@/lib/workspace-routing";
8
+
9
+ type AcceptState = "working" | "success" | "error";
10
+
11
+ export default function InvitationAccept() {
12
+ const [searchParams] = useSearchParams();
13
+ const navigate = useNavigate();
14
+ const token = searchParams.get("token");
15
+ const [state, setState] = useState<AcceptState>("working");
16
+ const [message, setMessage] = useState<string>("");
17
+ const [workspaceName, setWorkspaceName] = useState<string>("");
18
+ // Guard against the effect running twice (React 18 StrictMode) redeeming
19
+ // the single-use token twice.
20
+ const startedRef = useRef(false);
21
+
22
+ useEffect(() => {
23
+ // The single-use token must be redeemed exactly once. `startedRef` guards
24
+ // against React 18 StrictMode's double-invoke. We intentionally do not gate
25
+ // the result on an "active" flag: the in-flight redemption must always
26
+ // settle the UI (navigate or show the error), even across a StrictMode
27
+ // remount of the same component instance.
28
+ if (startedRef.current) {
29
+ return;
30
+ }
31
+ startedRef.current = true;
32
+
33
+ if (!token) {
34
+ setState("error");
35
+ setMessage("This invitation link is missing its token.");
36
+ return;
37
+ }
38
+
39
+ void (async () => {
40
+ try {
41
+ const result = await acceptWorkspaceInvitation(token);
42
+ setWorkspaceName(result.name);
43
+ setSelectedWorkspaceSlug(result.slug);
44
+ setState("success");
45
+ navigate(getWorkspaceGalleryPath(result.slug), { replace: true });
46
+ } catch (err) {
47
+ setState("error");
48
+ setMessage(
49
+ err instanceof Error
50
+ ? err.message
51
+ : "We couldn't accept this invitation.",
52
+ );
53
+ }
54
+ })();
55
+ }, [token, navigate]);
56
+
57
+ return (
58
+ <div className="flex min-h-screen items-center justify-center p-6">
59
+ <div className="w-full max-w-md rounded-lg border p-6 text-center">
60
+ {state === "working" && (
61
+ <div className="flex flex-col items-center gap-3">
62
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
63
+ <p className="text-sm text-muted-foreground">
64
+ Accepting your invitation…
65
+ </p>
66
+ </div>
67
+ )}
68
+
69
+ {state === "success" && (
70
+ <div className="flex flex-col items-center gap-3">
71
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
72
+ <p className="text-sm text-muted-foreground">
73
+ {workspaceName
74
+ ? `Welcome to ${workspaceName}. Redirecting…`
75
+ : "Invitation accepted. Redirecting…"}
76
+ </p>
77
+ </div>
78
+ )}
79
+
80
+ {state === "error" && (
81
+ <div className="flex flex-col items-center gap-4">
82
+ <h2 className="text-lg font-medium">Invitation unavailable</h2>
83
+ <p className="text-sm text-muted-foreground">{message}</p>
84
+ <Button onClick={() => navigate("/", { replace: true })}>
85
+ Go to your workspaces
86
+ </Button>
87
+ </div>
88
+ )}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
@@ -22,35 +22,41 @@ import { getAuthenticatedUserProfile } from "@features/auth/lib/auth-session";
22
22
  import {
23
23
  getActiveWorkspace,
24
24
  listWorkspaceMembers,
25
- addWorkspaceMember,
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 USER_ID_PATTERN = /^[A-Za-z0-9._:@+-]{3,128}$/;
36
- const USER_ID_VALIDATION_MESSAGE =
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 isValidUserId = (userId: string): boolean =>
40
- USER_ID_PATTERN.test(userId.trim());
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 [newUserId, setNewUserId] = useState("");
49
- const [newUserIdError, setNewUserIdError] = useState<string | null>(null);
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 listWorkspaceMembers(slug);
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 handleAddMember = async () => {
106
- const normalizedUserId = newUserId.trim();
107
- if (!slug || !normalizedUserId) {
115
+ const handleInvite = async () => {
116
+ const normalizedEmail = newEmail.trim();
117
+ if (!slug || !normalizedEmail) {
108
118
  return;
109
119
  }
110
- if (!isValidUserId(normalizedUserId)) {
111
- setNewUserIdError(USER_ID_VALIDATION_MESSAGE);
120
+ if (!isValidEmail(normalizedEmail)) {
121
+ setNewEmailError(EMAIL_VALIDATION_MESSAGE);
112
122
  return;
113
123
  }
114
124
  setIsAdding(true);
115
125
  try {
116
- const member = await addWorkspaceMember(slug, {
117
- user_id: normalizedUserId,
126
+ const invitation = await createWorkspaceInvitation(slug, {
127
+ email: normalizedEmail,
118
128
  role: newRole,
119
129
  });
120
- setMembers((prev) => [...prev, member]);
121
- setNewUserId("");
122
- setNewUserIdError(null);
130
+ setInvitations((prev) => [invitation, ...prev]);
131
+ setNewEmail("");
132
+ setNewEmailError(null);
123
133
  setNewRole("editor");
124
- toast({ title: "Member added successfully" });
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 add member",
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-4 text-lg font-medium">Add Member</h3>
224
+ <h3 className="mb-1 text-lg font-medium">Invite Member</h3>
225
+ <p className="mb-4 text-sm text-muted-foreground">
226
+ We&apos;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-user-id">User ID</Label>
231
+ <Label htmlFor="new-email">Email</Label>
193
232
  <Input
194
- id="new-user-id"
195
- placeholder="Enter user ID"
196
- value={newUserId}
233
+ id="new-email"
234
+ type="email"
235
+ placeholder="person@example.com"
236
+ value={newEmail}
197
237
  onChange={(e) => {
198
- setNewUserId(e.target.value);
199
- setNewUserIdError(null);
238
+ setNewEmail(e.target.value);
239
+ setNewEmailError(null);
200
240
  }}
201
241
  disabled={isAdding}
202
- aria-invalid={newUserIdError ? true : undefined}
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
- {newUserIdError && (
208
- <p id="new-user-id-error" className="text-xs text-destructive">
209
- {newUserIdError}
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 handleAddMember()}
238
- disabled={isAdding || !newUserId.trim()}
275
+ onClick={() => void handleInvite()}
276
+ disabled={isAdding || !newEmail.trim()}
239
277
  >
240
- {isAdding ? "Adding..." : "Add"}
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>User ID</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="font-mono text-sm">
277
- {member.user_id}
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 { tryRefreshTokens } from "@features/auth/lib/oidc-client";
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 (!authEnabled || isAuthenticated()) return "authenticated";
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
- tryRefreshTokens().then((refreshed) => {
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 <AutoLogin redirectTo={redirectTo} />;
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
+ };
@@ -234,7 +234,6 @@ export const getAccessToken = (): string | null => {
234
234
  return null;
235
235
  }
236
236
  if (!isTokenFresh(tokens)) {
237
- clearAuthSession();
238
237
  return null;
239
238
  }
240
239
  return tokens.accessToken;