feat: dashboard API key authentication

- Gateway: DASHBOARD_API_KEY middleware on /endpoints and /api/* routes
- Dashboard: login page with key validation, stored in localStorage
- SSE: key passed as ?key= query param (EventSource can't set headers)
- Sidebar: logout button to clear key

Refs #169
小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-09 11:56:25 +00:00
parent c602d2284b
commit 3b41625001
6 changed files with 164 additions and 6 deletions
+28 -2
View File
@@ -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<string, string> {
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<T>(base: string, path: string, body: unknown): Promise<T> {
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<T>(base: string, path: string, body: unknown): Promise<T
}
async function fetchJson<T>(base: string, path: string): Promise<T> {
const res = await fetch(`${base}${path}`);
const res = await fetch(`${base}${path}`, { headers: authHeaders() });
if (!res.ok) {
throw new Error(`API ${res.status}: ${path}`);
}
+8 -1
View File
@@ -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 <LoginPage onLogin={() => setAuthed(true)} />;
}
return (
<div className="flex h-screen">
<Sidebar view={view} agent={agent} onViewChange={setView} onAgentChange={setAgent} />
<Sidebar view={view} agent={agent} onViewChange={setView} onAgentChange={setAgent} onLogout={() => { clearApiKey(); setAuthed(false); }} />
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar agent={agent} onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
@@ -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<string | null>(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 (
<div className="min-h-screen flex items-center justify-center" style={{ background: "var(--color-bg)" }}>
<div
className="p-8 rounded-lg border w-full max-w-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}>
Workflow Dashboard
</h1>
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
Enter your API key to continue
</p>
<form onSubmit={handleSubmit}>
<input
type="password"
value={key}
onChange={(e) => 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 && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !key.trim()}
className="w-full px-3 py-2 rounded text-sm font-medium"
style={{
background: "var(--color-accent)",
color: "var(--color-bg)",
opacity: loading || !key.trim() ? 0.5 : 1,
}}
>
{loading ? "Verifying..." : "Login"}
</button>
</form>
</div>
</div>
);
}
@@ -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) {
</button>
))}
</nav>
<div className="p-2 border-t" style={{ borderColor: "var(--color-border)" }}>
<button
type="button"
onClick={onLogout}
className="w-full text-left px-3 py-2 rounded text-xs transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
🚪 Logout
</button>
</div>
</aside>
);
}
+4 -2
View File
@@ -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`;
}
+18
View File
@@ -5,6 +5,7 @@ type Env = {
Bindings: {
ENDPOINTS: KVNamespace;
GATEWAY_SECRET: string;
DASHBOARD_API_KEY: string;
};
};
@@ -21,6 +22,23 @@ const app = new Hono<Env>();
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 }));