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 ( +
+
+
+
+
+

Claude Code Swarm

+
+

Sign in to access the dashboard

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + className="bg-[var(--bg)] border border-[var(--border)] rounded-md px-3 py-2 text-[13px] text-[var(--text)] outline-none focus:border-[var(--accent)] transition-colors" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="bg-[var(--bg)] border border-[var(--border)] rounded-md px-3 py-2 text-[13px] text-[var(--text)] outline-none focus:border-[var(--accent)] transition-colors" + required + /> +
+ + +
+
+
+ ) +} 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 ===