feat: compact dashboard + login system for admin pages
Dashboard: - Much smaller cards (col-xl-2), compact badges - Short labels (Buero, HO, DND, OoO, etc.) - Optimized for small screens Login: - Password-protected admin pages (all except dashboard) - Dashboard remains public (read-only status view) - Login page with redirect back to requested page - Session cookie (24h, regenerated on restart) - Password set via Settings page (SHA256 hashed) - Sidebar shows only Dashboard when not logged in - Login/Logout link in sidebar footer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Busylight WLED Controller - Web Application."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -114,11 +116,64 @@ app = FastAPI(title="Busylight Manager", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
SESSION_TOKEN = secrets.token_hex(32) # Regenerated on restart
|
||||
|
||||
|
||||
def _hash_password(pw):
|
||||
return hashlib.sha256(pw.encode()).hexdigest()
|
||||
|
||||
|
||||
def _is_logged_in(request):
|
||||
return request.cookies.get("session") == SESSION_TOKEN
|
||||
|
||||
|
||||
def _require_login(request):
|
||||
"""Redirect to login if not authenticated. Returns RedirectResponse or None."""
|
||||
if not _is_logged_in(request):
|
||||
# Check if a password is set
|
||||
admin_pw = database.get_setting("admin_password", "")
|
||||
if admin_pw:
|
||||
return RedirectResponse(f"/login?next={request.url.path}", status_code=303)
|
||||
return None
|
||||
|
||||
|
||||
def _ctx(request, page, **kwargs):
|
||||
"""Build template context with common variables."""
|
||||
authenticated = scheduler.graph is not None and scheduler.graph.is_authenticated()
|
||||
return request, f"{page}.html", {"page": page, "authenticated": authenticated, **kwargs}
|
||||
logged_in = _is_logged_in(request)
|
||||
return request, f"{page}.html", {"page": page, "authenticated": authenticated, "logged_in": logged_in, **kwargs}
|
||||
|
||||
|
||||
# ── Login ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
next_url = request.query_params.get("next", "/")
|
||||
return templates.TemplateResponse(request, "login.html", {
|
||||
"page": "login", "next": next_url,
|
||||
"error": request.query_params.get("error"),
|
||||
})
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
async def login_submit(
|
||||
request: Request,
|
||||
password: str = Form(...),
|
||||
next: str = Form("/"),
|
||||
):
|
||||
admin_pw = database.get_setting("admin_password", "")
|
||||
if admin_pw and _hash_password(password) == admin_pw:
|
||||
response = RedirectResponse(next, status_code=303)
|
||||
response.set_cookie("session", SESSION_TOKEN, httponly=True, max_age=86400)
|
||||
return response
|
||||
return RedirectResponse("/login?error=1&next=" + next, status_code=303)
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout():
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
response.delete_cookie("session")
|
||||
return response
|
||||
|
||||
|
||||
# ── Dashboard ──────────────────────────────────────────────────────────────
|
||||
@@ -191,6 +246,8 @@ async def dashboard(request: Request):
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse)
|
||||
async def users_page(request: Request):
|
||||
redir = _require_login(request)
|
||||
if redir: return redir
|
||||
conn = database.get_db()
|
||||
users = conn.execute("""
|
||||
SELECT u.*, a.id as assignment_id
|
||||
@@ -262,6 +319,8 @@ async def toggle_user(azure_id: str):
|
||||
|
||||
@app.get("/devices", response_class=HTMLResponse)
|
||||
async def devices_page(request: Request):
|
||||
redir = _require_login(request)
|
||||
if redir: return redir
|
||||
conn = database.get_db()
|
||||
devices_raw = conn.execute(
|
||||
"SELECT * FROM wled_devices ORDER BY name, hostname"
|
||||
@@ -366,6 +425,8 @@ async def check_single_device(device_id: int):
|
||||
|
||||
@app.get("/assignments", response_class=HTMLResponse)
|
||||
async def assignments_page(request: Request):
|
||||
redir = _require_login(request)
|
||||
if redir: return redir
|
||||
conn = database.get_db()
|
||||
assignments = conn.execute("""
|
||||
SELECT a.id, a.segment, u.display_name, u.azure_id,
|
||||
@@ -419,6 +480,8 @@ async def delete_assignment(assignment_id: int):
|
||||
|
||||
@app.get("/colors", response_class=HTMLResponse)
|
||||
async def colors_page(request: Request):
|
||||
redir = _require_login(request)
|
||||
if redir: return redir
|
||||
conn = database.get_db()
|
||||
rules = conn.execute("SELECT * FROM color_rules ORDER BY status").fetchall()
|
||||
conn.close()
|
||||
@@ -475,6 +538,8 @@ async def add_color(
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
redir = _require_login(request)
|
||||
if redir: return redir
|
||||
settings = database.get_all_settings()
|
||||
return templates.TemplateResponse(*_ctx(request, "settings", settings=settings))
|
||||
|
||||
@@ -492,6 +557,12 @@ async def update_settings(request: Request):
|
||||
value = form.get(key, "")
|
||||
database.set_setting(key, value)
|
||||
|
||||
# Admin password
|
||||
new_pw = form.get("admin_password", "")
|
||||
if new_pw:
|
||||
database.set_setting("admin_password", _hash_password(new_pw))
|
||||
logger.info("Admin-Passwort geaendert")
|
||||
|
||||
# Update scheduler interval
|
||||
try:
|
||||
interval = int(form.get("poll_interval", "15"))
|
||||
@@ -507,6 +578,8 @@ async def update_settings(request: Request):
|
||||
|
||||
@app.get("/log", response_class=HTMLResponse)
|
||||
async def log_page(request: Request):
|
||||
redir = _require_login(request)
|
||||
if redir: return redir
|
||||
conn = database.get_db()
|
||||
logs = conn.execute(
|
||||
"SELECT * FROM activity_log ORDER BY id DESC LIMIT 200"
|
||||
@@ -544,6 +617,8 @@ async def trigger_poll():
|
||||
|
||||
@app.get("/backup", response_class=HTMLResponse)
|
||||
async def backup_page(request: Request):
|
||||
redir = _require_login(request)
|
||||
if redir: return redir
|
||||
# Portal backups
|
||||
db_backups = []
|
||||
if os.path.exists(BACKUP_DIR):
|
||||
|
||||
Reference in New Issue
Block a user