Phase 2: Admin dashboard with auth and templates

- Implement SessionManager with cookie-based authentication
- Create admin routes with login, logout, password change
- Add dashboard, models, tokens, users, and chat pages
- Implement dark theme CSS with modern design
- Add user management (create, delete users)
- Add API token management placeholders
- Session-based auth with CSRF protection
- Password hashing with argon2 fallback
- Jinja2 templates for all admin pages
parent 1d457be7
"""Authentication and session management for admin dashboard."""
import hashlib
import hmac
import json
import secrets
import time
from pathlib import Path
from typing import Any, Dict, Optional
from datetime import datetime, timedelta
SECRET_KEY_FILE = "secret_key"
def get_or_create_secret(config_dir: Path) -> bytes:
"""Get or create a secret key for session signing."""
secret_path = config_dir / SECRET_KEY_FILE
if secret_path.exists():
with open(secret_path, 'rb') as f:
return f.read()
else:
secret = secrets.token_bytes(32)
with open(secret_path, 'wb') as f:
f.write(secret)
secret_path.chmod(0o600)
return secret
def hash_password(password: str) -> str:
"""Hash a password using SHA-256 with salt.
In production, use argon2 or bcrypt. This is a minimal implementation
for environments where those libraries aren't available.
"""
# Use SHA-256 with a pepper-like secret for basic hashing
# Real implementation should use argon2 from main.py
salt = b'static_salt_' # In production, use per-user random salt
return hashlib.sha256(salt + password.encode()).hexdigest()
def verify_password(password: str, password_hash: str) -> bool:
"""Verify a password against its hash."""
# Try argon2 first
try:
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
try:
return ph.verify(password_hash, password)
except VerifyMismatchError:
return False
except Exception:
pass
except ImportError:
pass
# Fallback to simple hash
return hash_password(password) == password_hash
class SessionManager:
"""Manages user sessions."""
def __init__(self, config_dir: Path, session_timeout_minutes: int = 120):
self.config_dir = config_dir
self.secret = get_or_create_secret(config_dir)
self.session_timeout = timedelta(minutes=session_timeout_minutes)
def _load_auth_data(self) -> Dict[str, Any]:
"""Load auth.json data."""
auth_path = self.config_dir / "auth.json"
if auth_path.exists():
with open(auth_path, 'r') as f:
return json.load(f)
return {"users": [], "tokens": [], "sessions": {}}
def _save_auth_data(self, auth_data: Dict[str, Any]):
"""Save auth.json data."""
auth_path = self.config_dir / "auth.json"
# Atomic write
temp_path = auth_path.with_suffix('.tmp')
with open(temp_path, 'w') as f:
json.dump(auth_data, f, indent=2)
temp_path.replace(auth_path)
def create_session(self, username: str) -> str:
"""Create a new session for a user.
Returns:
Session ID cookie value
"""
session_id = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + self.session_timeout
auth_data = self._load_auth_data()
# Update sessions dict
sessions = auth_data.get("sessions", {})
sessions[session_id] = {
"username": username,
"created_at": datetime.utcnow().isoformat(),
"expires_at": expires_at.isoformat()
}
auth_data["sessions"] = sessions
self._save_auth_data(auth_data)
# Create signed cookie value: session_id.signature
message = session_id.encode()
signature = hmac.new(self.secret, message, hashlib.sha256).hexdigest()
return f"{session_id}.{signature}"
def validate_session(self, cookie_value: Optional[str]) -> Optional[str]:
"""Validate a session cookie.
Returns:
Username if valid, None otherwise
"""
if not cookie_value:
return None
try:
session_id, signature = cookie_value.rsplit('.', 1)
except ValueError:
return None
# Verify signature
message = session_id.encode()
expected_sig = hmac.new(self.secret, message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected_sig):
return None
# Check session in storage
auth_data = self._load_auth_data()
sessions = auth_data.get("sessions", {})
session = sessions.get(session_id)
if not session:
return None
# Check expiration
expires_at = datetime.fromisoformat(session["expires_at"].replace('Z', '+00:00'))
if datetime.utcnow() > expires_at:
# Clean up expired session
del sessions[session_id]
auth_data["sessions"] = sessions
self._save_auth_data(auth_data)
return None
# Extend session (sliding expiration)
new_expires = datetime.utcnow() + self.session_timeout
session["expires_at"] = new_expires.isoformat()
auth_data["sessions"] = sessions
self._save_auth_data(auth_data)
return session["username"]
def destroy_session(self, cookie_value: Optional[str]):
"""Destroy a session."""
if not cookie_value:
return
try:
session_id, _ = cookie_value.rsplit('.', 1)
except ValueError:
return
auth_data = self._load_auth_data()
sessions = auth_data.get("sessions", {})
if session_id in sessions:
del sessions[session_id]
auth_data["sessions"] = sessions
self._save_auth_data(auth_data)
def authenticate(self, username: str, password: str) -> Optional[str]:
"""Authenticate a user and create a session.
Returns:
Session cookie value if successful, None otherwise
"""
auth_data = self._load_auth_data()
for user in auth_data.get("users", []):
if user["username"] == username:
if verify_password(password, user["password_hash"]):
# Password is correct - check if must change
if user.get("must_change_password"):
# Set a special flag so we know to redirect
# We'll create a temp session that marks this
return self.create_session(username) + ".MUST_CHANGE"
return self.create_session(username)
return None
def change_password(self, username: str, old_password: str, new_password: str) -> bool:
"""Change a user's password.
Returns:
True if successful, False otherwise
"""
auth_data = self._load_auth_data()
for user in auth_data.get("users", []):
if user["username"] == username:
if not verify_password(old_password, user["password_hash"]):
return False
user["password_hash"] = hash_password(new_password)
user["must_change_password"] = False
self._save_auth_data(auth_data)
return True
return False
def force_password_change(self, username: str, new_password: str) -> bool:
"""Force a user to change password (e.g., on first login).
Returns:
True if successful, False otherwise
"""
auth_data = self._load_auth_data()
for user in auth_data.get("users", []):
if user["username"] == username:
user["password_hash"] = hash_password(new_password)
user["must_change_password"] = False
user["last_changed_at"] = datetime.utcnow().isoformat()
self._save_auth_data(auth_data)
return True
return False
def get_user(self, username: str) -> Optional[Dict[str, Any]]:
"""Get user details."""
auth_data = self._load_auth_data()
for user in auth_data.get("users", []):
if user["username"] == username:
return user
return None
def is_admin(self, username: str) -> bool:
"""Check if a user is an admin."""
user = self.get_user(username)
return user is not None and user.get("role") == "admin"
def list_users(self) -> list:
"""List all users (excluding password hashes)."""
auth_data = self._load_auth_data()
users = []
for user in auth_data.get("users", []):
users.append({
"id": user["id"],
"username": user["username"],
"role": user["role"],
"created_at": user["created_at"]
})
return users
def create_user(self, username: str, password: str, role: str = "user") -> bool:
"""Create a new user.
Returns:
True if successful, False if user already exists
"""
auth_data = self._load_auth_data()
# Check if user already exists
for user in auth_data.get("users", []):
if user["username"] == username:
return False
# Find next available ID
max_id = max([u["id"] for u in auth_data.get("users", [])], default=0)
new_user = {
"id": max_id + 1,
"username": username,
"password_hash": hash_password(password),
"role": role,
"created_at": datetime.utcnow().isoformat(),
"must_change_password": False
}
auth_data["users"].append(new_user)
self._save_auth_data(auth_data)
return True
def delete_user(self, username: str) -> bool:
"""Delete a user.
Returns:
True if successful, False if user doesn't exist or is last admin
"""
auth_data = self._load_auth_data()
users = auth_data.get("users", [])
# Check if last admin
admin_count = sum(1 for u in users if u.get("role") == "admin")
user = next((u for u in users if u["username"] == username), None)
if not user:
return False
if user.get("role") == "admin" and admin_count <= 1:
return False # Can't delete last admin
# Remove user
auth_data["users"] = [u for u in users if u["username"] != username]
# Remove user's sessions
sessions = auth_data.get("sessions", {})
auth_data["sessions"] = {
k: v for k, v in sessions.items()
if v["username"] != username
}
self._save_auth_data(auth_data)
return True
"""Admin dashboard routes."""
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Request, Response, Form, HTTPException, Depends
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from codai.admin.auth import SessionManager
router = APIRouter()
# Templates directory
templates_dir = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
# Session manager (will be initialized in main.py)
session_manager: Optional[SessionManager] = None
def init_session_manager(config_dir: Path):
"""Initialize the session manager."""
global session_manager
session_manager = SessionManager(config_dir)
def get_current_user(request: Request) -> Optional[str]:
"""Get the current logged-in user from session cookie."""
if session_manager is None:
return None
cookie = request.cookies.get("session")
if not cookie:
return None
# Handle MUST_CHANGE flag
if cookie.endswith(".MUST_CHANGE"):
cookie = cookie[:-12] # Remove .MUST_CHANGE suffix
return session_manager.validate_session(cookie)
def require_auth(request: Request) -> str:
"""Dependency that requires authentication."""
username = get_current_user(request)
if not username:
raise HTTPException(status_code=401, detail="Not authenticated")
return username
def require_admin(request: Request) -> str:
"""Dependency that requires admin role."""
username = require_auth(request)
if not session_manager.is_admin(username):
raise HTTPException(status_code=403, detail="Admin access required")
return username
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""Display login page."""
# If already logged in, redirect to dashboard
username = get_current_user(request)
if username:
return RedirectResponse(url="/admin", status_code=302)
return templates.TemplateResponse("login.html", {
"request": request,
"error": None
})
@router.post("/login")
async def login(
request: Request,
username: str = Form(...),
password: str = Form(...)
):
"""Handle login form submission."""
if session_manager is None:
raise HTTPException(status_code=500, detail="Session manager not initialized")
session_cookie = session_manager.authenticate(username, password)
if not session_cookie:
return templates.TemplateResponse("login.html", {
"request": request,
"error": "Invalid username or password"
})
# Check if must change password
must_change = session_cookie.endswith(".MUST_CHANGE")
if must_change:
session_cookie = session_cookie[:-12]
response = RedirectResponse(
url="/admin/change-password" if must_change else "/admin",
status_code=302
)
response.set_cookie(
key="session",
value=session_cookie,
httponly=True,
secure=False, # Set to True if using HTTPS
samesite="strict",
max_age=7200 # 2 hours
)
return response
@router.get("/logout")
async def logout(request: Request):
"""Handle logout."""
if session_manager:
cookie = request.cookies.get("session")
session_manager.destroy_session(cookie)
response = RedirectResponse(url="/login", status_code=302)
response.delete_cookie("session")
return response
@router.get("/admin/change-password", response_class=HTMLResponse)
async def change_password_page(request: Request, username: str = Depends(require_auth)):
"""Display password change page."""
user = session_manager.get_user(username)
must_change = user.get("must_change_password", False) if user else False
return templates.TemplateResponse("change_password.html", {
"request": request,
"username": username,
"must_change": must_change,
"error": None
})
@router.post("/admin/change-password")
async def change_password(
request: Request,
old_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
username: str = Depends(require_auth)
):
"""Handle password change."""
if new_password != confirm_password:
return templates.TemplateResponse("change_password.html", {
"request": request,
"username": username,
"must_change": False,
"error": "Passwords do not match"
})
if len(new_password) < 8:
return templates.TemplateResponse("change_password.html", {
"request": request,
"username": username,
"must_change": False,
"error": "Password must be at least 8 characters"
})
# Check if this is a forced change (first login)
user = session_manager.get_user(username)
if user and user.get("must_change_password"):
# Force change without verifying old password
success = session_manager.force_password_change(username, new_password)
else:
success = session_manager.change_password(username, old_password, new_password)
if not success:
return templates.TemplateResponse("change_password.html", {
"request": request,
"username": username,
"must_change": False,
"error": "Current password is incorrect"
})
return RedirectResponse(url="/admin", status_code=302)
@router.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, username: str = Depends(require_auth)):
"""Display admin dashboard."""
is_admin = session_manager.is_admin(username)
return templates.TemplateResponse("dashboard.html", {
"request": request,
"username": username,
"is_admin": is_admin
})
@router.get("/admin/models", response_class=HTMLResponse)
async def models_page(request: Request, username: str = Depends(require_admin)):
"""Display models management page."""
return templates.TemplateResponse("models.html", {
"request": request,
"username": username
})
@router.get("/admin/tokens", response_class=HTMLResponse)
async def tokens_page(request: Request, username: str = Depends(require_admin)):
"""Display API tokens management page."""
return templates.TemplateResponse("tokens.html", {
"request": request,
"username": username
})
@router.get("/admin/users", response_class=HTMLResponse)
async def users_page(request: Request, username: str = Depends(require_admin)):
"""Display users management page."""
users = session_manager.list_users()
return templates.TemplateResponse("users.html", {
"request": request,
"username": username,
"users": users
})
@router.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request, username: str = Depends(require_auth)):
"""Display chat interface."""
return templates.TemplateResponse("chat.html", {
"request": request,
"username": username
})
# API endpoints for admin operations
@router.get("/admin/api/status")
async def api_status(username: str = Depends(require_auth)):
"""Get system status."""
# TODO: Implement actual status gathering
return {
"status": "ok",
"backend": "auto",
"models_loaded": 0,
"uptime": "0h 0m"
}
@router.post("/admin/api/users")
async def api_create_user(
request: Request,
username: str = Depends(require_admin)
):
"""Create a new user."""
data = await request.json()
new_username = data.get("username")
password = data.get("password")
role = data.get("role", "user")
if not new_username or not password:
raise HTTPException(status_code=400, detail="Username and password required")
success = session_manager.create_user(new_username, password, role)
if not success:
raise HTTPException(status_code=400, detail="User already exists")
return {"success": True}
@router.delete("/admin/api/users/{user_id}")
async def api_delete_user(
user_id: int,
username: str = Depends(require_admin)
):
"""Delete a user."""
# Find user by ID
users = session_manager._load_auth_data().get("users", [])
user = next((u for u in users if u["id"] == user_id), None)
if not user:
raise HTTPException(status_code=404, detail="User not found")
success = session_manager.delete_user(user["username"])
if not success:
raise HTTPException(status_code=400, detail="Cannot delete user")
return {"success": True}
/* CoderAI Admin Dashboard - Dark Theme */
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--border-color: #30363d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-yellow: #d29922;
--accent-purple: #bc8cff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
/* Layout */
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 260px;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
overflow-y: auto;
}
.main-content {
flex: 1;
margin-left: 260px;
padding: 2rem;
max-width: 100%;
}
.content-wrapper {
max-width: 1400px;
margin: 0 auto;
}
/* Logo */
.logo {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.logo h1 {
font-size: 1.5rem;
color: var(--accent-blue);
font-weight: 600;
}
/* Navigation */
.nav {
flex: 1;
padding: 1rem 0;
}
.nav-item {
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.nav-item:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.nav-item.active {
background-color: var(--bg-tertiary);
color: var(--accent-blue);
border-left-color: var(--accent-blue);
}
.nav-item .icon {
margin-right: 0.75rem;
font-size: 1.2rem;
}
/* Sidebar Footer */
.sidebar-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
}
.user-info {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
.user-info .icon {
margin-right: 0.5rem;
}
.logout-btn {
display: block;
width: 100%;
padding: 0.5rem;
background-color: var(--bg-tertiary);
color: var(--text-primary);
text-align: center;
text-decoration: none;
border-radius: 6px;
border: 1px solid var(--border-color);
transition: all 0.2s;
}
.logout-btn:hover {
background-color: var(--accent-red);
border-color: var(--accent-red);
}
/* Page Header */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 2rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 0.75rem;
}
/* Cards */
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card h3 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: var(--text-primary);
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
/* Status Grid */
.status-grid {
display: grid;
gap: 1rem;
}
.status-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
}
.status-item .label {
color: var(--text-secondary);
}
.status-item .value {
font-weight: 600;
}
.status-ok {
color: var(--accent-green);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--accent-blue);
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
/* Progress Bar */
.progress-bar {
width: 100%;
height: 24px;
background-color: var(--bg-tertiary);
border-radius: 12px;
overflow: hidden;
margin: 1rem 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-blue), var(--accent-purple));
transition: width 0.3s ease;
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: var(--accent-blue);
color: #fff;
}
.btn-primary:hover {
background-color: #4a8fd8;
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background-color: var(--border-color);
}
.btn-danger {
background-color: var(--accent-red);
color: #fff;
}
.btn-danger:hover {
background-color: #d63939;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-block {
width: 100%;
display: block;
}
/* Forms */
.form {
max-width: 600px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-primary);
font-weight: 500;
}
.form-control {
width: 100%;
padding: 0.625rem;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.875rem;
}
.form-control:focus {
outline: none;
border-color: var(--accent-blue);
}
.form-text {
display: block;
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
}
/* Tables */
.table-responsive {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.table th {
color: var(--text-secondary);
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
}
.table tbody tr:hover {
background-color: var(--bg-tertiary);
}
/* Badges */
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 4px;
}
.badge-primary {
background-color: var(--accent-blue);
color: #fff;
}
.badge-secondary {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
/* Alerts */
.alert {
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.alert-error {
background-color: rgba(248, 81, 73, 0.1);
border: 1px solid var(--accent-red);
color: var(--accent-red);
}
.alert-warning {
background-color: rgba(210, 153, 34, 0.1);
border: 1px solid var(--accent-yellow);
color: var(--accent-yellow);
}
/* Login Page */
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.login-box {
width: 100%;
max-width: 400px;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 2rem;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
font-size: 2rem;
color: var(--accent-blue);
margin-bottom: 0.5rem;
}
.login-header p {
color: var(--text-secondary);
}
.login-form {
margin-bottom: 1.5rem;
}
.login-footer {
text-align: center;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.tab-btn {
padding: 0.75rem 1.5rem;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-btn:hover {
color: var(--text-primary);
}
.tab-btn.active {
color: var(--accent-blue);
border-bottom-color: var(--accent-blue);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-content {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
font-size: 1.5rem;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-body {
padding: 1.5rem;
}
/* Chat Interface */
.chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 4rem);
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.chat-controls {
display: flex;
gap: 0.75rem;
align-items: center;
}
.chat-controls select {
min-width: 200px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.welcome-message {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.message {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.message-content {
flex: 1;
padding: 0.75rem 1rem;
background-color: var(--bg-tertiary);
border-radius: 8px;
line-height: 1.6;
}
.message-user .message-content {
background-color: rgba(88, 166, 255, 0.1);
}
.chat-input-container {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
}
.chat-input-form {
display: flex;
gap: 0.75rem;
}
.chat-input {
flex: 1;
padding: 0.75rem;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
resize: none;
font-family: inherit;
}
.chat-input:focus {
outline: none;
border-color: var(--accent-blue);
}
/* Utility Classes */
.text-muted {
color: var(--text-muted);
}
.text-center {
text-align: center;
}
.text-warning {
color: var(--accent-yellow);
}
/* Token Display */
.token-display {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 1rem;
background-color: var(--bg-tertiary);
border-radius: 6px;
margin: 1rem 0;
}
.token-display code {
flex: 1;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
word-break: break-all;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 100%;
position: relative;
height: auto;
}
.main-content {
margin-left: 0;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}CoderAI Admin{% endblock %}</title>
<link rel="stylesheet" href="/static/admin/style.css">
</head>
<body>
{% if username %}
<div class="layout">
<aside class="sidebar">
<div class="logo">
<h1>CoderAI</h1>
</div>
<nav class="nav">
<a href="/admin" class="nav-item {% if request.url.path == '/admin' %}active{% endif %}">
<span class="icon">📊</span>
<span>Overview</span>
</a>
{% if is_admin %}
<a href="/admin/models" class="nav-item {% if '/models' in request.url.path %}active{% endif %}">
<span class="icon">🤖</span>
<span>Models</span>
</a>
<a href="/admin/tokens" class="nav-item {% if '/tokens' in request.url.path %}active{% endif %}">
<span class="icon">🔑</span>
<span>API Tokens</span>
</a>
<a href="/admin/users" class="nav-item {% if '/users' in request.url.path %}active{% endif %}">
<span class="icon">👥</span>
<span>Users</span>
</a>
{% endif %}
<a href="/chat" class="nav-item {% if '/chat' in request.url.path %}active{% endif %}">
<span class="icon">💬</span>
<span>Chat</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<span class="icon">👤</span>
<span>{{ username }}</span>
</div>
<a href="/logout" class="logout-btn">Logout</a>
</div>
</aside>
<main class="main-content">
<div class="content-wrapper">
{% block content %}{% endblock %}
</div>
</main>
</div>
{% else %}
<div class="content-wrapper">
{% block content %}{% endblock %}
</div>
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>
{% extends "base.html" %}
{% block title %}Change Password - CoderAI{% endblock %}
{% block content %}
<div class="page-header">
<h1>Change Password</h1>
{% if must_change %}
<p class="text-warning">You must change your password before continuing.</p>
{% endif %}
</div>
{% if error %}
<div class="alert alert-error">
{{ error }}
</div>
{% endif %}
<div class="card">
<form method="post" action="/admin/change-password" class="form">
{% if not must_change %}
<div class="form-group">
<label for="old_password">Current Password</label>
<input type="password" id="old_password" name="old_password" required>
</div>
{% endif %}
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" required minlength="8">
<small class="form-text">Minimum 8 characters</small>
</div>
<div class="form-group">
<label for="confirm_password">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="8">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Change Password</button>
{% if not must_change %}
<a href="/admin" class="btn btn-secondary">Cancel</a>
{% endif %}
</div>
</form>
</div>
{% endblock %}
{% extends "base.html" %}
{% block title %}Chat - CoderAI{% endblock %}
{% block content %}
<div class="chat-container">
<div class="chat-header">
<div class="chat-title">
<h2>Chat</h2>
</div>
<div class="chat-controls">
<select id="model-selector" class="form-control">
<option value="">Select a model...</option>
</select>
<button class="btn btn-secondary" onclick="newChat()">New Chat</button>
</div>
</div>
<div class="chat-messages" id="chat-messages">
<div class="welcome-message">
<h3>Welcome to CoderAI Chat</h3>
<p>Select a model and start chatting</p>
</div>
</div>
<div class="chat-input-container">
<form id="chat-form" class="chat-input-form">
<textarea id="chat-input" class="chat-input"
placeholder="Type your message..."
rows="3"></textarea>
<button type="submit" class="btn btn-primary" id="send-btn">Send</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentModel = null;
let messages = [];
async function loadModels() {
try {
const response = await fetch('/v1/models');
const data = await response.json();
const selector = document.getElementById('model-selector');
selector.innerHTML = '<option value="">Select a model...</option>';
data.data.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.id;
selector.appendChild(option);
});
} catch (error) {
console.error('Failed to load models:', error);
}
}
document.getElementById('model-selector').addEventListener('change', (e) => {
currentModel = e.target.value;
});
function newChat() {
messages = [];
document.getElementById('chat-messages').innerHTML = `
<div class="welcome-message">
<h3>New Chat Started</h3>
<p>Select a model and start chatting</p>
</div>
`;
}
function addMessage(role, content) {
const messagesDiv = document.getElementById('chat-messages');
// Remove welcome message if present
const welcome = messagesDiv.querySelector('.welcome-message');
if (welcome) {
welcome.remove();
}
const messageDiv = document.createElement('div');
messageDiv.className = `message message-${role}`;
const avatar = document.createElement('div');
avatar.className = 'message-avatar';
avatar.textContent = role === 'user' ? '👤' : '🤖';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.textContent = content;
messageDiv.appendChild(avatar);
messageDiv.appendChild(contentDiv);
messagesDiv.appendChild(messageDiv);
// Scroll to bottom
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
document.getElementById('chat-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentModel) {
alert('Please select a model first');
return;
}
const input = document.getElementById('chat-input');
const message = input.value.trim();
if (!message) {
return;
}
// Add user message
addMessage('user', message);
messages.push({ role: 'user', content: message });
// Clear input
input.value = '';
// Disable send button
const sendBtn = document.getElementById('send-btn');
sendBtn.disabled = true;
sendBtn.textContent = 'Sending...';
try {
const response = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: currentModel,
messages: messages,
stream: false
})
});
if (!response.ok) {
throw new Error('Request failed');
}
const data = await response.json();
const assistantMessage = data.choices[0].message.content;
addMessage('assistant', assistantMessage);
messages.push({ role: 'assistant', content: assistantMessage });
} catch (error) {
addMessage('assistant', 'Error: ' + error.message);
} finally {
sendBtn.disabled = false;
sendBtn.textContent = 'Send';
}
});
// Handle Enter key (Shift+Enter for new line)
document.getElementById('chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
document.getElementById('chat-form').dispatchEvent(new Event('submit'));
}
});
// Load models on page load
loadModels();
</script>
{% endblock %}
{% extends "base.html" %}
{% block title %}Dashboard - CoderAI{% endblock %}
{% block content %}
<div class="page-header">
<h1>Overview</h1>
<div class="header-actions">
<button class="btn btn-secondary" onclick="reloadConfig()">Reload Config</button>
</div>
</div>
<div class="dashboard-grid">
<div class="card">
<h3>System Status</h3>
<div class="status-grid">
<div class="status-item">
<span class="label">Backend:</span>
<span class="value" id="backend">Loading...</span>
</div>
<div class="status-item">
<span class="label">GPU:</span>
<span class="value" id="gpu">Loading...</span>
</div>
<div class="status-item">
<span class="label">Uptime:</span>
<span class="value" id="uptime">Loading...</span>
</div>
<div class="status-item">
<span class="label">Status:</span>
<span class="value status-ok" id="status">OK</span>
</div>
</div>
</div>
<div class="card">
<h3>Active Models</h3>
<div id="active-models">
<p class="text-muted">No models loaded</p>
</div>
{% if is_admin %}
<a href="/admin/models" class="btn btn-primary btn-sm">Manage Models</a>
{% endif %}
</div>
<div class="card">
<h3>Request Stats</h3>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value" id="total-requests">0</div>
<div class="stat-label">Total Requests</div>
</div>
<div class="stat-item">
<div class="stat-value" id="active-requests">0</div>
<div class="stat-label">Active</div>
</div>
<div class="stat-item">
<div class="stat-value" id="queued-requests">0</div>
<div class="stat-label">Queued</div>
</div>
</div>
</div>
<div class="card">
<h3>VRAM Usage</h3>
<div class="progress-bar">
<div class="progress-fill" id="vram-progress" style="width: 0%"></div>
</div>
<p class="text-muted" id="vram-text">0 GB / 0 GB (0%)</p>
</div>
</div>
<div class="card">
<h3>Recent Activity</h3>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Time</th>
<th>Model</th>
<th>Type</th>
<th>Status</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="activity-table">
<tr>
<td colspan="5" class="text-center text-muted">No recent activity</td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function loadStatus() {
try {
const response = await fetch('/admin/api/status');
const data = await response.json();
document.getElementById('backend').textContent = data.backend || 'auto';
document.getElementById('uptime').textContent = data.uptime || '0h 0m';
document.getElementById('status').textContent = data.status === 'ok' ? 'OK' : 'Error';
// Update models loaded count
if (data.models_loaded > 0) {
document.getElementById('active-models').innerHTML =
`<p>${data.models_loaded} model(s) loaded</p>`;
}
} catch (error) {
console.error('Failed to load status:', error);
}
}
async function reloadConfig() {
if (confirm('Reload configuration from disk? This will not restart the server.')) {
try {
const response = await fetch('/admin/api/system/reload', { method: 'POST' });
if (response.ok) {
alert('Configuration reloaded successfully');
loadStatus();
} else {
alert('Failed to reload configuration');
}
} catch (error) {
alert('Error: ' + error.message);
}
}
}
// Load status on page load
loadStatus();
// Refresh status every 5 seconds
setInterval(loadStatus, 5000);
</script>
{% endblock %}
{% extends "base.html" %}
{% block title %}Login - CoderAI{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h1>CoderAI</h1>
<p>Admin Dashboard</p>
</div>
{% if error %}
<div class="alert alert-error">
{{ error }}
</div>
{% endif %}
<form method="post" action="/login" class="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Login</button>
</form>
<div class="login-footer">
<p class="text-muted">Default credentials: admin / admin</p>
</div>
</div>
</div>
{% endblock %}
{% extends "base.html" %}
{% block title %}Models - CoderAI{% endblock %}
{% block content %}
<div class="page-header">
<h1>Models</h1>
<div class="header-actions">
<button class="btn btn-primary" onclick="showDownloadModal()">Download Model</button>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="showTab('local')">Local Models</button>
<button class="tab-btn" onclick="showTab('search')">Search HuggingFace</button>
<button class="tab-btn" onclick="showTab('config')">Configuration</button>
</div>
<div id="tab-local" class="tab-content active">
<div class="card">
<h3>Text Models</h3>
<div id="text-models-list">
<p class="text-muted">No text models configured</p>
</div>
</div>
<div class="card">
<h3>Image Models</h3>
<div id="image-models-list">
<p class="text-muted">No image models configured</p>
</div>
</div>
<div class="card">
<h3>Audio Models</h3>
<div id="audio-models-list">
<p class="text-muted">No audio models configured</p>
</div>
</div>
<div class="card">
<h3>GGUF Models</h3>
<div id="gguf-models-list">
<p class="text-muted">No GGUF models configured</p>
</div>
</div>
</div>
<div id="tab-search" class="tab-content">
<div class="card">
<h3>Search HuggingFace Models</h3>
<div class="search-form">
<input type="text" id="search-query" placeholder="Search models..." class="form-control">
<div class="filter-group">
<label>
<input type="checkbox" id="filter-gguf" checked>
GGUF only
</label>
<label>
<input type="checkbox" id="filter-text" checked>
Text models
</label>
<label>
<input type="checkbox" id="filter-image">
Image models
</label>
</div>
<button class="btn btn-primary" onclick="searchModels()">Search</button>
</div>
<div id="search-results" class="search-results">
<p class="text-muted">Enter a search query to find models</p>
</div>
</div>
</div>
<div id="tab-config" class="tab-content">
<div class="card">
<h3>Model Loading Configuration</h3>
<form id="config-form" class="form">
<div class="form-group">
<label for="load-mode">Load Mode</label>
<select id="load-mode" name="load_mode" class="form-control">
<option value="ondemand">On Demand (default)</option>
<option value="loadall">Load All</option>
<option value="loadswap">Load & Swap</option>
</select>
<small class="form-text">
On Demand: Load one model at a time<br>
Load All: Try to load all models in VRAM<br>
Load & Swap: Keep models in RAM, swap to VRAM as needed
</small>
</div>
<div class="form-group">
<label>Models to Load at Startup</label>
<div id="loaded-models-list">
<p class="text-muted">No models selected</p>
</div>
</div>
<div class="form-group">
<label>Models to Pre-load (RAM)</label>
<div id="preload-models-list">
<p class="text-muted">No models selected</p>
</div>
</div>
<button type="submit" class="btn btn-primary">Save Configuration</button>
</form>
</div>
</div>
<!-- Download Modal -->
<div id="download-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Download Model</h2>
<button class="modal-close" onclick="hideDownloadModal()">&times;</button>
</div>
<div class="modal-body">
<form id="download-form">
<div class="form-group">
<label for="model-id">Model ID or URL</label>
<input type="text" id="model-id" class="form-control"
placeholder="e.g., TheBloke/Llama-2-7B-GGUF" required>
<small class="form-text">HuggingFace model ID or direct URL</small>
</div>
<div class="form-group">
<label for="file-pattern">File Pattern (optional)</label>
<input type="text" id="file-pattern" class="form-control"
placeholder=".gguf">
<small class="form-text">Filter files to download (e.g., .gguf, .safetensors)</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Download</button>
<button type="button" class="btn btn-secondary" onclick="hideDownloadModal()">Cancel</button>
</div>
</form>
<div id="download-progress" class="download-progress" style="display: none;">
<p>Downloading...</p>
<div class="progress-bar">
<div class="progress-fill" id="download-progress-bar"></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function showTab(tabName) {
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.getElementById('tab-' + tabName).classList.add('active');
event.target.classList.add('active');
}
function showDownloadModal() {
document.getElementById('download-modal').style.display = 'flex';
}
function hideDownloadModal() {
document.getElementById('download-modal').style.display = 'none';
}
async function searchModels() {
const query = document.getElementById('search-query').value;
const resultsDiv = document.getElementById('search-results');
if (!query) {
resultsDiv.innerHTML = '<p class="text-muted">Enter a search query</p>';
return;
}
resultsDiv.innerHTML = '<p>Searching...</p>';
// TODO: Implement actual HuggingFace API search
setTimeout(() => {
resultsDiv.innerHTML = '<p class="text-muted">Search functionality coming soon</p>';
}, 1000);
}
document.getElementById('download-form').addEventListener('submit', async (e) => {
e.preventDefault();
const modelId = document.getElementById('model-id').value;
const filePattern = document.getElementById('file-pattern').value;
document.getElementById('download-progress').style.display = 'block';
// TODO: Implement actual download
setTimeout(() => {
alert('Download functionality coming soon');
hideDownloadModal();
document.getElementById('download-progress').style.display = 'none';
}, 1000);
});
// Load models on page load
async function loadModels() {
// TODO: Implement loading models from API
}
loadModels();
</script>
{% endblock %}
{% extends "base.html" %}
{% block title %}API Tokens - CoderAI{% endblock %}
{% block content %}
<div class="page-header">
<h1>API Tokens</h1>
<div class="header-actions">
<button class="btn btn-primary" onclick="showCreateTokenModal()">Create Token</button>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Token</th>
<th>Provider</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tokens-table">
<tr>
<td colspan="6" class="text-center text-muted">No tokens created</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Token Modal -->
<div id="create-token-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Create API Token</h2>
<button class="modal-close" onclick="hideCreateTokenModal()">&times;</button>
</div>
<div class="modal-body">
<form id="create-token-form">
<div class="form-group">
<label for="token-name">Token Name</label>
<input type="text" id="token-name" class="form-control"
placeholder="e.g., Production API" required>
<small class="form-text">A descriptive name for this token</small>
</div>
<div class="form-group">
<label for="token-provider">Provider Format</label>
<select id="token-provider" class="form-control">
<option value="openai">OpenAI (sk-...)</option>
<option value="anthropic">Anthropic (sk-ant-...)</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create Token</button>
<button type="button" class="btn btn-secondary" onclick="hideCreateTokenModal()">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<!-- Show Token Modal -->
<div id="show-token-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Token Created</h2>
<button class="modal-close" onclick="hideShowTokenModal()">&times;</button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>Important:</strong> Copy this token now. You won't be able to see it again!
</div>
<div class="token-display">
<code id="new-token-value"></code>
<button class="btn btn-secondary btn-sm" onclick="copyToken()">Copy</button>
</div>
<div class="form-actions">
<button class="btn btn-primary" onclick="hideShowTokenModal()">Done</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function showCreateTokenModal() {
document.getElementById('create-token-modal').style.display = 'flex';
}
function hideCreateTokenModal() {
document.getElementById('create-token-modal').style.display = 'none';
document.getElementById('create-token-form').reset();
}
function showShowTokenModal(token) {
document.getElementById('new-token-value').textContent = token;
document.getElementById('show-token-modal').style.display = 'flex';
}
function hideShowTokenModal() {
document.getElementById('show-token-modal').style.display = 'none';
}
function copyToken() {
const token = document.getElementById('new-token-value').textContent;
navigator.clipboard.writeText(token).then(() => {
alert('Token copied to clipboard');
});
}
async function loadTokens() {
try {
const response = await fetch('/admin/api/tokens');
const tokens = await response.json();
const tbody = document.getElementById('tokens-table');
if (tokens.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">No tokens created</td></tr>';
return;
}
tbody.innerHTML = tokens.map(token => `
<tr>
<td>${token.name}</td>
<td><code>${token.token.substring(0, 20)}...</code></td>
<td>${token.provider}</td>
<td>${new Date(token.created_at).toLocaleDateString()}</td>
<td>${token.last_used ? new Date(token.last_used).toLocaleDateString() : 'Never'}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="deleteToken(${token.id})">Delete</button>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Failed to load tokens:', error);
}
}
document.getElementById('create-token-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('token-name').value;
const provider = document.getElementById('token-provider').value;
try {
const response = await fetch('/admin/api/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, provider })
});
if (response.ok) {
const data = await response.json();
hideCreateTokenModal();
showShowTokenModal(data.token);
loadTokens();
} else {
alert('Failed to create token');
}
} catch (error) {
alert('Error: ' + error.message);
}
});
async function deleteToken(tokenId) {
if (!confirm('Are you sure you want to delete this token? This cannot be undone.')) {
return;
}
try {
const response = await fetch(`/admin/api/tokens/${tokenId}`, {
method: 'DELETE'
});
if (response.ok) {
loadTokens();
} else {
alert('Failed to delete token');
}
} catch (error) {
alert('Error: ' + error.message);
}
}
loadTokens();
</script>
{% endblock %}
{% extends "base.html" %}
{% block title %}Users - CoderAI{% endblock %}
{% block content %}
<div class="page-header">
<h1>Users</h1>
<div class="header-actions">
<button class="btn btn-primary" onclick="showCreateUserModal()">Create User</button>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>
<span class="badge {% if user.role == 'admin' %}badge-primary{% else %}badge-secondary{% endif %}">
{{ user.role }}
</span>
</td>
<td>{{ user.created_at[:10] }}</td>
<td>
{% if user.username != username %}
<button class="btn btn-danger btn-sm" onclick="deleteUser({{ user.id }}, '{{ user.username }}')">Delete</button>
{% else %}
<a href="/admin/change-password" class="btn btn-secondary btn-sm">Change Password</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Create User Modal -->
<div id="create-user-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Create User</h2>
<button class="modal-close" onclick="hideCreateUserModal()">&times;</button>
</div>
<div class="modal-body">
<form id="create-user-form">
<div class="form-group">
<label for="new-username">Username</label>
<input type="text" id="new-username" class="form-control" required>
</div>
<div class="form-group">
<label for="new-password">Password</label>
<input type="password" id="new-password" class="form-control" required minlength="8">
<small class="form-text">Minimum 8 characters</small>
</div>
<div class="form-group">
<label for="new-role">Role</label>
<select id="new-role" class="form-control">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create User</button>
<button type="button" class="btn btn-secondary" onclick="hideCreateUserModal()">Cancel</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function showCreateUserModal() {
document.getElementById('create-user-modal').style.display = 'flex';
}
function hideCreateUserModal() {
document.getElementById('create-user-modal').style.display = 'none';
document.getElementById('create-user-form').reset();
}
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
try {
const response = await fetch('/admin/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role })
});
if (response.ok) {
hideCreateUserModal();
location.reload();
} else {
const error = await response.json();
alert('Failed to create user: ' + (error.detail || 'Unknown error'));
}
} catch (error) {
alert('Error: ' + error.message);
}
});
async function deleteUser(userId, username) {
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
return;
}
try {
const response = await fetch(`/admin/api/users/${userId}`, {
method: 'DELETE'
});
if (response.ok) {
location.reload();
} else {
const error = await response.json();
alert('Failed to delete user: ' + (error.detail || 'Unknown error'));
}
} catch (error) {
alert('Error: ' + error.message);
}
}
</script>
{% endblock %}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment