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.
Files changed (88) hide show
  1. package/.bumpversion.cfg +1 -1
  2. package/dist/assets/{arc-De9dLqyl.js → arc-CqnxfCHp.js} +1 -1
  3. package/dist/assets/{architectureDiagram-VXUJARFQ-hEslfmYm.js → architectureDiagram-VXUJARFQ-UGPIhA1j.js} +1 -1
  4. package/dist/assets/{blockDiagram-VD42YOAC-CyWSMWJa.js → blockDiagram-VD42YOAC-Cn6wka40.js} +1 -1
  5. package/dist/assets/{c4Diagram-YG6GDRKO-CxoTVJ47.js → c4Diagram-YG6GDRKO-BvPRNdur.js} +1 -1
  6. package/dist/assets/channel-FjxqoZWk.js +1 -0
  7. package/dist/assets/{chunk-4BX2VUAB-twftUw9o.js → chunk-4BX2VUAB-F_o1AmTw.js} +1 -1
  8. package/dist/assets/{chunk-55IACEB6-DQZDNQUo.js → chunk-55IACEB6-Cl075es2.js} +1 -1
  9. package/dist/assets/{chunk-B4BG7PRW-BYGOoNB5.js → chunk-B4BG7PRW-DcErQhy9.js} +1 -1
  10. package/dist/assets/{chunk-DI55MBZ5-qyPhcZF3.js → chunk-DI55MBZ5-CFxCndOp.js} +1 -1
  11. package/dist/assets/{chunk-FMBD7UC4-BgUDHGEG.js → chunk-FMBD7UC4-XOcF0fgy.js} +1 -1
  12. package/dist/assets/{chunk-QN33PNHL-DHlMh8NC.js → chunk-QN33PNHL-CjJH_Bvj.js} +1 -1
  13. package/dist/assets/{chunk-QZHKN3VN-Bfc8IV0O.js → chunk-QZHKN3VN-nLb-Go8u.js} +1 -1
  14. package/dist/assets/{chunk-TZMSLE5B-BqwdftTm.js → chunk-TZMSLE5B-L07ovmT0.js} +1 -1
  15. package/dist/assets/classDiagram-2ON5EDUG-8JYNY9Ic.js +1 -0
  16. package/dist/assets/classDiagram-v2-WZHVMYZB-8JYNY9Ic.js +1 -0
  17. package/dist/assets/clone-NPVQC0Uc.js +1 -0
  18. package/dist/assets/{cose-bilkent-S5V4N54A-g5agH7fS.js → cose-bilkent-S5V4N54A-B6IcYcMk.js} +1 -1
  19. package/dist/assets/{dagre-6UL2VRFP-ajLMaXPP.js → dagre-6UL2VRFP-CN7ADhef.js} +1 -1
  20. package/dist/assets/{diagram-PSM6KHXK-BndVcBhB.js → diagram-PSM6KHXK-3chqLyzk.js} +1 -1
  21. package/dist/assets/{diagram-QEK2KX5R-BFiIYeu7.js → diagram-QEK2KX5R-MrbKZWaG.js} +1 -1
  22. package/dist/assets/{diagram-S2PKOQOG-DXy-A9pn.js → diagram-S2PKOQOG-Dr7BijIx.js} +1 -1
  23. package/dist/assets/{erDiagram-Q2GNP2WA-D_Bac_58.js → erDiagram-Q2GNP2WA-Ck-v9a6N.js} +1 -1
  24. package/dist/assets/{flowDiagram-NV44I4VS-DhEi8qtz.js → flowDiagram-NV44I4VS-D662eMD1.js} +1 -1
  25. package/dist/assets/{ganttDiagram-JELNMOA3-BtpUvOuc.js → ganttDiagram-JELNMOA3-c2mXK47M.js} +1 -1
  26. package/dist/assets/{gitGraphDiagram-V2S2FVAM-MBES60cx.js → gitGraphDiagram-V2S2FVAM-DUMhfhsS.js} +1 -1
  27. package/dist/assets/{graph-BcwvIN-m.js → graph-DkWlmVun.js} +1 -1
  28. package/dist/assets/index-BVY_PYJU.css +1 -0
  29. package/dist/assets/index-kH6EkBsZ.js +790 -0
  30. package/dist/assets/{infoDiagram-HS3SLOUP-vaIFYyC9.js → infoDiagram-HS3SLOUP-OTL7qvkP.js} +1 -1
  31. package/dist/assets/{isUndefined-DVO6xakl.js → isUndefined-BmZvH1PX.js} +1 -1
  32. package/dist/assets/{journeyDiagram-XKPGCS4Q-D-_q99If.js → journeyDiagram-XKPGCS4Q-BYzqE5D8.js} +1 -1
  33. package/dist/assets/{kanban-definition-3W4ZIXB7-i_AZcs_J.js → kanban-definition-3W4ZIXB7-NtzQ4y9p.js} +1 -1
  34. package/dist/assets/{layout-CPJ9nR_z.js → layout-CwhyLMkP.js} +1 -1
  35. package/dist/assets/{linear-KBGpPBiH.js → linear-Cirf2V1x.js} +1 -1
  36. package/dist/assets/{min-By5f7YdD.js → min-C81vLJYh.js} +1 -1
  37. package/dist/assets/{mindmap-definition-VGOIOE7T-DRjtRxFS.js → mindmap-definition-VGOIOE7T-TDnLQIKZ.js} +1 -1
  38. package/dist/assets/{pieDiagram-ADFJNKIX-BkkSglWM.js → pieDiagram-ADFJNKIX-d0-JhjNZ.js} +1 -1
  39. package/dist/assets/{quadrantDiagram-AYHSOK5B-DY-Ng2qt.js → quadrantDiagram-AYHSOK5B-wCoihp8a.js} +1 -1
  40. package/dist/assets/{requirementDiagram-UZGBJVZJ-B5w0etXG.js → requirementDiagram-UZGBJVZJ-BqiCIjcI.js} +1 -1
  41. package/dist/assets/{sankeyDiagram-TZEHDZUN-BWntUp8b.js → sankeyDiagram-TZEHDZUN-BPH0Pd1D.js} +1 -1
  42. package/dist/assets/{sequenceDiagram-WL72ISMW-Cl77fd7y.js → sequenceDiagram-WL72ISMW-B8FfJraH.js} +1 -1
  43. package/dist/assets/{stateDiagram-FKZM4ZOC-B8_DoPW6.js → stateDiagram-FKZM4ZOC-ZQR3Q9_8.js} +1 -1
  44. package/dist/assets/stateDiagram-v2-4FDKWEC3-uDYn1wPF.js +1 -0
  45. package/dist/assets/{timeline-definition-IT6M3QCI-udvNLTe_.js → timeline-definition-IT6M3QCI-D-arYxX5.js} +1 -1
  46. package/dist/assets/{treemap-GDKQZRPO-BSvoMl74.js → treemap-GDKQZRPO-Dw5BHp_B.js} +1 -1
  47. package/dist/assets/{xychartDiagram-PRI3JC2R-CTdy-FCX.js → xychartDiagram-PRI3JC2R-Dt1zH6V2.js} +1 -1
  48. package/dist/index.html +2 -2
  49. package/package.json +1 -1
  50. package/src/App.tsx +7 -2
  51. package/src/features/account/pages/invitation-accept.tsx +92 -0
  52. package/src/features/account/pages/workspace-members.tsx +153 -42
  53. package/src/features/auth/components/require-auth.tsx +13 -18
  54. package/src/features/auth/lib/auth-api.ts +196 -0
  55. package/src/features/auth/lib/auth-session.ts +0 -1
  56. package/src/features/auth/pages/login.tsx +169 -16
  57. package/src/features/auth/pages/verify.tsx +89 -0
  58. package/src/features/chatkit/components/public-chat-widget.tsx +89 -8
  59. package/src/features/chatkit/lib/chatkit-client.ts +66 -0
  60. package/src/features/chatkit/pages/public-chat.tsx +17 -30
  61. package/src/features/shared/components/top-navigation/account-menu.tsx +20 -7
  62. package/src/features/shared/components/top-navigation/studio-brand.tsx +14 -3
  63. package/src/features/workflow/components/dialogs/publish-workflow-dialog.tsx +121 -0
  64. package/src/features/workflow/data/templates/candidate-badges.ts +28 -0
  65. package/src/features/workflow/data/workflow-types.ts +6 -0
  66. package/src/features/workflow/lib/workflow-storage-helpers.ts +5 -3
  67. package/src/features/workflow/lib/workflow-storage-versioning.ts +4 -0
  68. package/src/features/workflow/lib/workflow-storage.types.ts +1 -0
  69. package/src/features/workflow/pages/workflow/components/workflow-tab-content.tsx +122 -37
  70. package/src/features/workflow/pages/workflow/hooks/controller/build-layout-props.ts +2 -0
  71. package/src/features/workflow/pages/workflow/hooks/controller/use-workflow-lifecycle.ts +1 -0
  72. package/src/features/workflow/pages/workflow/hooks/use-workflow-loader.ts +5 -0
  73. package/src/features/workflow/pages/workflow/hooks/use-workflow-metadata-state.ts +3 -0
  74. package/src/features/workflow/pages/workflow/hooks/use-workflow-saver.ts +11 -2
  75. package/src/features/workflow/pages/workflow-gallery/workflow-gallery-tabs.tsx +47 -4
  76. package/src/lib/api.ts +78 -1
  77. package/src/vite-env.d.ts +1 -0
  78. package/dist/assets/channel-CzCzGn0f.js +0 -1
  79. package/dist/assets/classDiagram-2ON5EDUG-ekNI5d_4.js +0 -1
  80. package/dist/assets/classDiagram-v2-WZHVMYZB-ekNI5d_4.js +0 -1
  81. package/dist/assets/clone-DowBu1Kh.js +0 -1
  82. package/dist/assets/index-DLYzf0K-.js +0 -775
  83. package/dist/assets/index-DebsXgfj.css +0 -1
  84. package/dist/assets/stateDiagram-v2-4FDKWEC3-DHL7p-md.js +0 -1
  85. package/src/features/MODULES.md +0 -199
  86. package/src/features/auth/components/auto-login.tsx +0 -51
  87. package/src/features/auth/lib/oidc-client.ts +0 -565
  88. package/src/features/auth/pages/oauth-callback.tsx +0 -63
