Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 15 additions & 4 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <div className="min-h-screen bg-[var(--bg)]" />
}

if (!isAuthenticated) {
return <LoginPage />
}

const counts = {
agents: agentsData?.total ?? 0,
Expand Down
26 changes: 25 additions & 1 deletion frontend/src/api/client.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,42 @@
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}`)
}
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)}` : ''}`)
Expand Down
80 changes: 80 additions & 0 deletions frontend/src/components/auth/LoginPage.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-[var(--bg)]">
<div className="w-full max-w-sm">
<div className="mb-8 text-center">
<div className="flex items-center justify-center gap-2.5 mb-2">
<div className="w-2 h-2 rounded-full bg-[var(--accent)] shadow-[0_0_8px_var(--accent)]" />
<h1 className="text-[18px] font-semibold tracking-tight">Claude Code Swarm</h1>
</div>
<p className="text-[12px] text-[var(--text-muted)]">Sign in to access the dashboard</p>
</div>

<form
onSubmit={handleSubmit}
className="bg-[var(--surface)] border border-[var(--border)] rounded-xl p-6 flex flex-col gap-4"
>
{error && (
<div className="text-[11px] text-[var(--red)] bg-[var(--red-dim)] border border-[rgba(248,113,113,0.15)] rounded-md px-3 py-2 font-mono">
{error}
</div>
)}

<div className="flex flex-col gap-1.5">
<label className="text-[11px] text-[var(--text-muted)] font-medium">Username</label>
<input
type="text"
autoComplete="username"
value={username}
onChange={e => 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
/>
</div>

<div className="flex flex-col gap-1.5">
<label className="text-[11px] text-[var(--text-muted)] font-medium">Password</label>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={e => 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
/>
</div>

<button
type="submit"
disabled={loading}
className="mt-1 px-4 py-2 text-[12px] font-semibold bg-[var(--accent)] text-white rounded-md hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-[0_0_16px_rgba(139,92,246,0.2)]"
>
{loading ? 'Signing in\u2026' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
11 changes: 10 additions & 1 deletion frontend/src/components/layout/Header.jsx
Original file line number Diff line number Diff line change
@@ -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 }) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -82,6 +84,13 @@ export function Header({ onAddWorkspace, onOpenSettings, onOpenPlanner }) {
<Settings size={14} />
</button>
)}
<button
onClick={logout}
className="p-1.5 rounded-md text-[var(--text-muted)] hover:text-[var(--red)] hover:bg-[var(--surface-hover)] transition-colors"
title="Sign out"
>
<LogOut size={14} />
</button>
</div>
</header>
)
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/context/AuthContext.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthContext.Provider value={{ token, isAuthenticated: !!token, isChecking, login, logout }}>
{children}
</AuthContext.Provider>
)
}

export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}
3 changes: 2 additions & 1 deletion frontend/src/hooks/useAgents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/hooks/useIssues.js
Original file line number Diff line number Diff line change
@@ -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,
})
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/hooks/useMetrics.js
Original file line number Diff line number Diff line change
@@ -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,
})
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/hooks/usePRs.js
Original file line number Diff line number Diff line change
@@ -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,
})
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -17,9 +18,11 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<WorkspaceProvider>
<App />
</WorkspaceProvider>
<AuthProvider>
<WorkspaceProvider>
<App />
</WorkspaceProvider>
</AuthProvider>
</QueryClientProvider>
</StrictMode>
)
8 changes: 8 additions & 0 deletions orchestrator/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand All @@ -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"):
Expand Down Expand Up @@ -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)"
Expand Down
Loading
Loading