diff --git a/dashboard/app.py b/dashboard/app.py
index cb42686..4077ee3 100644
--- a/dashboard/app.py
+++ b/dashboard/app.py
@@ -3,12 +3,15 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session
from pydantic import BaseModel
-from datetime import datetime, timezone
+from datetime import datetime, timezone, timedelta
from typing import Optional
import hashlib
import json
import os
import secrets
+import smtplib
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
from database import get_db, init_db
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge
@@ -19,6 +22,15 @@ app = FastAPI(title="Agent Command Center", version="2026.04.12.01")
SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32))
_sessions: dict[str, dict] = {} # token -> {user_id, username, role}
+_magic_links: dict[str, dict] = {} # token -> {user_id, email, expires}
+
+# SMTP Config
+SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
+SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
+SMTP_USER = os.environ.get("SMTP_USER", "eric.jungbauer@gmail.com")
+SMTP_PASS = os.environ.get("SMTP_PASS", "jozj oags ifqy auey")
+SMTP_FROM = os.environ.get("SMTP_FROM", "eric.jungbauer@gmail.com")
+APP_URL = os.environ.get("APP_URL", "https://agents.jfamily.io")
def hash_password(password: str) -> str:
@@ -64,6 +76,9 @@ class LoginRequest(BaseModel):
username: str # accepts username or email
password: str
+class MagicLinkRequest(BaseModel):
+ email: str
+
class InstanceCreate(BaseModel):
catalog_id: str
name: Optional[str] = None
@@ -134,6 +149,78 @@ def logout(response: Response, session: Optional[str] = Cookie(None)):
return {"status": "ok"}
+def send_magic_email(email: str, token: str):
+ """Send a magic link email via SMTP."""
+ link = f"{APP_URL}/auth/verify?token={token}"
+ html = f"""
+
+
Agent Command Center
+
Click the button below to sign in:
+
Sign In
+
This link expires in 15 minutes. If you didn't request this, ignore this email.
+
{APP_URL}
+
+ """
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = "Sign in to Agent Command Center"
+ msg["From"] = SMTP_FROM
+ msg["To"] = email
+ msg.attach(MIMEText(f"Sign in: {link}\n\nExpires in 15 minutes.", "plain"))
+ msg.attach(MIMEText(html, "html"))
+
+ with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
+ server.starttls()
+ server.login(SMTP_USER, SMTP_PASS)
+ server.send_message(msg)
+
+
+@app.post("/api/auth/magic-link")
+def request_magic_link(data: MagicLinkRequest, db: Session = Depends(get_db)):
+ """Send a magic link to the user's email."""
+ user = db.query(User).filter(User.email == data.email).first()
+ if not user:
+ # Don't reveal whether email exists — return success either way
+ return {"status": "ok", "message": "If that email is registered, a sign-in link has been sent."}
+
+ # Generate token
+ token = secrets.token_urlsafe(32)
+ _magic_links[token] = {
+ "user_id": user.id,
+ "email": user.email,
+ "expires": datetime.now(timezone.utc) + timedelta(minutes=15),
+ }
+
+ # Send email
+ try:
+ send_magic_email(user.email, token)
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to send email: {e}")
+
+ return {"status": "ok", "message": "If that email is registered, a sign-in link has been sent."}
+
+
+@app.get("/auth/verify")
+def verify_magic_link(token: str, response: Response, db: Session = Depends(get_db)):
+ """Verify a magic link token and log the user in."""
+ link_data = _magic_links.pop(token, None)
+ if not link_data:
+ return RedirectResponse("/login?error=invalid", status_code=302)
+
+ if datetime.now(timezone.utc) > link_data["expires"]:
+ return RedirectResponse("/login?error=expired", status_code=302)
+
+ user = db.query(User).filter(User.id == link_data["user_id"]).first()
+ if not user:
+ return RedirectResponse("/login?error=invalid", status_code=302)
+
+ # Create session
+ session_token = create_session(user)
+ resp = RedirectResponse("/", status_code=302)
+ resp.set_cookie("session", session_token, httponly=True, samesite="lax", max_age=86400 * 7)
+ return resp
+
+
@app.get("/api/me")
def me(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
u = db.query(User).filter(User.id == user["user_id"]).first()
diff --git a/dashboard/static/login.html b/dashboard/static/login.html
index d7a1c8d..babc90b 100644
--- a/dashboard/static/login.html
+++ b/dashboard/static/login.html
@@ -5,94 +5,30 @@
Login — Agent Command Center
@@ -100,49 +36,122 @@
Agent Command Center
Sign in to continue
-