@@ -1,24 +1,177 @@
1
- import { useMemo } from "react";
2
- import { useLocation } from "react-router-dom";
3
- import AutoLogin from "@features/auth/components/auto-login";
1
+ import { useMemo, useState, type FormEvent } from "react";
2
+ import { useLocation, useNavigate } from "react-router-dom";
3
+ import { Loader2, Mail } from "lucide-react";
4
+ import { Button } from "@/design-system/ui/button";
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from "@/design-system/ui/card";
12
+ import { Input } from "@/design-system/ui/input";
13
+ import { Label } from "@/design-system/ui/label";
14
+ import {
15
+ startEmailChallenge,
16
+ verifyEmailCode,
17
+ } from "@features/auth/lib/auth-api";
4
18
 
5
- const parseInviteContext = (search: string) => {
19
+ // Only allow same-origin relative paths to avoid open-redirect via state/query.
20
+ const sanitizeRedirect = (value: unknown): string | undefined => {
21
+ if (typeof value !== "string") {
22
+ return undefined;
23
+ }
24
+ const trimmed = value.trim();
25
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//")) {
26
+ return undefined;
27
+ }
28
+ return trimmed;
29
+ };
30
+
31
+ const resolveRedirectTo = (state: unknown, search: string): string => {
32
+ const fromState =
33
+ state && typeof state === "object"
34
+ ? (state as { from?: unknown }).from
35
+ : undefined;
36
+ const stateRedirect = sanitizeRedirect(fromState);
37
+ if (stateRedirect) {
38
+ return stateRedirect;
39
+ }
6
40
  const params = new URLSearchParams(search);
7
- const normalize = (v: string | null) => v?.trim() || undefined;
8
- return {
9
- invitation: normalize(params.get("invitation")),
10
- organization: normalize(params.get("organization")),
11
- organizationName: normalize(params.get("organization_name")),
12
- loginHint: normalize(params.get("login_hint")),
13
- screenHint: normalize(params.get("screen_hint")),
14
- };
41
+ return sanitizeRedirect(params.get("redirect") ?? params.get("from")) ?? "/";
15
42
  };
