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
|
@@ -1,24 +1,177 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
2
|
-
import { useLocation } from "react-router-dom";
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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-
|
|
52
|
-
|
|
53
|
-
|
|
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>
|