diff --git a/packages/workflow-dashboard/src/api.ts b/packages/workflow-dashboard/src/api.ts index 1d1703a..9f7732d 100644 --- a/packages/workflow-dashboard/src/api.ts +++ b/packages/workflow-dashboard/src/api.ts @@ -1,5 +1,31 @@ const GATEWAY_URL = import.meta.env.VITE_GATEWAY_URL || ""; +export function getApiKey(): string | null { + try { + return localStorage.getItem("workflow-api-key"); + } catch { + return null; + } +} + +export function setApiKey(key: string): void { + localStorage.setItem("workflow-api-key", key); +} + +export function clearApiKey(): void { + localStorage.removeItem("workflow-api-key"); +} + +export function hasApiKey(): boolean { + return getApiKey() !== null && getApiKey() !== ""; +} + +function authHeaders(): Record { + const key = getApiKey(); + if (key) return { Authorization: `Bearer ${key}` }; + return {}; +} + function agentBase(agent: string): string { if (GATEWAY_URL) { return `${GATEWAY_URL}/api/${agent}`; @@ -11,7 +37,7 @@ function agentBase(agent: string): string { async function postJson(base: string, path: string, body: unknown): Promise { const res = await fetch(`${base}${path}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(body), }); if (!res.ok) { @@ -22,7 +48,7 @@ async function postJson(base: string, path: string, body: unknown): Promise(base: string, path: string): Promise { - const res = await fetch(`${base}${path}`); + const res = await fetch(`${base}${path}`, { headers: authHeaders() }); if (!res.ok) { throw new Error(`API ${res.status}: ${path}`); } diff --git a/packages/workflow-dashboard/src/app.tsx b/packages/workflow-dashboard/src/app.tsx index 77ad746..8ee3139 100644 --- a/packages/workflow-dashboard/src/app.tsx +++ b/packages/workflow-dashboard/src/app.tsx @@ -1,4 +1,6 @@ import { useState } from "react"; +import { hasApiKey, clearApiKey } from "./api.ts"; +import { LoginPage } from "./components/login.tsx"; import { RunDialog } from "./components/run-dialog.tsx"; import { Sidebar } from "./components/sidebar.tsx"; import { StatusBar } from "./components/status-bar.tsx"; @@ -8,12 +10,17 @@ import { WorkflowList } from "./components/workflow-list.tsx"; import { useHashRoute } from "./use-hash-route.ts"; export function App() { + const [authed, setAuthed] = useState(hasApiKey()); const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute(); const [showRun, setShowRun] = useState(false); + if (!authed) { + return setAuthed(true)} />; + } + return (
- + { clearApiKey(); setAuthed(false); }} />
setShowRun(true)} />
diff --git a/packages/workflow-dashboard/src/components/login.tsx b/packages/workflow-dashboard/src/components/login.tsx new file mode 100644 index 0000000..fb86af7 --- /dev/null +++ b/packages/workflow-dashboard/src/components/login.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { setApiKey } from "../api.ts"; + +type Props = { + onLogin: () => void; +}; + +export function LoginPage({ onLogin }: Props) { + const [key, setKey] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!key.trim()) return; + + setLoading(true); + setError(null); + + // Test the key by hitting the endpoints list + const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ""; + try { + const res = await fetch(`${gatewayUrl}/endpoints`, { + headers: { Authorization: `Bearer ${key.trim()}` }, + }); + if (res.status === 401) { + setError("Invalid API key"); + setLoading(false); + return; + } + if (!res.ok) { + setError(`Server error: ${res.status}`); + setLoading(false); + return; + } + } catch (err) { + setError(`Connection failed: ${err instanceof Error ? err.message : String(err)}`); + setLoading(false); + return; + } + + setApiKey(key.trim()); + onLogin(); + } + + return ( +
+
+

+ ⚙ Workflow Dashboard +

+

+ Enter your API key to continue +

+
+ setKey(e.target.value)} + placeholder="API Key" + className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none" + style={{ + background: "var(--color-bg)", + borderColor: "var(--color-border)", + color: "var(--color-text)", + }} + autoFocus + /> + {error && ( +

+ {error} +

+ )} + +
+
+
+ ); +} diff --git a/packages/workflow-dashboard/src/components/sidebar.tsx b/packages/workflow-dashboard/src/components/sidebar.tsx index d0cf291..378e663 100644 --- a/packages/workflow-dashboard/src/components/sidebar.tsx +++ b/packages/workflow-dashboard/src/components/sidebar.tsx @@ -8,9 +8,10 @@ type Props = { agent: string | null; onViewChange: (v: "threads" | "workflows") => void; onAgentChange: (a: string | null) => void; + onLogout: () => void; }; -export function Sidebar({ view, agent, onViewChange, onAgentChange }: Props) { +export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }: Props) { const { status, data } = useFetch(() => listAgents(), []); const [expanded, setExpanded] = useState(true); @@ -97,6 +98,17 @@ export function Sidebar({ view, agent, onViewChange, onAgentChange }: Props) { ))} + +
+ +
); } diff --git a/packages/workflow-dashboard/src/use-sse.ts b/packages/workflow-dashboard/src/use-sse.ts index bf6900d..d65ae83 100644 --- a/packages/workflow-dashboard/src/use-sse.ts +++ b/packages/workflow-dashboard/src/use-sse.ts @@ -8,6 +8,7 @@ import { } from "react"; import type { ThreadRecord } from "./api.ts"; +import { getApiKey } from "./api.ts"; export type UseSSEReturn = { records: ThreadRecord[]; @@ -58,10 +59,11 @@ function handleRecordEvent(ev: Event, ctx: RecordEventContext): void { function sseUrl(agent: string, threadId: string): string { const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ""; + const key = getApiKey(); + const keyParam = key ? `?key=${encodeURIComponent(key)}` : ""; if (gatewayUrl) { - return `${gatewayUrl}/api/${agent}/threads/${encodeURIComponent(threadId)}/live`; + return `${gatewayUrl}/api/${agent}/threads/${encodeURIComponent(threadId)}/live${keyParam}`; } - // Local dev: use vite proxy return `/api/threads/${encodeURIComponent(threadId)}/live`; } diff --git a/packages/workflow-gateway/src/index.ts b/packages/workflow-gateway/src/index.ts index e1942bc..c536f09 100644 --- a/packages/workflow-gateway/src/index.ts +++ b/packages/workflow-gateway/src/index.ts @@ -5,6 +5,7 @@ type Env = { Bindings: { ENDPOINTS: KVNamespace; GATEWAY_SECRET: string; + DASHBOARD_API_KEY: string; }; }; @@ -21,6 +22,23 @@ const app = new Hono(); app.use("*", cors()); +// ── Dashboard API key auth (skip healthz + register) ───────────── +app.use("/endpoints", async (c, next) => { + if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401); + await next(); +}); +app.use("/api/*", async (c, next) => { + if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401); + await next(); +}); + +function checkDashboardAuth(c: { req: { header: (n: string) => string | undefined; query: (n: string) => string | undefined }; env: Env["Bindings"] }): boolean { + const bearer = c.req.header("Authorization")?.replace("Bearer ", ""); + const query = c.req.query("key"); + const key = bearer ?? query; + return key === c.env.DASHBOARD_API_KEY; +} + // ── Health ────────────────────────────────────────────────────────── app.get("/healthz", (c) => c.json({ ok: true }));