16
43
 
44
+ type Stage = "email" | "sent";
45
+
17
46
  export default function Login() {
18
47
  const location = useLocation();
19
- const inviteContext = useMemo(
20
- () => parseInviteContext(location.search),
21
- [location.search],
48
+ const navigate = useNavigate();
49
+ const redirectTo = useMemo(
50
+ () => resolveRedirectTo(location.state, location.search),
51
+ [location.state, location.search],
52
+ );
53
+
54
+ const [stage, setStage] = useState<Stage>("email");
55
+ const [email, setEmail] = useState("");
56
+ const [code, setCode] = useState("");
57
+ const [busy, setBusy] = useState(false);
58
+ const [error, setError] = useState<string | null>(null);
59
+
60
+ const handleSendEmail = async (event: FormEvent) => {
61
+ event.preventDefault();
62
+ setError(null);
63
+ setBusy(true);
64
+ try {
65
+ await startEmailChallenge(email.trim(), "login", redirectTo);
66
+ setStage("sent");
67
+ } catch (err) {
68
+ setError(
69
+ err instanceof Error ? err.message : "Unable to send the sign-in email.",
70
+ );
71
+ } finally {
72
+ setBusy(false);
73
+ }
74
+ };
75
+
76
+ const handleVerifyCode = async (event: FormEvent) => {
77
+ event.preventDefault();
78
+ setError(null);
79
+ setBusy(true);
80
+ try {
81
+ await verifyEmailCode(email.trim(), code.trim());
82
+ navigate(redirectTo, { replace: true });
83
+ } catch (err) {
84
+ setError(
85
+ err instanceof Error ? err.message : "That code is invalid or expired.",
86
+ );
87
+ } finally {
88
+ setBusy(false);
89
+ }
90
+ };
91
+
92
+ const resetToEmail = () => {
93
+ setStage("email");
94
+ setCode("");
95
+ setError(null);
96
+ };
97
+
98
+ return (
99
+ <div className="flex min-h-screen items-center justify-center bg-[#ebd3a6] p-6 text-foreground">
100
+ <Card className="w-full max-w-md border-border bg-card text-card-foreground shadow-xl">
101
+ <CardHeader className="text-center">
102
+ <CardTitle className="text-xl">Sign in to Orcheo</CardTitle>
103
+ <CardDescription>
104
+ {stage === "email"
105
+ ? "Enter your email and we'll send you a sign-in link and code."
106
+ : `We sent a sign-in link and code to ${email}.`}
107
+ </CardDescription>
108
+ </CardHeader>
109
+ <CardContent>
110
+ {stage === "email" ? (
111
+ <form className="flex flex-col gap-4" onSubmit={handleSendEmail}>
112
+ <div className="flex flex-col gap-2">
113
+ <Label htmlFor="email">Email address</Label>
114
+ <Input
115
+ id="email"
116
+ type="email"
117
+ autoComplete="email"
118
+ required
119
+ autoFocus
120
+ placeholder="you@example.com"
121
+ value={email}
122
+ onChange={(event) => setEmail(event.target.value)}
123
+ />
124
+ </div>
125
+ {error && <p className="text-sm text-destructive">{error}</p>}
126
+ <Button type="submit" disabled={busy || !email.trim()}>
127
+ {busy ? (
128
+ <Loader2 className="h-4 w-4 animate-spin" />
129
+ ) : (
130
+ <>
131
+ <Mail className="mr-2 h-4 w-4" />
132
+ Continue with email
133
+ </>
134
+ )}
135
+ </Button>
136
+ </form>
137
+ ) : (
138
+ <form className="flex flex-col gap-4" onSubmit={handleVerifyCode}>
139
+ <p className="text-sm text-muted-foreground">
140
+ Click the link in your email, or enter the code below.
141
+ </p>
142
+ <div className="flex flex-col gap-2">
143
+ <Label htmlFor="code">Sign-in code</Label>
144
+ <Input
145
+ id="code"
146
+ inputMode="numeric"
147
+ autoComplete="one-time-code"
148
+ required
149
+ autoFocus
150
+ placeholder="123456"
151
+ value={code}
152
+ onChange={(event) => setCode(event.target.value)}
153
+ />
154
+ </div>
155
+ {error && <p className="text-sm text-destructive">{error}</p>}
156
+ <Button type="submit" disabled={busy || !code.trim()}>
157
+ {busy ? (
158
+ <Loader2 className="h-4 w-4 animate-spin" />
159
+ ) : (
160
+ "Verify code"
161
+ )}
162
+ </Button>
163
+ <Button
164
+ type="button"
165
+ variant="ghost"
166
+ onClick={resetToEmail}
167
+ disabled={busy}
168
+ >
169
+ Use a different email
170
+ </Button>
171
+ </form>
172
+ )}
173
+ </CardContent>
174
+ </Card>
175
+ </div>
22
176
  );
23
- return <AutoLogin {...inviteContext} />;
24
177
  }
@@ -0,0 +1,89 @@
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 {
6
+ Card,
7
+ CardContent,
8
+ CardHeader,
9
+ CardTitle,
10
+ } from "@/design-system/ui/card";
11
+ import { verifyEmailToken } from "@features/auth/lib/auth-api";
12
+
13
+ // Only allow same-origin relative paths to avoid open-redirect.
14
+ const sanitizeRedirect = (value: string | null): string => {
15
+ if (!value) {
16
+ return "/";
17
+ }
18
+ const trimmed = value.trim();
19
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//")) {
20
+ return "/";
21
+ }
22
+ return trimmed;
23
+ };
24
+
25
+ type VerifyState = "working" | "error";
26
+
27
+ export default function AuthVerify() {
28
+ const [searchParams] = useSearchParams();
29
+ const navigate = useNavigate();
30
+ const token = searchParams.get("token");
31
+ const redirectTo = sanitizeRedirect(searchParams.get("redirect"));
32
+ const [state, setState] = useState<VerifyState>("working");
33
+ const [message, setMessage] = useState("Signing you in…");
34
+ // The magic-link token is single-use; guard against React 18 StrictMode's
35
+ // double-invoke redeeming it twice.
36
+ const startedRef = useRef(false);
37
+
38
+ useEffect(() => {
39
+ if (startedRef.current) {
40
+ return;
41
+ }
42
+ startedRef.current = true;
43
+
44
+ if (!token) {
45
+ setState("error");
46
+ setMessage("This sign-in link is missing its token.");
47
+ return;
48
+ }
49
+
50
+ void (async () => {
51
+ try {
52
+ await verifyEmailToken(token);
53
+ navigate(redirectTo, { replace: true });
54
+ } catch (err) {
55
+ setState("error");
56
+ setMessage(
57
+ err instanceof Error
58
+ ? err.message
59
+ : "This sign-in link is invalid or has expired.",
60
+ );
61
+ }
62
+ })();
63
+ }, [token, redirectTo, navigate]);
64
+
65
+ return (
66
+ <div className="flex min-h-screen items-center justify-center bg-[#ebd3a6] p-6 text-foreground">
67
+ <Card className="w-full max-w-md border-border bg-card text-card-foreground shadow-xl">
68
+ <CardHeader>
69
+ <CardTitle>Signing in</CardTitle>
70
+ </CardHeader>
71
+ <CardContent className="flex flex-col items-center gap-4 text-center text-sm text-muted-foreground">
72
+ {state === "working" ? (
73
+ <div className="flex items-center gap-3">
74
+ <Loader2 className="h-4 w-4 animate-spin" />
75
+ <span>{message}</span>
76
+ </div>
77
+ ) : (
78
+ <>
79
+ <p>{message}</p>
80
+ <Button onClick={() => navigate("/login", { replace: true })}>
81
+ Back to sign in
82
+ </Button>
83
+ </>
84
+ )}
85
+ </CardContent>
86
+ </Card>
87
+ </div>
88
+ );
89
+ }
@@ -1,5 +1,6 @@
1
- import { useMemo } from "react";
1
+ import { useCallback, useMemo, useRef } from "react";
2
2
  import type { UseChatKitOptions } from "@openai/chatkit-react";
