diff --git a/dashboard/app.py b/dashboard/app.py index 02e7d29..af286eb 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -1,11 +1,15 @@ -from fastapi import FastAPI, Depends, HTTPException +from fastapi import FastAPI, Depends, HTTPException, Request, Response, Cookie from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, RedirectResponse, JSONResponse from sqlalchemy.orm import Session from pydantic import BaseModel from datetime import datetime, timezone from typing import Optional +import hashlib +import hmac import json +import os +import secrets from database import get_db, init_db from models import Agent, Run @@ -13,6 +17,62 @@ from models import Agent, Run app = FastAPI(title="Agent Command Center", version="1.0.0") +# --- Auth --- + +AUTH_USER = os.environ.get("AUTH_USER", "eric") +AUTH_PASS = os.environ.get("AUTH_PASS", "Kj8#mPx2vQ!nR4wL") +SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32)) + +# In-memory session store +_sessions: dict[str, str] = {} + + +def create_session(username: str) -> str: + token = secrets.token_urlsafe(32) + _sessions[token] = username + return token + + +def get_current_user(session: Optional[str] = Cookie(None)) -> Optional[str]: + if session and session in _sessions: + return _sessions[session] + return None + + +def require_auth(session: Optional[str] = Cookie(None)): + user = get_current_user(session) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +class LoginRequest(BaseModel): + username: str + password: str + + +@app.post("/api/login") +def login(creds: LoginRequest, response: Response): + if creds.username == AUTH_USER and creds.password == AUTH_PASS: + token = create_session(creds.username) + response.set_cookie("session", token, httponly=True, samesite="lax", max_age=86400 * 7) + return {"status": "ok", "user": creds.username} + raise HTTPException(status_code=401, detail="Invalid credentials") + + +@app.post("/api/logout") +def logout(response: Response, session: Optional[str] = Cookie(None)): + if session and session in _sessions: + del _sessions[session] + response.delete_cookie("session") + return {"status": "ok"} + + +@app.get("/login") +def login_page(): + return FileResponse("static/login.html") + + # --- Pydantic schemas --- class AgentCreate(BaseModel): @@ -49,7 +109,7 @@ def health(): @app.get("/api/agents") -def list_agents(db: Session = Depends(get_db)): +def list_agents(user: str = Depends(require_auth), db: Session = Depends(get_db)): agents = db.query(Agent).all() result = [] for a in agents: @@ -96,7 +156,7 @@ def create_agent(agent: AgentCreate, db: Session = Depends(get_db)): @app.get("/api/agents/{agent_id}") -def get_agent(agent_id: str, db: Session = Depends(get_db)): +def get_agent(agent_id: str, user: str = Depends(require_auth), db: Session = Depends(get_db)): agent = db.query(Agent).filter(Agent.id == agent_id).first() if not agent: raise HTTPException(status_code=404, detail="Agent not found") @@ -172,7 +232,7 @@ def update_run(run_id: int, update: RunUpdate, db: Session = Depends(get_db)): @app.get("/api/runs") -def list_runs(limit: int = 50, db: Session = Depends(get_db)): +def list_runs(limit: int = 50, user: str = Depends(require_auth), db: Session = Depends(get_db)): runs = db.query(Run).order_by(Run.started_at.desc()).limit(limit).all() return [{ "id": r.id, @@ -191,7 +251,10 @@ def list_runs(limit: int = 50, db: Session = Depends(get_db)): app.mount("/static", StaticFiles(directory="static"), name="static") @app.get("/") -def root(): +def root(session: Optional[str] = Cookie(None)): + user = get_current_user(session) + if not user: + return RedirectResponse("/login", status_code=302) return FileResponse("static/index.html") diff --git a/dashboard/static/index.html b/dashboard/static/index.html index 0682b5d..6fe28a7 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -50,6 +50,18 @@ border-radius: 50%; background: var(--green); } + .logout-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + padding: 0.35rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + cursor: pointer; + margin-left: 1rem; + transition: border-color 0.2s, color 0.2s; + } + .logout-btn:hover { border-color: var(--text-dim); color: var(--text); } .container { max-width: 1200px; margin: 0 auto; padding: 1.5rem 2rem; } /* Agent Cards */ @@ -210,6 +222,7 @@
0 agents +
@@ -346,12 +359,21 @@ document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); }); +async function logout() { + await fetch(API + '/api/logout', {method: 'POST'}); + window.location.href = '/login'; +} + async function refresh() { try { const [agentsRes, runsRes] = await Promise.all([ fetch(API + '/api/agents'), fetch(API + '/api/runs?limit=25'), ]); + if (agentsRes.status === 401 || runsRes.status === 401) { + window.location.href = '/login'; + return; + } renderAgents(await agentsRes.json()); renderRuns(await runsRes.json()); } catch (err) { diff --git a/dashboard/static/login.html b/dashboard/static/login.html new file mode 100644 index 0000000..d7a1c8d --- /dev/null +++ b/dashboard/static/login.html @@ -0,0 +1,148 @@ + + + + + +Login — Agent Command Center + + + + +
+

Agent Command Center

+

Sign in to continue

+
+
+ + +
+
+ + +
+ +

+
+
+ + + +