Add session-based login page and auth for dashboard

This commit is contained in:
2026-04-13 01:19:56 +00:00
parent 90cf0992b8
commit 73ea08003e
3 changed files with 239 additions and 6 deletions
+69 -6
View File
@@ -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.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, RedirectResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
import hashlib
import hmac
import json import json
import os
import secrets
from database import get_db, init_db from database import get_db, init_db
from models import Agent, Run from models import Agent, Run
@@ -13,6 +17,62 @@ from models import Agent, Run
app = FastAPI(title="Agent Command Center", version="1.0.0") 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 --- # --- Pydantic schemas ---
class AgentCreate(BaseModel): class AgentCreate(BaseModel):
@@ -49,7 +109,7 @@ def health():
@app.get("/api/agents") @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() agents = db.query(Agent).all()
result = [] result = []
for a in agents: for a in agents:
@@ -96,7 +156,7 @@ def create_agent(agent: AgentCreate, db: Session = Depends(get_db)):
@app.get("/api/agents/{agent_id}") @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() agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent: if not agent:
raise HTTPException(status_code=404, detail="Agent not found") 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") @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() runs = db.query(Run).order_by(Run.started_at.desc()).limit(limit).all()
return [{ return [{
"id": r.id, "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.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/") @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") return FileResponse("static/index.html")
+22
View File
@@ -50,6 +50,18 @@
border-radius: 50%; border-radius: 50%;
background: var(--green); 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; } .container { max-width: 1200px; margin: 0 auto; padding: 1.5rem 2rem; }
/* Agent Cards */ /* Agent Cards */
@@ -210,6 +222,7 @@
<div class="status"> <div class="status">
<div class="dot"></div> <div class="dot"></div>
<span id="agent-count">0 agents</span> <span id="agent-count">0 agents</span>
<button class="logout-btn" onclick="logout()">Logout</button>
</div> </div>
</div> </div>
@@ -346,12 +359,21 @@ document.getElementById('modal-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal(); if (e.target === e.currentTarget) closeModal();
}); });
async function logout() {
await fetch(API + '/api/logout', {method: 'POST'});
window.location.href = '/login';
}
async function refresh() { async function refresh() {
try { try {
const [agentsRes, runsRes] = await Promise.all([ const [agentsRes, runsRes] = await Promise.all([
fetch(API + '/api/agents'), fetch(API + '/api/agents'),
fetch(API + '/api/runs?limit=25'), fetch(API + '/api/runs?limit=25'),
]); ]);
if (agentsRes.status === 401 || runsRes.status === 401) {
window.location.href = '/login';
return;
}
renderAgents(await agentsRes.json()); renderAgents(await agentsRes.json());
renderRuns(await runsRes.json()); renderRuns(await runsRes.json());
} catch (err) { } catch (err) {
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — Agent Command Center</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2e3345;
--text: #e4e6ed;
--text-dim: #8b8fa3;
--accent: #6c5ce7;
--accent-hover: #7c6ef0;
--red: #e17055;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 380px;
}
.login-card h1 {
font-size: 1.3rem;
font-weight: 600;
margin-bottom: 0.4rem;
text-align: center;
}
.login-card .subtitle {
color: var(--text-dim);
font-size: 0.85rem;
text-align: center;
margin-bottom: 2rem;
}
.field {
margin-bottom: 1.25rem;
}
.field label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-dim);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field input {
width: 100%;
padding: 0.65rem 0.85rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
.field input:focus {
border-color: var(--accent);
}
.btn {
width: 100%;
padding: 0.7rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
margin-top: 0.5rem;
}
.btn:hover { background: var(--accent-hover); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.error {
color: var(--red);
font-size: 0.85rem;
text-align: center;
margin-top: 1rem;
display: none;
}
</style>
</head>
<body>
<div class="login-card">
<h1>Agent Command Center</h1>
<p class="subtitle">Sign in to continue</p>
<form id="login-form">
<div class="field">
<label>Username</label>
<input type="text" id="username" autocomplete="username" autofocus required>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn" id="submit-btn">Sign In</button>
<p class="error" id="error-msg"></p>
</form>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const err = document.getElementById('error-msg');
btn.disabled = true;
err.style.display = 'none';
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
if (res.ok) {
window.location.href = '/';
} else {
err.textContent = 'Invalid username or password';
err.style.display = 'block';
}
} catch (ex) {
err.textContent = 'Connection error';
err.style.display = 'block';
}
btn.disabled = false;
});
</script>
</body>
</html>