3
+ import { authFetch } from "@/lib/auth-fetch";
3
4
  import { buildBackendHttpUrl } from "@/lib/config";
4
5
  import { cn } from "@/lib/utils";
5
6
  import type { ColorScheme } from "@/hooks/use-color-scheme";
@@ -9,6 +10,7 @@ import type {
9
10
  } from "@features/workflow/lib/workflow-storage.types";
10
11
  import { buildChatKitAttachmentOptions } from "@features/chatkit/lib/chatkit-attachments";
11
12
  import {
13
+ buildAuthenticatedChatFetch,
12
14
  buildPublicChatFetch,
13
15
  getChatKitDomainKey,
14
16
  type PublicChatHttpError,
@@ -24,6 +26,17 @@ interface PublicChatWidgetProps {
24
26
  workflowId: string;
25
27
  workflowName: string;
26
28
  backendBaseUrl?: string;
29
+ /**
30
+ * When true the workflow is published for workspace members only. The widget
31
+ * mints a ChatKit session token (JWT) so the backend can enforce membership.
32
+ */
33
+ requireLogin?: boolean;
34
+ useSessionToken?: boolean;
35
+ /**
36
+ * Workspace slug the workflow belongs to. Sent as `X-Orcheo-Workspace` when
37
+ * minting the session token so membership resolves to the correct workspace.
38
+ */
39
+ workspaceSlug?: string;
27
40
  onReady?: () => void;
28
41
  onHttpError?: (error: PublicChatHttpError) => void;
29
42
  onLog?: (payload: Record<string, unknown>) => void;
@@ -43,6 +56,9 @@ export function PublicChatWidget({
43
56
  workflowId,
44
57
  workflowName,
45
58
  backendBaseUrl,
59
+ requireLogin = false,
60
+ useSessionToken = requireLogin,
61
+ workspaceSlug,
46
62
  onReady,
47
63
  onHttpError,
48
64
  onLog,
@@ -51,6 +67,60 @@ export function PublicChatWidget({
51
67
  startScreenPrompts,
52
68
  supportedModels,
53
69
  }: PublicChatWidgetProps) {
70
+ const cachedSessionTokenRef = useRef<{
71
+ token: string;
72
+ expiresAt: number;
73
+ } | null>(null);
74
+
75
+ const resolveSessionToken = useCallback(async (): Promise<string> => {
76
+ const cached = cachedSessionTokenRef.current;
77
+ if (cached && cached.expiresAt - Date.now() > 30_000) {
78
+ return cached.token;
79
+ }
80
+
81
+ // Use the workflow-scoped session endpoint so the backend resolves the
82
+ // caller's workspace via membership (real OIDC tokens do not carry
83
+ // workspace_ids) and scopes the JWT to this workflow + workspace.
84
+ const url = buildBackendHttpUrl(
85
+ `/api/workflows/${encodeURIComponent(workflowId)}/chatkit/session`,
86
+ backendBaseUrl,
87
+ );
88
+ const headers: Record<string, string> = {};
89
+ if (workspaceSlug) {
90
+ headers["X-Orcheo-Workspace"] = workspaceSlug;
91
+ }
92
+ const response = await authFetch(url, { method: "POST", headers });
93
+ if (!response.ok) {
94
+ if (response.status === 403) {
95
+ throw new Error(
96
+ "Unable to obtain a ChatKit session token: your account is missing the required chatkit:session scope or workspace membership.",
97
+ );
98
+ }
99
+ if (response.status === 401) {
100
+ throw new Error(
101
+ "Unable to obtain a ChatKit session token: sign in again and retry.",
102
+ );
103
+ }
104
+ throw new Error("Failed to obtain ChatKit session token");
105
+ }
106
+ const data = (await response.json()) as {
107
+ client_secret?: string;
108
+ clientSecret?: string;
109
+ expires_at?: string;
110
+ expiresAt?: string;
111
+ };
112
+ const secret = data.client_secret ?? data.clientSecret;
113
+ if (!secret) {
114
+ throw new Error("ChatKit session response missing client secret");
115
+ }
116
+ const expiresAtRaw = data.expires_at ?? data.expiresAt;
117
+ const expiresAt = expiresAtRaw ? Date.parse(expiresAtRaw) : Number.NaN;
118
+ if (Number.isFinite(expiresAt)) {
119
+ cachedSessionTokenRef.current = { token: secret, expiresAt };
120
+ }
121
+ return secret;
122
+ }, [backendBaseUrl, workflowId, workspaceSlug]);
123
+
54
124
  const options = useMemo<UseChatKitOptions>(() => {
55
125
  const domainKey = getChatKitDomainKey();
56
126
  const uploadBase = buildBackendHttpUrl(
@@ -62,17 +132,25 @@ export function PublicChatWidget({
62
132
  const uploadUrl = uploadUrlObj.toString();
63
133
  const modelOptions = buildModelOptions(supportedModels);
64
134
 
135
+ const fetchImpl =
136
+ requireLogin && useSessionToken
137
+ ? buildAuthenticatedChatFetch({
138
+ workflowId,
139
+ onHttpError,
140
+ metadata: { workflow_name: workflowName },
141
+ getToken: resolveSessionToken,
142
+ })
143
+ : buildPublicChatFetch({
144
+ workflowId,
145
+ onHttpError,
146
+ metadata: { workflow_name: workflowName },
147
+ });
148
+
65
149
  return {
66
150
  api: {
67
151
  url: buildBackendHttpUrl("/api/chatkit", backendBaseUrl),
68
152
  domainKey,
69
- fetch: buildPublicChatFetch({
70
- workflowId,
71
- onHttpError,
72
- metadata: {
73
- workflow_name: workflowName,
74
- },
75
- }),
153
+ fetch: fetchImpl,
76
154
  uploadStrategy: { type: "direct", uploadUrl },
77
155
  },
78
156
  header: {
@@ -133,6 +211,9 @@ export function PublicChatWidget({
133
211
  supportedModels,
134
212
  workflowId,
135
213
  workflowName,
214
+ requireLogin,
215
+ resolveSessionToken,
216
+ useSessionToken,
136
217
  ]);
137
218
 
138
219
  return (
@@ -86,6 +86,41 @@ export const getChatKitDomainKey = (): string => {
86
86
  return fromEnv ?? fromRuntime ?? DEFAULT_DOMAIN_KEY;
87
87
  };
88
88
 
89
+ export const VISITOR_ID_HEADER = "X-Orcheo-Visitor-Id";
90
+ const VISITOR_ID_STORAGE_KEY = "orcheo.chatkit.visitorId";
91
+
92
+ const createVisitorId = (): string => {
93
+ const cryptoObj =
94
+ typeof globalThis !== "undefined"
95
+ ? (globalThis.crypto as Crypto | undefined)
96
+ : undefined;
97
+ if (cryptoObj?.randomUUID) {
98
+ return cryptoObj.randomUUID();
99
+ }
100
+ return `v-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
101
+ };
102
+
103
+ /**
104
+ * Return a stable, browser-local visitor id used to scope anonymous chat
105
+ * history to this device. The id is opaque and never used for authorization —
106
+ * the backend only filters a visitor's own threads by it. Falls back to an
107
+ * ephemeral per-call id when storage is unavailable (e.g. SSR, private mode).
108
+ */
109
+ export const getOrCreateVisitorId = (): string => {
110
+ try {
111
+ const storage = window.localStorage;
112
+ const existing = safeString(storage.getItem(VISITOR_ID_STORAGE_KEY));
113
+ if (existing) {
114
+ return existing;
115
+ }
116
+ const next = createVisitorId();
117
+ storage.setItem(VISITOR_ID_STORAGE_KEY, next);
118
+ return next;
119
+ } catch {
120
+ return createVisitorId();
121
+ }
122
+ };
123
+
89
124
  export const buildPublicChatFetch = ({
90
125
  workflowId,
91
126
  onHttpError,
@@ -121,6 +156,10 @@ export const buildPublicChatFetch = ({
121
156
  requestHeaders.set(key, value);
122
157
  }
123
158
 
159
+ // Scope chat history to this browser for anonymous visitors. Authenticated
160
+ // requests are scoped server-side by OAuth subject, so this is ignored there.
161
+ requestHeaders.set(VISITOR_ID_HEADER, getOrCreateVisitorId());
162
+
124
163
  const contentType = requestHeaders.get("Content-Type") ?? "";
125
164
  const expectsJson = contentType.includes("application/json");
126
165
  const stringBody = typeof nextInit.body === "string";
@@ -193,3 +232,30 @@ export const buildPublicChatFetch = ({
193
232
  return response;
194
233
  };
195
234
  };
235
+
236
+ interface AuthenticatedChatFetchOptions extends PublicChatFetchOptions {
237
+ /**
238
+ * Resolver returning a cached ChatKit session token (JWT).
239
+ */
240
+ getToken: () => Promise<string>;
241
+ }
242
+
243
+ /**
244
+ * Wrap {@link buildPublicChatFetch} so each ChatKit request carries a session
245
+ * token in the `Authorization` header. Used for `require_login` workflows so the
246
+ * backend's JWT auth path can enforce workspace membership.
247
+ */
248
+ export const buildAuthenticatedChatFetch = ({
249
+ getToken,
250
+ ...publicOptions
251
+ }: AuthenticatedChatFetchOptions): typeof fetch => {
252
+ const base = buildPublicChatFetch(publicOptions);
253
+ return async (input: RequestInfo | URL, init: RequestInit = {}) => {
254
+ const token = await getToken();
255
+ const headers = new Headers(init.headers ?? {});
256
+ if (token) {
257
+ headers.set("Authorization", `Bearer ${token}`);
258
+ }
259
+ return base(input, { ...init, headers });
260
+ };
261
+ };
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import { Monitor, Moon, Sun } from "lucide-react";
4
- import { Link, useLocation, useParams } from "react-router-dom";
4
+ import { Link, useParams } from "react-router-dom";
5
5
  import { Alert, AlertDescription, AlertTitle } from "@/design-system/ui/alert";
6
6
  import { Button } from "@/design-system/ui/button";
7
7
  import { Skeleton } from "@/design-system/ui/skeleton";
@@ -27,6 +27,18 @@ type WorkflowState =
27
27
  | { status: "error"; message: string }
28
28
  | { status: "ready"; workflow: PublicWorkflowMetadata };
29
29
 
30
+ export const getPublicChatAccessErrorMessage = (
31
+ error: Pick<PublicChatHttpError, "status" | "code">,
32
+ ): string => {
33
+ if (error.code === "chatkit.auth.workspace_mismatch") {
34
+ return "Your account is signed in, but it is not a member of this workflow workspace. Switch workspaces or ask the owner to add you.";
35
+ }
36
+ if (error.status === 403) {
37
+ return "You do not have permission to use this workflow. Ask the owner to confirm your workspace access.";
38
+ }
39
+ return "You do not have access to this workflow yet. Ask the owner to confirm it is still published.";
40
+ };
41
+
30
42
  const sanitizeWorkflowNameForEmail = (value: string): string =>
31
43
  value
32
44
  .replace(/[\r\n]+/g, " ")
@@ -39,7 +51,6 @@ export default function PublicChatPage() {
39
51
  workspaceSlug?: string;
40
52
  teamSlug?: string;
41
53
  }>();
42
- const location = useLocation();
43
54
  const [workflowState, setWorkflowState] = useState<WorkflowState>({
44
55
  status: workflowId ? "loading" : "error",
45
56
  ...(workflowId ? {} : { message: "Workflow identifier missing from URL." }),
@@ -56,7 +67,6 @@ export default function PublicChatPage() {
56
67
  );
57
68
  const { theme, setTheme } = useThemePreferences({});
58
69
  const backendBaseUrl = useMemo(() => getBackendBaseUrl(), []);
59
- const redirectTo = `${location.pathname}${location.search}${location.hash}`;
60
70
 
61
71
  useEffect(() => {
62
72
  setChatError(null);
@@ -162,8 +172,7 @@ export default function PublicChatPage() {
162
172
  if (error.status === 401 || error.status === 403) {
163
173
  setChatError({
164
174
  ...error,
165
- message:
166
- "You do not have access to this workflow yet. Ask the owner to confirm it is still published.",
175
+ message: getPublicChatAccessErrorMessage(error),
167
176
  });
168
177
  setIsChatReady(false);
169
178
  return;
@@ -239,31 +248,6 @@ export default function PublicChatPage() {
239
248
  );
240
249
  }
241
250
 
242
- if (
243
- workflowState.status === "ready" &&
244
- workflowState.workflow.require_login &&
245
- !isAuthenticated()
246
- ) {
247
- return (
248
- <div className="rounded-3xl border border-slate-200/80 bg-white/70 p-6 text-slate-900 shadow-sm dark:border-slate-800/60 dark:bg-slate-950/40 dark:text-white">
249
- <p className="text-lg font-semibold">Login required</p>
250
- <p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
251
- The owner requires OAuth login before this chat can start.
252
- </p>
253
- <div className="mt-4 flex flex-wrap gap-3">
254
- <Button asChild>
255
- <Link to="/login" state={{ from: redirectTo }}>
256
- Sign in to continue
257
- </Link>
258
- </Button>
259
- <Button asChild variant="outline">
260
- <a href={contactHref}>Contact owner</a>
261
- </Button>
262
- </div>
263
- </div>
264
- );
265
- }
266
-
267
251
  return (
268
252
  <div className="space-y-4">
269
253
  {rateLimitError && (
@@ -314,6 +298,9 @@ export default function PublicChatPage() {
314
298
  key={workflowState.workflow.id}
315
299
  workflowId={workflowState.workflow.id}
316
300
  workflowName={workflowState.workflow.name}
301
+ requireLogin={workflowState.workflow.require_login}
302
+ useSessionToken={isAuthenticated()}
303
+ workspaceSlug={workspaceSlug}
317
304
  startScreenPrompts={
318
305
  workflowState.workflow.chatkit?.start_screen_prompts
319
306
  }
@@ -16,11 +16,9 @@ import {
16
16
  DialogDescription,
17
17
  DialogTitle,
18
18
  } from "@/design-system/ui/dialog";
19
- import { Building2, LogOut, Settings, User, Vault } from "lucide-react";
20
- import {
21
- clearAuthSession,
22
- getAuthenticatedUserProfile,
23
- } from "@features/auth/lib/auth-session";
19
+ import { Building2, Github, LogOut, Settings, User, Vault } from "lucide-react";
20
+ import { getAuthenticatedUserProfile } from "@features/auth/lib/auth-session";
21
+ import { logoutSession } from "@features/auth/lib/auth-api";
24
22
  import { getSelectedWorkspaceSlug } from "@/lib/workspace-session";
25
23
  import CredentialsVault from "@features/workflow/components/dialogs/credentials-vault";
26
24
  import { usePageContext } from "@/hooks/use-page-context";
@@ -42,6 +40,9 @@ interface AccountMenuProps {
42
40
  onRevealCredentialSecret?: (id: string) => Promise<string | null>;
43
41
  }
44
42
 
43
+ const ORCHEO_ISSUE_CHOOSER_URL =
44
+ "https://github.com/AI-Colleagues/orcheo/issues/new/choose";
45
+
45
46
  export default function AccountMenu({
46
47
  credentials,
47
48
  isCredentialsLoading,
@@ -135,14 +136,26 @@ export default function AccountMenu({
135
136
  <span>Credential Vault</span>
136
137
  </button>
137
138
  </DropdownMenuItem>
139
+ <DropdownMenuItem asChild>
140
+ <a
141
+ href={ORCHEO_ISSUE_CHOOSER_URL}
142
+ target="_blank"
143
+ rel="noreferrer"
144
+ className="flex w-full items-center gap-0"
145
+ >
146
+ <Github className="mr-2 h-4 w-4" />
147
+ <span>Feedback & issues</span>
148
+ </a>
149
+ </DropdownMenuItem>
138
150
  <DropdownMenuSeparator />
139
151
  <DropdownMenuItem asChild>
140
152
  <button
141
153
  type="button"
142
154
  className="flex w-full items-center gap-0"
143
155
  onClick={() => {
144
- clearAuthSession();
145
- navigate("/login", { replace: true });
156
+ void logoutSession().finally(() => {
157
+ navigate("/login", { replace: true });
158
+ });
146
159
  }}
147
160
  >
148
161
  <LogOut className="mr-2 h-4 w-4" />
@@ -48,9 +48,20 @@ export default function StudioBrand() {
48
48
  </Badge>
49
49
  </button>
50
50
  </TooltipTrigger>
51
- <TooltipContent side="bottom" className="max-w-xs">
52
- This environment is experimental. Data created here may be reset,
53
- deleted, or not migrated to the launched version.
51
+ <TooltipContent side="bottom" className="max-w-sm">
52
+ Beta Disclaimer & Terms: Orcheo Cloud is a free beta for evaluation
53
+ and testing, provided as-is. Data, workflows, and credentials may be
54
+ reset, deleted, or not migrated, so do not use it as your only
55
+ storage for critical production data; read the{" "}
56
+ <a
57
+ href="https://ai-colleagues.com/terms"
58
+ target="_blank"
59
+ rel="noreferrer"
60
+ className="font-medium underline underline-offset-2"
61
+ >
62
+ full terms
63
+ </a>
64
+ .
54
65
  </TooltipContent>
55
66
  </Tooltip>
56
67
  </TooltipProvider>