From 7042fd310b7b01d935619fb3ea6bb28c7f0b751f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 02:17:58 +0000 Subject: [PATCH 1/4] feat(dashboard): zero-friction onboarding page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /onboarding route shown immediately after native registration. The page renders inside the standard authenticated app shell (sidebar retained) but uses a slim header that only carries a 'Skip to Dashboard' affordance. Three interactive steps: 1. Live API key — calls the existing useCreateApiKey hook on mount so the user sees a real key (not a placeholder) and can copy it. 2. Workload runner — async vs batch toggle, browser vs CLI execution toggle, Python vs cURL language toggle. Browser mode pre-renders the payload and a Run Now button that uploads + creates a real batch via useCreateBatch / useUploadFileWithProgress while still cycling through idle → running → success on a 2.5s timer for predictable UX. CLI mode shows a 'Listening for request…' indicator that flips to success on click. Both flows redirect to /models 2s after success. 3. Team invite — POSTs the supplied email to the configured Zapier hook (no-cors, fire-and-forget) and surfaces a sent confirmation. Also fires a background 'Hello World' batch + toast on mount so users see data on the dashboard when they land. The post-signup default redirect from RegisterForm is now /onboarding; explicit ?redirect= and the server-driven onboarding_redirect_url still take priority. Co-authored-by: aschkanAH --- dashboard/src/App.tsx | 17 + .../src/components/auth/RegisterForm.tsx | 9 +- dashboard/src/components/features/index.ts | 1 + .../onboarding/Onboarding/Onboarding.tsx | 804 ++++++++++++++++++ .../features/onboarding/Onboarding/index.ts | 1 + .../components/features/onboarding/index.ts | 1 + 6 files changed, 830 insertions(+), 3 deletions(-) create mode 100644 dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx create mode 100644 dashboard/src/components/features/onboarding/Onboarding/index.ts create mode 100644 dashboard/src/components/features/onboarding/index.ts diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 227c87816..d860bc966 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -183,6 +183,11 @@ const Connections = lazyWithRetry(() => default: m.Connections, })), ); +const Onboarding = lazyWithRetry(() => + import("./components/features/onboarding").then((m) => ({ + default: m.Onboarding, + })), +); // Loading component for lazy-loaded routes function RouteLoader() { @@ -653,6 +658,18 @@ function AppRoutes() { } /> + {/* Onboarding renders its own slim header inside the sidebar shell, + so it deliberately does NOT wrap with AppLayout. */} + + }> + + + + } + /> ); diff --git a/dashboard/src/components/auth/RegisterForm.tsx b/dashboard/src/components/auth/RegisterForm.tsx index 6bec1842f..abf7b1812 100644 --- a/dashboard/src/components/auth/RegisterForm.tsx +++ b/dashboard/src/components/auth/RegisterForm.tsx @@ -43,10 +43,13 @@ export function RegisterForm() { password, displayName: undefined, // Let user set this later }); + // Post-signup we want users to land on the zero-friction onboarding + // page where they get a live API key, run their first workload, and + // (optionally) invite teammates. An explicit ?redirect= takes priority + // (e.g. org invite acceptance flows), as does the server-driven + // onboarding_redirect_url which AuthProvider applies on initial load. const redirect = searchParams.get("redirect"); - if (redirect) { - navigate(redirect); - } + navigate(redirect || "/onboarding"); toast.success("Registration successful!"); } catch (error) { toast.error( diff --git a/dashboard/src/components/features/index.ts b/dashboard/src/components/features/index.ts index ac50e2f93..e597403e8 100644 --- a/dashboard/src/components/features/index.ts +++ b/dashboard/src/components/features/index.ts @@ -2,6 +2,7 @@ export * from "./api-keys"; export * from "./cost-management"; export * from "./endpoints"; export * from "./models"; +export * from "./onboarding"; export * from "./playground"; export * from "./profile"; export * from "./requests"; diff --git a/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx b/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx new file mode 100644 index 000000000..6ac9100fe --- /dev/null +++ b/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx @@ -0,0 +1,804 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + ArrowRight, + Check, + CheckCircle2, + Code2, + Copy, + FileJson, + KeyRound, + Loader2, + Play, + Sparkles, + Users, +} from "lucide-react"; +import { toast } from "sonner"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/auth"; +import { + useCreateApiKey, + useCreateBatch, + useModels, + useUploadFileWithProgress, + useUser, +} from "@/api/control-layer/hooks"; +import { copyToClipboard as copyToClipboardUtil } from "@/utils/clipboard"; +import { AppSidebar } from "../../../layout/Sidebar/AppSidebar"; + +// Webhook configured by Growth team for capturing teammate invites. Posting +// here is best-effort (no-cors) so we can't read the response. +const INVITE_WEBHOOK_URL = + "https://hooks.zapier.com/hooks/catch/27180094/uvhcpf2/"; + +// Default catalog model used in the visible code samples. We swap this with the +// first available chat model alias from the user's catalog when one is found, +// but keep medgemma-4b as a fallback so the snippet always renders something +// concrete even before /models loads. +const FALLBACK_MODEL_ALIAS = "medgemma-4b"; + +const SUCCESS_REDIRECT_DELAY_MS = 2000; +const RUN_NOW_SIMULATED_DELAY_MS = 2500; +const TOAST_DURATION_MS = 6000; + +type WorkloadType = "async" | "batch"; +type ExecutionMode = "browser" | "cli"; +type Language = "python" | "curl"; +type RunState = "idle" | "running" | "success"; + +function buildAsyncPayload(modelAlias: string): string { + return `{ + "model": "${modelAlias}", + "messages": [ + {"role": "system", "content": "Output only valid JSON."}, + {"role": "user", "content": "Generate a synthetic patient profile (Age, Gender, Symptoms, Diagnosis)."} + ], + "tier": "async" +}`; +} + +function buildJsonlPayload(modelAlias: string): string { + const row = (id: string) => + `{"custom_id": "${id}", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "${modelAlias}", "messages": [{"role": "system", "content": "Output only valid JSON."}, {"role": "user", "content": "Generate a synthetic patient profile (Age, Gender, Symptoms, Diagnosis)."}]}}`; + return [row("row-1"), row("row-2"), row("row-3")].join("\n"); +} + +function buildSnippets(apiKey: string, modelAlias: string) { + return { + batch: { + python: `from openai import OpenAI + +client = OpenAI( + api_key="${apiKey}", + base_url="https://api.doubleword.ai/v1" +) + +# 1. Upload your batch input file +batch_input_file = client.files.create( + file=open("patients.jsonl", "rb"), + purpose="batch" +) + +# 2. Start the batch job (~50% savings, 24h window) +batch = client.batches.create( + input_file_id=batch_input_file.id, + endpoint="/v1/chat/completions", + completion_window="24h" +) + +print(f"Batch started: {batch.id}")`, + curl: `curl -X POST https://api.doubleword.ai/v1/batches \\ + -H "Authorization: Bearer ${apiKey}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "${modelAlias}", + "priority": "standard", + "requests": [ + { + "custom_id": "row-1", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": "${modelAlias}", + "messages": [{"role": "user", "content": "Generate a synthetic patient profile."}] + } + } + ] + }'`, + }, + async: { + python: `from openai import OpenAI + +client = OpenAI( + api_key="${apiKey}", + base_url="https://api.doubleword.ai/v1" +) + +# Start an async job (~25% savings, minutes completion) +response = client.chat.completions.create( + model="${modelAlias}", + messages=[ + {"role": "user", "content": "Generate a synthetic patient profile."} + ], + extra_headers={"x-doubleword-tier": "async"} +) + +print(f"Async job queued!")`, + curl: `curl -X POST https://api.doubleword.ai/v1/chat/completions \\ + -H "Authorization: Bearer ${apiKey}" \\ + -H "Content-Type: application/json" \\ + -H "x-doubleword-tier: async" \\ + -d '{ + "model": "${modelAlias}", + "messages": [{"role": "user", "content": "Generate a synthetic patient profile."}] + }'`, + }, + } as const; +} + +export function Onboarding() { + const navigate = useNavigate(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { data: currentUser } = useUser("current"); + + const [apiKey, setApiKey] = useState(null); + const [apiKeyError, setApiKeyError] = useState(null); + const [copiedKey, setCopiedKey] = useState(false); + const [copiedCode, setCopiedCode] = useState(false); + + const [workloadType, setWorkloadType] = useState("async"); + const [executionMode, setExecutionMode] = useState("browser"); + const [language, setLanguage] = useState("python"); + const [runState, setRunState] = useState("idle"); + const [listenerState, setListenerState] = useState<"waiting" | "success">( + "waiting", + ); + + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteSubmitting, setInviteSubmitting] = useState(false); + const [inviteSent, setInviteSent] = useState(false); + + const apiKeyRequestedRef = useRef(false); + const sampleBatchRequestedRef = useRef(false); + const redirectScheduledRef = useRef(false); + + const createApiKey = useCreateApiKey(); + const createBatch = useCreateBatch(); + const uploadFile = useUploadFileWithProgress(); + + // Pull the first chat model alias from the catalog so the rendered code + // samples reference something that will actually work for the user. Falls + // back to a hard-coded alias when the catalog query is still loading or + // empty. + const { data: modelsData } = useModels({ accessible: true, limit: 50 }); + const modelAlias = useMemo(() => { + const chat = modelsData?.data?.find( + (m) => (m.model_type ?? "CHAT") === "CHAT", + ); + return chat?.alias ?? FALLBACK_MODEL_ALIAS; + }, [modelsData]); + + // Mint a live API key on mount so step 1 has something concrete to show. + // We only do this once per visit and only when the user is authenticated. + useEffect(() => { + if (apiKeyRequestedRef.current) return; + if (authLoading || !isAuthenticated || !currentUser) return; + apiKeyRequestedRef.current = true; + + createApiKey + .mutateAsync({ + data: { + name: `Onboarding key (${new Date().toLocaleString()})`, + description: "Auto-generated during onboarding", + purpose: "realtime", + }, + userId: currentUser.id, + }) + .then((response) => { + setApiKey(response.key); + }) + .catch((err) => { + // Surface the failure but don't block the rest of the flow — the user + // can still see the snippets and copy a placeholder, and they can + // generate keys from /api-keys later. + console.error("Failed to create onboarding API key:", err); + setApiKeyError( + err instanceof Error ? err.message : "Failed to create API key", + ); + }); + }, [authLoading, isAuthenticated, currentUser, createApiKey]); + + // Fire the "Hello World" sample batch in the background on mount. This is + // best-effort: if the catalog has no chat model or the upload fails, we + // swallow the error and just hide the toast. The toast is shown + // optimistically so the user sees activity even if the model catalog is + // slow to load. + useEffect(() => { + if (sampleBatchRequestedRef.current) return; + if (authLoading || !isAuthenticated) return; + sampleBatchRequestedRef.current = true; + + toast("Sample Batch Started", { + description: + "We just fired off a 'Hello World' batch in the background so you have some data to look at when you visit the dashboard.", + duration: TOAST_DURATION_MS, + icon: , + }); + + void (async () => { + try { + // Wait one tick for the models query to resolve. If it hasn't, the + // fallback alias is fine — the batch creation will just fail silently + // server-side which is acceptable for this background "demo" job. + const helloPayload = `{"custom_id": "hello-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "${modelAlias}", "messages": [{"role": "user", "content": "Say hello."}]}}\n`; + const blob = new Blob([helloPayload], { type: "application/jsonl" }); + const file = new File([blob], `onboarding-hello-${Date.now()}.jsonl`, { + type: "application/jsonl", + }); + + const uploaded = await uploadFile.mutateAsync({ + data: { file, purpose: "batch" }, + }); + + await createBatch.mutateAsync({ + input_file_id: uploaded.id, + endpoint: "/v1/chat/completions", + completion_window: "24h", + }); + } catch (err) { + // Background task — keep the surface area quiet; only log. + console.warn("Background Hello World batch failed:", err); + } + })(); + // We intentionally only run this once after auth is resolved; modelAlias + // is read inside the IIFE so we don't need it as a dep. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, isAuthenticated]); + + const goToDashboard = useCallback(() => { + navigate("/models"); + }, [navigate]); + + // Auto-redirect after success in both browser and CLI modes. + useEffect(() => { + const succeeded = + runState === "success" || listenerState === "success"; + if (!succeeded || redirectScheduledRef.current) return; + redirectScheduledRef.current = true; + const timer = setTimeout(goToDashboard, SUCCESS_REDIRECT_DELAY_MS); + return () => clearTimeout(timer); + }, [runState, listenerState, goToDashboard]); + + const snippets = useMemo( + () => buildSnippets(apiKey ?? "", modelAlias), + [apiKey, modelAlias], + ); + + const browserPayload = + workloadType === "batch" + ? buildJsonlPayload(modelAlias) + : buildAsyncPayload(modelAlias); + const cliSnippet = snippets[workloadType][language]; + + const handleCopyKey = async () => { + if (!apiKey) return; + const ok = await copyToClipboardUtil(apiKey); + if (ok) { + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + } + }; + + const handleCopyCode = async () => { + const text = executionMode === "browser" ? browserPayload : cliSnippet; + const ok = await copyToClipboardUtil(text); + if (ok) { + setCopiedCode(true); + setTimeout(() => setCopiedCode(false), 2000); + } + }; + + const handleRunNow = async () => { + if (runState !== "idle") return; + setRunState("running"); + + // Fire the real batch creation in the background. We don't surface its + // success/failure to the run state machine since the spec asks for a + // simulated 2.5s "running" → "success" cycle that gives the user a + // predictable redirect experience, regardless of how fast the API + // responds. + void (async () => { + try { + const payload = + workloadType === "batch" + ? buildJsonlPayload(modelAlias) + : `${JSON.stringify({ + custom_id: "row-1", + method: "POST", + url: "/v1/chat/completions", + body: JSON.parse(buildAsyncPayload(modelAlias)), + })}\n`; + const blob = new Blob([payload], { type: "application/jsonl" }); + const file = new File( + [blob], + `onboarding-${workloadType}-${Date.now()}.jsonl`, + { type: "application/jsonl" }, + ); + const uploaded = await uploadFile.mutateAsync({ + data: { file, purpose: "batch" }, + }); + await createBatch.mutateAsync({ + input_file_id: uploaded.id, + endpoint: "/v1/chat/completions", + completion_window: workloadType === "batch" ? "24h" : "1h", + }); + } catch (err) { + console.warn("Onboarding run-now batch failed:", err); + } + })(); + + setTimeout(() => setRunState("success"), RUN_NOW_SIMULATED_DELAY_MS); + }; + + const handleSendInvite = async (e: React.FormEvent) => { + e.preventDefault(); + if (!inviteEmail.trim() || inviteSubmitting) return; + setInviteSubmitting(true); + try { + // Zapier hooks accept a basic POST. We use no-cors because the hook + // host doesn't return CORS headers, which means we can't read the + // response — that's fine, we trust the network call. + await fetch(INVITE_WEBHOOK_URL, { + method: "POST", + mode: "no-cors", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: inviteEmail.trim(), + inviter_email: currentUser?.email, + inviter_id: currentUser?.id, + source: "onboarding", + }), + }); + setInviteSent(true); + setInviteEmail(""); + toast.success("Invite sent"); + setTimeout(() => setInviteSent(false), 3000); + } catch (err) { + console.error("Failed to send invite:", err); + toast.error("Could not send invite — please try again."); + } finally { + setInviteSubmitting(false); + } + }; + + const apiKeyDisplay = apiKey ?? (apiKeyError ? "—" : "Generating live key…"); + + return ( + +
+ + + {/* Slim header — onboarding intentionally only shows the skip + affordance, not the full app chrome. */} +
+
+ +
+ +
+ +
+
+
+

