diff --git a/.env.example b/.env.example
index baf3bdb..3ea17e1 100644
--- a/.env.example
+++ b/.env.example
@@ -9,3 +9,6 @@ TRIGGER_MENTION=@claude-swarm
# Use a GitHub-associated name/email so platforms recognise the commit author.
GIT_AUTHOR_NAME=Your Name
GIT_AUTHOR_EMAIL=your-email@example.com
+# Dashboard admin credentials
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=your-strong-password-here
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 31c9380..6724c5a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -13,6 +13,8 @@ import { useAgents } from './hooks/useAgents'
import { useIssues } from './hooks/useIssues'
import { usePRs } from './hooks/usePRs'
import { useWorkspaceContext } from './context/WorkspaceContext'
+import { useAuth } from './context/AuthContext'
+import { LoginPage } from './components/auth/LoginPage'
function ErrorBanner({ error }) {
if (!error) return null
@@ -24,16 +26,25 @@ function ErrorBanner({ error }) {
}
export function App() {
+ const { isAuthenticated, isChecking } = useAuth()
const [activeTab, setActiveTab] = useState('agents')
const [addWorkspaceOpen, setAddWorkspaceOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [plannerOpen, setPlannerOpen] = useState(false)
const { selectedWorkspaceId } = useWorkspaceContext()
+ const queryEnabled = isAuthenticated && !isChecking
+ const { error: metricsError } = useMetrics(selectedWorkspaceId, { enabled: queryEnabled })
+ const { data: agentsData } = useAgents(selectedWorkspaceId, { enabled: queryEnabled })
+ const { data: issuesData } = useIssues(selectedWorkspaceId, { enabled: queryEnabled })
+ const { data: prsData } = usePRs(selectedWorkspaceId, { enabled: queryEnabled })
- const { error: metricsError } = useMetrics(selectedWorkspaceId)
- const { data: agentsData } = useAgents(selectedWorkspaceId)
- const { data: issuesData } = useIssues(selectedWorkspaceId)
- const { data: prsData } = usePRs(selectedWorkspaceId)
+ if (isChecking) {
+ return
+ }
+
+ if (!isAuthenticated) {
+ return
+ }
const counts = {
agents: agentsData?.total ?? 0,
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 20d8c7c..10da1e6 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -1,11 +1,25 @@
+export const TOKEN_KEY = 'swarm_auth_token'
+
const BASE = ''
async function apiFetch(path, options = {}) {
const { headers: customHeaders, ...rest } = options
+ const token = localStorage.getItem(TOKEN_KEY)
+ const authHeaders = token ? { 'Authorization': `Bearer ${token}` } : {}
+
const res = await fetch(`${BASE}${path}`, {
- headers: { 'Content-Type': 'application/json', ...customHeaders },
+ headers: { 'Content-Type': 'application/json', ...authHeaders, ...customHeaders },
...rest,
})
+
+ if (res.status === 401) {
+ if (token) {
+ localStorage.removeItem(TOKEN_KEY)
+ window.dispatchEvent(new CustomEvent('swarm:unauthorized'))
+ }
+ throw new Error('Unauthorized')
+ }
+
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `HTTP ${res.status}`)
@@ -13,6 +27,16 @@ async function apiFetch(path, options = {}) {
return res.json()
}
+// Auth
+export const login = (username, password) =>
+ apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) })
+
+export const logout = () =>
+ apiFetch('/api/auth/logout', { method: 'POST' })
+
+export const checkAuth = () =>
+ apiFetch('/api/auth/check')
+
// Metrics
export const getMetrics = (wsId) =>
apiFetch(`/api/metrics${wsId ? `?workspace_id=${encodeURIComponent(wsId)}` : ''}`)
diff --git a/frontend/src/components/auth/LoginPage.jsx b/frontend/src/components/auth/LoginPage.jsx
new file mode 100644
index 0000000..8e29c43
--- /dev/null
+++ b/frontend/src/components/auth/LoginPage.jsx
@@ -0,0 +1,80 @@
+import { useState } from 'react'
+import { useAuth } from '../../context/AuthContext'
+
+export function LoginPage() {
+ const { login } = useAuth()
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ async function handleSubmit(e) {
+ e.preventDefault()
+ setError(null)
+ setLoading(true)
+ try {
+ await login(username, password)
+ } catch (err) {
+ setError(err.message === 'Unauthorized' ? 'Invalid username or password' : err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+
Sign in to access the dashboard
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx
index 83a6fd2..acf309f 100644
--- a/frontend/src/components/layout/Header.jsx
+++ b/frontend/src/components/layout/Header.jsx
@@ -1,8 +1,9 @@
-import { Settings, Plus, Check, AlertTriangle, RefreshCw } from 'lucide-react'
+import { Settings, Plus, Check, AlertTriangle, RefreshCw, LogOut } from 'lucide-react'
import { WorkspaceSwitcher } from './WorkspaceSwitcher'
import { useMetrics } from '../../hooks/useMetrics'
import { useGitSync } from '../../hooks/useGitSync'
import { useWorkspaceContext } from '../../context/WorkspaceContext'
+import { useAuth } from '../../context/AuthContext'
import { formatDistanceToNow } from 'date-fns'
function SyncIndicator({ wsId }) {
@@ -38,6 +39,7 @@ function SyncIndicator({ wsId }) {
export function Header({ onAddWorkspace, onOpenSettings, onOpenPlanner }) {
const { selectedWorkspaceId } = useWorkspaceContext()
+ const { logout } = useAuth()
const { dataUpdatedAt } = useMetrics(selectedWorkspaceId)
const lastUpdated = dataUpdatedAt
@@ -82,6 +84,13 @@ export function Header({ onAddWorkspace, onOpenSettings, onOpenPlanner }) {
)}
+
)
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx
new file mode 100644
index 0000000..d895d3a
--- /dev/null
+++ b/frontend/src/context/AuthContext.jsx
@@ -0,0 +1,52 @@
+import { createContext, useContext, useState, useEffect, useCallback } from 'react'
+import { TOKEN_KEY, login as apiLogin, logout as apiLogout, checkAuth } from '../api/client'
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }) {
+ const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY))
+ const [isChecking, setIsChecking] = useState(() => !!localStorage.getItem(TOKEN_KEY))
+
+ // On mount: verify existing token against server
+ useEffect(() => {
+ if (!token) {
+ setIsChecking(false)
+ return
+ }
+ checkAuth()
+ .then(() => setIsChecking(false))
+ .catch(() => setIsChecking(false)) // 401 fires swarm:unauthorized below
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Listen for 401 responses from apiFetch
+ useEffect(() => {
+ const handler = () => setToken(null)
+ window.addEventListener('swarm:unauthorized', handler)
+ return () => window.removeEventListener('swarm:unauthorized', handler)
+ }, [])
+
+ const login = useCallback(async (username, password) => {
+ const data = await apiLogin(username, password)
+ localStorage.setItem(TOKEN_KEY, data.token)
+ setToken(data.token)
+ return data
+ }, [])
+
+ const logout = useCallback(async () => {
+ try { await apiLogout() } catch {}
+ localStorage.removeItem(TOKEN_KEY)
+ setToken(null)
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext)
+ if (!ctx) throw new Error('useAuth must be used within AuthProvider')
+ return ctx
+}
diff --git a/frontend/src/hooks/useAgents.js b/frontend/src/hooks/useAgents.js
index e6a5d3e..fbf22fb 100644
--- a/frontend/src/hooks/useAgents.js
+++ b/frontend/src/hooks/useAgents.js
@@ -2,10 +2,11 @@ import { useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getAgents, getAgentLogs } from '../api/client'
-export function useAgents(wsId, { limit = 20, offset = 0 } = {}) {
+export function useAgents(wsId, { limit = 20, offset = 0, enabled = true } = {}) {
return useQuery({
queryKey: ['agents', wsId, limit, offset],
queryFn: () => getAgents(wsId, limit, offset),
+ enabled,
refetchInterval: 3000,
staleTime: 0,
})
diff --git a/frontend/src/hooks/useIssues.js b/frontend/src/hooks/useIssues.js
index fb66927..a127a7e 100644
--- a/frontend/src/hooks/useIssues.js
+++ b/frontend/src/hooks/useIssues.js
@@ -1,10 +1,11 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getIssues, updateIssueStatus } from '../api/client'
-export function useIssues(wsId) {
+export function useIssues(wsId, { enabled = true } = {}) {
return useQuery({
queryKey: ['issues', wsId],
queryFn: () => getIssues(wsId),
+ enabled,
refetchInterval: 5000,
staleTime: 0,
})
diff --git a/frontend/src/hooks/useMetrics.js b/frontend/src/hooks/useMetrics.js
index 598ac9b..ac48fcf 100644
--- a/frontend/src/hooks/useMetrics.js
+++ b/frontend/src/hooks/useMetrics.js
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getMetrics } from '../api/client'
-export function useMetrics(wsId) {
+export function useMetrics(wsId, { enabled = true } = {}) {
return useQuery({
queryKey: ['metrics', wsId],
queryFn: () => getMetrics(wsId),
+ enabled,
refetchInterval: 3000,
staleTime: 0,
})
diff --git a/frontend/src/hooks/usePRs.js b/frontend/src/hooks/usePRs.js
index f865de5..b3fe605 100644
--- a/frontend/src/hooks/usePRs.js
+++ b/frontend/src/hooks/usePRs.js
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getPRs } from '../api/client'
-export function usePRs(wsId) {
+export function usePRs(wsId, { enabled = true } = {}) {
return useQuery({
queryKey: ['prs', wsId],
queryFn: () => getPRs(wsId),
+ enabled,
refetchInterval: 5000,
staleTime: 0,
})
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 4c5aac8..cd06722 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { WorkspaceProvider } from './context/WorkspaceContext'
+import { AuthProvider } from './context/AuthContext'
import { App } from './App'
import './index.css'
@@ -17,9 +18,11 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')).render(
-
-
-
+
+
+
+
+
)
diff --git a/orchestrator/config.py b/orchestrator/config.py
index 8e5dee2..3a7ded4 100644
--- a/orchestrator/config.py
+++ b/orchestrator/config.py
@@ -51,6 +51,10 @@
# === Dashboard ===
DASHBOARD_PORT = int(os.environ.get("DASHBOARD_PORT", "8420"))
+# === Dashboard Admin Auth ===
+ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin")
+ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
+
# === Workspaces ===
WORKSPACES_DIR = Path(os.environ.get("WORKSPACES_DIR", "/root/workspaces"))
@@ -74,6 +78,8 @@ def validate_environment() -> list[str]:
errors.append("CLAUDE_CODE_OAUTH_TOKEN is not set")
if not GH_TOKEN:
errors.append("GH_TOKEN is not set")
+ if not ADMIN_PASSWORD:
+ errors.append("ADMIN_PASSWORD is not set — the dashboard is unprotected")
# Check claude CLI
if not shutil.which("claude"):
@@ -121,6 +127,8 @@ def print_config():
print(f" MAX_RATE_RESUMES: {MAX_RATE_LIMIT_RESUMES}")
print(f" SKILLS_ENABLED: {SKILLS_ENABLED}")
print(f" DASHBOARD_PORT: {DASHBOARD_PORT}")
+ print(f" ADMIN_USERNAME: {ADMIN_USERNAME}")
+ print(f" ADMIN_PASSWORD: {'(set)' if ADMIN_PASSWORD else '(NOT SET — unprotected!)'}")
print(f" GIT_AUTHOR_NAME: {GIT_AUTHOR_NAME or '(not set — agent default)'}")
print(f" GIT_AUTHOR_EMAIL: {GIT_AUTHOR_EMAIL or '(not set — agent default)'}")
token_preview = CLAUDE_CODE_OAUTH_TOKEN[:12] + "..." if CLAUDE_CODE_OAUTH_TOKEN else "(not set)"
diff --git a/orchestrator/dashboard.py b/orchestrator/dashboard.py
index 699128a..52cc4a4 100644
--- a/orchestrator/dashboard.py
+++ b/orchestrator/dashboard.py
@@ -4,12 +4,14 @@
import json
import logging
import os
+import secrets
import signal
import time
import uuid
+from datetime import datetime, timedelta
from pathlib import Path
-from fastapi import FastAPI, HTTPException, Query
+from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@@ -18,9 +20,32 @@
from orchestrator import planner
from orchestrator import worktree
from orchestrator import workspace_manager as wm
+from orchestrator.config import ADMIN_USERNAME, ADMIN_PASSWORD
app = FastAPI(title="Claude Code Swarm Dashboard")
+SESSION_DURATION_DAYS = 30
+
+
+@app.middleware("http")
+async def auth_middleware(request: Request, call_next):
+ path = request.url.path
+ # Allow: login endpoint, static assets, and SPA HTML (non-API paths)
+ if path in ("/api/auth/login",) or not path.startswith("/api/"):
+ return await call_next(request)
+
+ # All other /api/* routes require a valid session token
+ auth_header = request.headers.get("Authorization", "")
+ if not auth_header.startswith("Bearer "):
+ return JSONResponse({"detail": "Not authenticated"}, status_code=401)
+
+ token = auth_header[7:] # strip "Bearer "
+ session = db.get_session(token)
+ if session is None:
+ return JSONResponse({"detail": "Invalid or expired session"}, status_code=401)
+
+ return await call_next(request)
+
STATIC_DIR = Path(__file__).parent / "static"
# Set by main.py after the AgentPool is created so dashboard endpoints can
@@ -35,6 +60,11 @@ def set_agent_pool(pool):
# === Pydantic Models ===
+class LoginRequest(BaseModel):
+ username: str
+ password: str
+
+
class CreateWorkspaceRequest(BaseModel):
repo_url: str
name: str | None = None
@@ -62,6 +92,38 @@ async def index():
return FileResponse(str(index))
+# === Auth Endpoints ===
+
+@app.post("/api/auth/login")
+async def login(req: LoginRequest):
+ """Validate credentials and return a session token."""
+ u_ok = secrets.compare_digest(req.username.encode(), ADMIN_USERNAME.encode())
+ p_ok = secrets.compare_digest(req.password.encode(), ADMIN_PASSWORD.encode())
+ valid = ADMIN_PASSWORD and u_ok and p_ok
+ if not valid:
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+ token = secrets.token_urlsafe(32)
+ expires_at = (datetime.utcnow() + timedelta(days=SESSION_DURATION_DAYS)).isoformat()
+ db.create_session(token, expires_at)
+ db.cleanup_expired_sessions()
+ return {"token": token, "expires_at": expires_at}
+
+
+@app.get("/api/auth/check")
+async def auth_check():
+ """Verify the current session is valid (middleware handles the actual check)."""
+ return {"ok": True}
+
+
+@app.post("/api/auth/logout")
+async def logout(request: Request):
+ """Invalidate the current session token."""
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.startswith("Bearer "):
+ db.delete_session(auth_header[7:])
+ return {"ok": True}
+
+
# === Workspace Endpoints ===
@app.post("/api/workspaces")
diff --git a/orchestrator/db.py b/orchestrator/db.py
index e565093..1eefba5 100644
--- a/orchestrator/db.py
+++ b/orchestrator/db.py
@@ -126,6 +126,12 @@
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES planning_sessions(id)
);
+
+CREATE TABLE IF NOT EXISTS sessions (
+ token TEXT PRIMARY KEY,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ expires_at TIMESTAMP NOT NULL
+);
"""
@@ -754,6 +760,39 @@ def get_planning_events(session_id: str, since_id: int = 0, limit: int = 200) ->
return [dict(r) for r in rows]
+# === Sessions ===
+
+
+def create_session(token: str, expires_at: str) -> None:
+ conn = _get_connection()
+ conn.execute(
+ "INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)",
+ (token, _now(), expires_at),
+ )
+ conn.commit()
+
+
+def get_session(token: str) -> dict | None:
+ conn = _get_connection()
+ row = conn.execute(
+ "SELECT * FROM sessions WHERE token = ? AND expires_at > ?",
+ (token, _now()),
+ ).fetchone()
+ return dict(row) if row else None
+
+
+def delete_session(token: str) -> None:
+ conn = _get_connection()
+ conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
+ conn.commit()
+
+
+def cleanup_expired_sessions() -> None:
+ conn = _get_connection()
+ conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (_now(),))
+ conn.commit()
+
+
# === Metrics ===