+ You're ready to run at scale. +

+

+ Your workspace is provisioned. Send your first API request + below to authenticate your setup. +

+
+ + {/* Step 1 — API Key */} +
+
+
+ +
+
+

+ 1. Your Live API Key +

+

+ This key will only be shown once. Please store it + securely. +

+
+
+ +
+
+ {apiKeyDisplay} +
+ +
+ {apiKeyError && ( +
+ {apiKeyError}. You can create a key from the API Keys page. +
+ )} +
+ + {/* Step 2 — Workload runner */} +
+
+
+
+
+ {executionMode === "browser" ? ( + + ) : ( + + )} +
+
+

+ 2. Run your first workload +

+

+ {executionMode === "browser" + ? "We've prepped an example payload. Run it directly from your browser." + : "Run this snippet from your terminal. Your key is already injected."} +

+
+
+ +
+ + +
+
+ +
+ + +
+
+ + {executionMode === "browser" ? ( + <> +
+
+                        
+                          {browserPayload}
+                        
+                      
+ +
+ +
+
+ Estimated cost for 10,000 records:{" "} + + {workloadType === "batch" ? "$1.25" : "$1.87"} + + + ( + {workloadType === "batch" + ? "50% less than real-time inference" + : "25% less than real-time inference"} + ) + +
+ +
+ +
+
+ + + Workload successfully received! Redirecting to + dashboard… + +
+
+ + ) : ( + <> +
+ + snippet.{language === "python" ? "py" : "sh"} + +
+ + +
+
+
+
+                        
+                          {cliSnippet}
+                        
+                      
+ +
+ + + + )} +
+ + {/* Step 3 — Team invite */} +
+
+
+
+
+
+
+ ); +} + +export default Onboarding; diff --git a/dashboard/src/components/features/onboarding/Onboarding/index.ts b/dashboard/src/components/features/onboarding/Onboarding/index.ts new file mode 100644 index 000000000..a2a9d4090 --- /dev/null +++ b/dashboard/src/components/features/onboarding/Onboarding/index.ts @@ -0,0 +1 @@ +export { Onboarding, default } from "./Onboarding"; diff --git a/dashboard/src/components/features/onboarding/index.ts b/dashboard/src/components/features/onboarding/index.ts new file mode 100644 index 000000000..0b4fb367d --- /dev/null +++ b/dashboard/src/components/features/onboarding/index.ts @@ -0,0 +1 @@ +export * from "./Onboarding"; From 63e288e2834ed7d1acd53ce3af7751777ccbaedb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:36:05 +0000 Subject: [PATCH 2/4] fix(onboarding): use form-encoded body for no-cors Zapier invite mode: 'no-cors' silently downgrades Content-Type: application/json to text/plain because JSON isn't CORS-safelisted. Zapier Catch Hooks only auto-parse JSON when the request actually carries an application/json content-type, so the previous code was firing the webhook successfully but with all fields empty. Switching the body to URLSearchParams produces an application/x-www-form-urlencoded body (which IS CORS-safelisted) and which Zapier does parse into structured fields. Co-authored-by: aschkanAH --- .../onboarding/Onboarding/Onboarding.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx b/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx index 6ac9100fe..fc3c29b77 100644 --- a/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx +++ b/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx @@ -350,19 +350,27 @@ export function Onboarding() { if (!inviteEmail.trim() || inviteSubmitting) return; setInviteSubmitting(true); try { - // Zapier hooks accept a basic POST. We use no-cors because the hook - // host doesn't return CORS headers, which means we can't read the - // response — that's fine, we trust the network call. + // The Zapier hook host doesn't return CORS headers so we have to + // POST in `no-cors` mode. That mode forbids non-safelisted + // Content-Types: setting `application/json` would be silently + // downgraded to `text/plain;charset=UTF-8`, which Zapier Catch Hooks + // do NOT auto-parse into fields — the Zap would still fire but + // `email`/`inviter_email`/etc. would all be empty. + // + // Using URLSearchParams produces an `application/x-www-form-urlencoded` + // body, which IS CORS-safelisted and IS parsed into structured fields + // by Zapier. We can't read the response either way (opaque), so the + // success toast is best-effort by design. + const params = new URLSearchParams(); + params.set("email", inviteEmail.trim()); + if (currentUser?.email) params.set("inviter_email", currentUser.email); + if (currentUser?.id) params.set("inviter_id", currentUser.id); + params.set("source", "onboarding"); + await fetch(INVITE_WEBHOOK_URL, { method: "POST", mode: "no-cors", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: inviteEmail.trim(), - inviter_email: currentUser?.email, - inviter_id: currentUser?.id, - source: "onboarding", - }), + body: params, }); setInviteSent(true); setInviteEmail(""); From f8ee3a73afa323fb0e8194e0fc4c17ab6fb68b87 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:54:09 +0000 Subject: [PATCH 3/4] refactor(onboarding): move invite webhook URL to env variable Replaces the hardcoded Zapier hook with VITE_INVITE_WEBHOOK_URL. The invite section is hidden entirely when the variable is unset so we don't collect emails with nowhere to send them; the submit handler also has a defensive guard for stale builds. Co-authored-by: aschkanAH --- .../onboarding/Onboarding/Onboarding.tsx | 23 +++++++++++++++---- dashboard/src/vite-env.d.ts | 5 ++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx b/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx index fc3c29b77..a62b742ff 100644 --- a/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx +++ b/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx @@ -31,10 +31,13 @@ import { import { copyToClipboard as copyToClipboardUtil } from "@/utils/clipboard"; import { AppSidebar } from "../../../layout/Sidebar/AppSidebar"; -// Webhook configured by Growth team for capturing teammate invites. Posting -// here is best-effort (no-cors) so we can't read the response. -const INVITE_WEBHOOK_URL = - "https://hooks.zapier.com/hooks/catch/27180094/uvhcpf2/"; +// Webhook used by the "invite a teammate" form. Configured per-environment +// via VITE_INVITE_WEBHOOK_URL (typically a Zapier Catch Hook). Posting here +// is best-effort under `no-cors`, so the host does not need CORS headers +// configured and we can't read the response. If the env var is unset the +// invite form is hidden — we'd rather not silently drop submissions. +const INVITE_WEBHOOK_URL: string | undefined = + import.meta.env.VITE_INVITE_WEBHOOK_URL; // Default catalog model used in the visible code samples. We swap this with the // first available chat model alias from the user's catalog when one is found, @@ -348,6 +351,12 @@ export function Onboarding() { const handleSendInvite = async (e: React.FormEvent) => { e.preventDefault(); if (!inviteEmail.trim() || inviteSubmitting) return; + if (!INVITE_WEBHOOK_URL) { + // Defensive — the form is hidden when the env var is unset, but + // a stale build could still hit this path. + toast.error("Invites are not configured for this environment."); + return; + } setInviteSubmitting(true); try { // The Zapier hook host doesn't return CORS headers so we have to @@ -756,7 +765,10 @@ export function Onboarding() { )} - {/* Step 3 — Team invite */} + {/* Step 3 — Team invite. Rendered only when the webhook is + configured; otherwise we'd be collecting invite emails + with nowhere to send them. */} + {INVITE_WEBHOOK_URL && (
+ )} diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts index 3cdaa28e9..14e85ab60 100644 --- a/dashboard/src/vite-env.d.ts +++ b/dashboard/src/vite-env.d.ts @@ -2,6 +2,11 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string; + readonly VITE_BUILD_SHA?: string; + // URL of the Zapier (or other) webhook used by the onboarding "invite a + // teammate" form. Fire-and-forget POST under `no-cors`, so the host does + // not need CORS headers configured. Leave unset to disable the form. + readonly VITE_INVITE_WEBHOOK_URL?: string; } interface ImportMeta { From e36679396889529b720a904dd2fb6ee98abf5bb1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 05:32:07 +0000 Subject: [PATCH 4/4] fix(onboarding): JSON-escape model alias and avoid post-register navigate race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review: 1. buildAsyncPayload, buildJsonlPayload, and the snippet generators were interpolating the model alias into JSON / Python / cURL string literals via raw template literals. Catalog aliases come from user- controlled DB metadata and may legitimately contain quotes, backslashes, or control characters, which would yield invalid JSON. handleRunNow's JSON.parse(buildAsyncPayload(...)) would throw on such aliases, yet the simulated 2.5s timer would still flip the UI to 'success' and redirect — falsely telling the user a workload was queued. Payloads are now built as objects and serialized via JSON.stringify (JSONL/JSON cases), and code-snippet interpolations go through escapeForLiteral which uses JSON.stringify's inner string form to correctly escape both Python and shell-string literal special chars. 2. The CLI listener block exposed '(Click to simulate success)' to desktop users. 'Simulate' is internal language that breaks the illusion of the onboarding flow. Replaced with '(Click to continue)' and updated the aria-label to match. 3. RegisterForm called navigate('/onboarding') unconditionally after register(). When the server returns onboarding_redirect_url, AuthProvider.checkAuthStatus has already initiated a hard navigation via window.location.href — the new client-side push raced that and could briefly mount the wrong route before the hard nav resolved. We now read the just-populated current-user cache entry and skip navigate() when an onboarding_redirect_url is present, deferring cleanly to the server-driven redirect (matching the pre-onboarding behaviour for that path). Co-authored-by: aschkanAH --- .../src/components/auth/RegisterForm.tsx | 30 +++++-- .../onboarding/Onboarding/Onboarding.tsx | 83 ++++++++++++++----- 2 files changed, 85 insertions(+), 28 deletions(-) diff --git a/dashboard/src/components/auth/RegisterForm.tsx b/dashboard/src/components/auth/RegisterForm.tsx index abf7b1812..035b450f0 100644 --- a/dashboard/src/components/auth/RegisterForm.tsx +++ b/dashboard/src/components/auth/RegisterForm.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Link, useSearchParams, useNavigate } from "react-router-dom"; import { Eye, EyeOff } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; @@ -13,6 +14,8 @@ import { CardTitle, } from "../ui/card"; import { useAuth } from "../../contexts/auth"; +import { queryKeys } from "../../api/control-layer/keys"; +import type { UserResponse } from "../../api/control-layer/types"; import { toast } from "sonner"; export function RegisterForm() { @@ -24,6 +27,7 @@ export function RegisterForm() { const { register } = useAuth(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -43,13 +47,27 @@ export function RegisterForm() { password, displayName: undefined, // Let user set this later }); - // Post-signup we want users to land on the zero-friction onboarding - // page where they get a live API key, run their first workload, and - // (optionally) invite teammates. An explicit ?redirect= takes priority - // (e.g. org invite acceptance flows), as does the server-driven - // onboarding_redirect_url which AuthProvider applies on initial load. + // Post-signup landing logic, ordered by priority: + // + // 1. Explicit ?redirect= (e.g. org invite acceptance) — always wins. + // 2. Server-driven onboarding_redirect_url — AuthProvider triggers a + // hard navigation via window.location.href as part of register()'s + // checkAuthStatus() call. We must NOT call navigate() in that case + // or we race the pending hard navigation, briefly mounting the + // wrong route. We detect this by reading the just-populated user + // cache entry that AuthProvider writes before deciding to redirect. + // 3. Otherwise, default to /onboarding for the zero-friction flow. const redirect = searchParams.get("redirect"); - navigate(redirect || "/onboarding"); + if (redirect) { + navigate(redirect); + } else { + const currentUser = queryClient.getQueryData( + queryKeys.users.byId("current", "organizations"), + ); + if (!currentUser?.onboarding_redirect_url) { + navigate("/onboarding"); + } + } toast.success("Registration successful!"); } catch (error) { toast.error( diff --git a/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx b/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx index a62b742ff..2cf74f8e5 100644 --- a/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx +++ b/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx @@ -54,30 +54,64 @@ type ExecutionMode = "browser" | "cli"; type Language = "python" | "curl"; type RunState = "idle" | "running" | "success"; +// Builds the inner JSON body shared by both the single-row async payload +// and each row of the JSONL batch payload. Model aliases are user-controlled +// (catalog metadata) and may legitimately contain ", \, or control chars, +// so we never interpolate them into JSON via template literals. +function buildChatBody(modelAlias: string) { + return { + model: modelAlias, + messages: [ + { role: "system", content: "Output only valid JSON." }, + { + role: "user", + content: + "Generate a synthetic patient profile (Age, Gender, Symptoms, Diagnosis).", + }, + ], + }; +} + +function buildAsyncPayloadObject(modelAlias: string) { + return { ...buildChatBody(modelAlias), tier: "async" }; +} + function buildAsyncPayload(modelAlias: string): string { - return `{ - "model": "${modelAlias}", - "messages": [ - {"role": "system", "content": "Output only valid JSON."}, - {"role": "user", "content": "Generate a synthetic patient profile (Age, Gender, Symptoms, Diagnosis)."} - ], - "tier": "async" -}`; + return JSON.stringify(buildAsyncPayloadObject(modelAlias), null, 2); } function buildJsonlPayload(modelAlias: string): string { - const row = (id: string) => - `{"custom_id": "${id}", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "${modelAlias}", "messages": [{"role": "system", "content": "Output only valid JSON."}, {"role": "user", "content": "Generate a synthetic patient profile (Age, Gender, Symptoms, Diagnosis)."}]}}`; - return [row("row-1"), row("row-2"), row("row-3")].join("\n"); + return ["row-1", "row-2", "row-3"] + .map((id) => + JSON.stringify({ + custom_id: id, + method: "POST", + url: "/v1/chat/completions", + body: buildChatBody(modelAlias), + }), + ) + .join("\n"); +} + +// Returns the alias formatted as an inner-string literal (no surrounding +// quotes), correctly escaping any chars that would break Python/cURL string +// literals or the embedded JSON in the cURL example. JS string literal +// escapes are a strict subset of Python's, so reusing the JSON encoding is +// safe for all three target languages. +function escapeForLiteral(value: string): string { + const json = JSON.stringify(value); + return json.slice(1, json.length - 1); } function buildSnippets(apiKey: string, modelAlias: string) { + const safeKey = escapeForLiteral(apiKey); + const safeAlias = escapeForLiteral(modelAlias); return { batch: { python: `from openai import OpenAI client = OpenAI( - api_key="${apiKey}", + api_key="${safeKey}", base_url="https://api.doubleword.ai/v1" ) @@ -96,10 +130,10 @@ batch = client.batches.create( print(f"Batch started: {batch.id}")`, curl: `curl -X POST https://api.doubleword.ai/v1/batches \\ - -H "Authorization: Bearer ${apiKey}" \\ + -H "Authorization: Bearer ${safeKey}" \\ -H "Content-Type: application/json" \\ -d '{ - "model": "${modelAlias}", + "model": "${safeAlias}", "priority": "standard", "requests": [ { @@ -107,7 +141,7 @@ print(f"Batch started: {batch.id}")`, "method": "POST", "url": "/v1/chat/completions", "body": { - "model": "${modelAlias}", + "model": "${safeAlias}", "messages": [{"role": "user", "content": "Generate a synthetic patient profile."}] } } @@ -118,13 +152,13 @@ print(f"Batch started: {batch.id}")`, python: `from openai import OpenAI client = OpenAI( - api_key="${apiKey}", + api_key="${safeKey}", base_url="https://api.doubleword.ai/v1" ) # Start an async job (~25% savings, minutes completion) response = client.chat.completions.create( - model="${modelAlias}", + model="${safeAlias}", messages=[ {"role": "user", "content": "Generate a synthetic patient profile."} ], @@ -133,11 +167,11 @@ response = client.chat.completions.create( print(f"Async job queued!")`, curl: `curl -X POST https://api.doubleword.ai/v1/chat/completions \\ - -H "Authorization: Bearer ${apiKey}" \\ + -H "Authorization: Bearer ${safeKey}" \\ -H "Content-Type: application/json" \\ -H "x-doubleword-tier: async" \\ -d '{ - "model": "${modelAlias}", + "model": "${safeAlias}", "messages": [{"role": "user", "content": "Generate a synthetic patient profile."}] }'`, }, @@ -317,6 +351,11 @@ export function Onboarding() { // responds. void (async () => { try { + // Always build the JSONL via the object helpers so we never round- + // trip through JSON.parse — model aliases can contain quotes/ + // backslashes that would have made the previous template-string + // payload invalid JSON and thrown here, while the simulated timer + // still flipped the UI to "success". const payload = workloadType === "batch" ? buildJsonlPayload(modelAlias) @@ -324,7 +363,7 @@ export function Onboarding() { custom_id: "row-1", method: "POST", url: "/v1/chat/completions", - body: JSON.parse(buildAsyncPayload(modelAlias)), + body: buildAsyncPayloadObject(modelAlias), })}\n`; const blob = new Blob([payload], { type: "application/jsonl" }); const file = new File( @@ -732,7 +771,7 @@ export function Onboarding() { }`} aria-label={ listenerState === "waiting" - ? "Simulate request received" + ? "Mark request received" : "Request received" } > @@ -756,7 +795,7 @@ export function Onboarding() { {listenerState === "waiting" && ( - (Click to simulate success) + (Click to continue) )}