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:
Christian Mueller 2026-04-24 13:48:58 +02:00
parent 5294e8ef50
commit 8a578fbe8d
6 changed files with 229 additions and 58 deletions

77
app.py
View File

@ -1,9 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Busylight WLED Controller - Web Application.""" """Busylight WLED Controller - Web Application."""
import hashlib
import json import json
import logging import logging
import os import os
import secrets
import shutil import shutil
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -114,11 +116,64 @@ app = FastAPI(title="Busylight Manager", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates") 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): def _ctx(request, page, **kwargs):
"""Build template context with common variables.""" """Build template context with common variables."""
authenticated = scheduler.graph is not None and scheduler.graph.is_authenticated() 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 ────────────────────────────────────────────────────────────── # ── Dashboard ──────────────────────────────────────────────────────────────
@ -191,6 +246,8 @@ async def dashboard(request: Request):
@app.get("/users", response_class=HTMLResponse) @app.get("/users", response_class=HTMLResponse)
async def users_page(request: Request): async def users_page(request: Request):
redir = _require_login(request)
if redir: return redir
conn = database.get_db() conn = database.get_db()
users = conn.execute(""" users = conn.execute("""
SELECT u.*, a.id as assignment_id SELECT u.*, a.id as assignment_id
@ -262,6 +319,8 @@ async def toggle_user(azure_id: str):
@app.get("/devices", response_class=HTMLResponse) @app.get("/devices", response_class=HTMLResponse)
async def devices_page(request: Request): async def devices_page(request: Request):
redir = _require_login(request)
if redir: return redir
conn = database.get_db() conn = database.get_db()
devices_raw = conn.execute( devices_raw = conn.execute(
"SELECT * FROM wled_devices ORDER BY name, hostname" "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) @app.get("/assignments", response_class=HTMLResponse)
async def assignments_page(request: Request): async def assignments_page(request: Request):
redir = _require_login(request)
if redir: return redir
conn = database.get_db() conn = database.get_db()
assignments = conn.execute(""" assignments = conn.execute("""
SELECT a.id, a.segment, u.display_name, u.azure_id, 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) @app.get("/colors", response_class=HTMLResponse)
async def colors_page(request: Request): async def colors_page(request: Request):
redir = _require_login(request)
if redir: return redir
conn = database.get_db() conn = database.get_db()
rules = conn.execute("SELECT * FROM color_rules ORDER BY status").fetchall() rules = conn.execute("SELECT * FROM color_rules ORDER BY status").fetchall()
conn.close() conn.close()
@ -475,6 +538,8 @@ async def add_color(
@app.get("/settings", response_class=HTMLResponse) @app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request): async def settings_page(request: Request):
redir = _require_login(request)
if redir: return redir
settings = database.get_all_settings() settings = database.get_all_settings()
return templates.TemplateResponse(*_ctx(request, "settings", settings=settings)) return templates.TemplateResponse(*_ctx(request, "settings", settings=settings))
@ -492,6 +557,12 @@ async def update_settings(request: Request):
value = form.get(key, "") value = form.get(key, "")
database.set_setting(key, value) 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 # Update scheduler interval
try: try:
interval = int(form.get("poll_interval", "15")) interval = int(form.get("poll_interval", "15"))
@ -507,6 +578,8 @@ async def update_settings(request: Request):
@app.get("/log", response_class=HTMLResponse) @app.get("/log", response_class=HTMLResponse)
async def log_page(request: Request): async def log_page(request: Request):
redir = _require_login(request)
if redir: return redir
conn = database.get_db() conn = database.get_db()
logs = conn.execute( logs = conn.execute(
"SELECT * FROM activity_log ORDER BY id DESC LIMIT 200" "SELECT * FROM activity_log ORDER BY id DESC LIMIT 200"
@ -544,6 +617,8 @@ async def trigger_poll():
@app.get("/backup", response_class=HTMLResponse) @app.get("/backup", response_class=HTMLResponse)
async def backup_page(request: Request): async def backup_page(request: Request):
redir = _require_login(request)
if redir: return redir
# Portal backups # Portal backups
db_backups = [] db_backups = []
if os.path.exists(BACKUP_DIR): if os.path.exists(BACKUP_DIR):

View File

@ -119,6 +119,45 @@ body {
vertical-align: middle; vertical-align: middle;
} }
/* Compact dashboard */
.seg-compact {
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 3px;
font-size: 0.8rem;
}
.seg-compact .seg-name {
display: block;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.78rem;
}
.seg-compact .seg-badges {
display: flex;
gap: 3px;
flex-wrap: wrap;
margin-top: 2px;
}
.seg-compact.seg-empty {
background: #f8f8f8;
border-left: 3px solid #ddd;
}
.badge-sm {
font-size: 0.65rem;
padding: 1px 5px;
border-radius: 3px;
font-weight: 500;
color: #fff;
display: inline-block;
line-height: 1.4;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {
width: 100%; width: 100%;

View File

@ -22,6 +22,7 @@
<i class="bi bi-speedometer2"></i> Dashboard <i class="bi bi-speedometer2"></i> Dashboard
</a> </a>
</li> </li>
{% if logged_in %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-white {% if page == 'users' %}active{% endif %}" href="/users"> <a class="nav-link text-white {% if page == 'users' %}active{% endif %}" href="/users">
<i class="bi bi-people"></i> Benutzer <i class="bi bi-people"></i> Benutzer
@ -57,6 +58,7 @@
<i class="bi bi-journal-text"></i> Protokoll <i class="bi bi-journal-text"></i> Protokoll
</a> </a>
</li> </li>
{% endif %}
</ul> </ul>
<div class="p-3 border-top border-secondary"> <div class="p-3 border-top border-secondary">
{% if authenticated %} {% if authenticated %}
@ -64,6 +66,13 @@
{% else %} {% else %}
<small class="text-danger"><i class="bi bi-x-circle"></i> Azure getrennt</small> <small class="text-danger"><i class="bi bi-x-circle"></i> Azure getrennt</small>
{% endif %} {% endif %}
<div class="mt-2">
{% if logged_in %}
<a href="/logout" class="text-light small"><i class="bi bi-box-arrow-left"></i> Abmelden</a>
{% else %}
<a href="/login" class="text-light small"><i class="bi bi-box-arrow-in-right"></i> Anmelden</a>
{% endif %}
</div>
</div> </div>
</nav> </nav>

View File

@ -2,99 +2,82 @@
{% block title %}Dashboard - Busylight{% endblock %} {% block title %}Dashboard - Busylight{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Dashboard</h4> <h5 class="mb-0">Dashboard</h5>
<div> <div>
<form method="post" action="/api/poll" class="d-inline"> <form method="post" action="/api/poll" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-primary"> <button type="submit" class="btn btn-sm btn-outline-primary py-0">
<i class="bi bi-arrow-clockwise"></i> Jetzt abfragen <i class="bi bi-arrow-clockwise"></i> Abfragen
</button> </button>
</form> </form>
<span class="text-muted ms-2" id="last-update"></span> <small class="text-muted ms-2" id="last-update"></small>
</div> </div>
</div> </div>
{% if not authenticated %} {% if not authenticated %}
<div class="alert alert-warning"> <div class="alert alert-warning py-2 mb-3">
<i class="bi bi-exclamation-triangle"></i> <small><i class="bi bi-exclamation-triangle"></i> Azure nicht verbunden.
Azure ist nicht verbunden. Bitte unter <a href="/settings">Einstellungen</a> konfigurieren und <a href="/settings">Einstellungen</a> pruefen.</small>
<code>python app.py --auth</code> ausfuehren.
</div> </div>
{% endif %} {% endif %}
{% if devices %} {% if devices %}
<div class="row g-3"> <div class="row g-2">
{% for device in devices %} {% for device in devices %}
<div class="col-md-6 col-lg-4 col-xl-3"> <div class="col-sm-6 col-md-4 col-lg-3 col-xl-2">
<div class="card busylight-card h-100"> <div class="card busylight-card h-100">
<div class="card-header bg-white border-0 pb-0"> <div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center mb-1">
<strong>{{ device.name }}</strong> <strong class="small">{{ device.name }}</strong>
<small class="text-muted">{{ device.hostname }}</small>
</div> </div>
</div>
<div class="card-body pt-2">
{% for seg in device.segments|reverse %} {% for seg in device.segments|reverse %}
<div class="mb-2"> {% if seg.user %}
<small class="text-muted d-block mb-1">Segment {{ seg.index }}</small> {% if seg.aida == '-' and seg.teams == '-' %}
{% if seg.user %} <div class="seg-compact" style="background: #f0f0f0; border-left: 3px solid #ccc;">
{% if seg.aida == '-' and seg.teams == '-' %} <span class="seg-name">{{ seg.user }}</span>
<div class="segment-bar" style="background: #f0f0f0; border-color: #ccc;"> <small class="text-muted"><i class="bi bi-hourglass-split"></i></small>
<span class="status-dot me-2" style="background: #ccc;"></span> </div>
{{ seg.user }} {% else %}
</div> <div class="seg-compact" style="background: rgba({{ seg.r }}, {{ seg.g }}, {{ seg.b }}, 0.15); border-left: 3px solid rgb({{ seg.r }}, {{ seg.g }}, {{ seg.b }});">
<small class="text-muted"><i class="bi bi-hourglass-split"></i> Warte auf Status...</small> <span class="seg-name">{{ seg.user }}</span>
{% else %} <div class="seg-badges">
<div class="segment-bar" style="background: rgba({{ seg.r }}, {{ seg.g }}, {{ seg.b }}, 0.3); border-color: rgb({{ seg.r }}, {{ seg.g }}, {{ seg.b }});">
<span class="status-dot me-2" style="background: rgb({{ seg.r }}, {{ seg.g }}, {{ seg.b }});"></span>
{{ seg.user }}
</div>
<div class="d-flex gap-2 mt-1">
{% if seg.aida == 'anwesend' %} {% if seg.aida == 'anwesend' %}
<span class="badge bg-success"><i class="bi bi-building"></i> Anwesend</span> <span class="badge-sm bg-success">Buero</span>
{% elif seg.aida == 'abwesend' %} {% elif seg.aida == 'abwesend' %}
<span class="badge bg-secondary"><i class="bi bi-door-open"></i> Abwesend</span> <span class="badge-sm bg-secondary">Weg</span>
{% elif seg.aida == 'homeoffice' %} {% elif seg.aida == 'homeoffice' %}
<span class="badge bg-warning text-dark"><i class="bi bi-house"></i> Homeoffice</span> <span class="badge-sm bg-warning text-dark">HO</span>
{% else %}
<span class="badge bg-light text-dark">{{ seg.aida }}</span>
{% endif %} {% endif %}
{% if seg.teams != '-' %} {% if seg.teams != '-' %}
{% if 'available' in seg.teams %} {% if 'available' in seg.teams %}
<span class="badge bg-success"><i class="bi bi-microsoft-teams"></i> Verfuegbar</span> <span class="badge-sm bg-success">Frei</span>
{% elif 'busy' in seg.teams or 'inacall' in seg.teams or 'inameeting' in seg.teams %} {% elif 'busy' in seg.teams or 'inacall' in seg.teams or 'inameeting' in seg.teams %}
<span class="badge bg-danger"><i class="bi bi-microsoft-teams"></i> Beschaeftigt</span> <span class="badge-sm bg-danger">Besetzt</span>
{% elif 'donotdisturb' in seg.teams %} {% elif 'donotdisturb' in seg.teams %}
<span class="badge" style="background:#8b00ff;color:#fff"><i class="bi bi-microsoft-teams"></i> Nicht stoeren</span> <span class="badge-sm" style="background:#8b00ff;color:#fff">DND</span>
{% elif 'away' in seg.teams or 'berightback' in seg.teams %} {% elif 'away' in seg.teams or 'berightback' in seg.teams %}
<span class="badge bg-warning text-dark"><i class="bi bi-microsoft-teams"></i> Abwesend</span> <span class="badge-sm bg-warning text-dark">Weg</span>
{% elif 'offline' in seg.teams %}
<span class="badge bg-secondary"><i class="bi bi-microsoft-teams"></i> Offline</span>
{% elif 'presenting' in seg.teams %} {% elif 'presenting' in seg.teams %}
<span class="badge bg-danger"><i class="bi bi-microsoft-teams"></i> Praesentiert</span> <span class="badge-sm bg-danger">Praes.</span>
{% elif 'offline' in seg.teams or 'inactive' in seg.teams or 'offwork' in seg.teams %}
<span class="badge-sm bg-secondary">Off</span>
{% elif 'outofoffice' in seg.teams %} {% elif 'outofoffice' in seg.teams %}
<span class="badge bg-secondary"><i class="bi bi-microsoft-teams"></i> Nicht im Buero</span> <span class="badge-sm bg-secondary">OoO</span>
{% elif 'inactive' in seg.teams or 'offwork' in seg.teams %}
<span class="badge bg-secondary"><i class="bi bi-microsoft-teams"></i> Inaktiv</span>
{% else %}
<span class="badge bg-light text-dark"><i class="bi bi-microsoft-teams"></i> {{ seg.teams }}</span>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if seg.phone %} {% if seg.phone %}
<span class="badge bg-danger"><i class="bi bi-telephone-fill"></i> Telefoniert</span> <span class="badge-sm bg-danger"><i class="bi bi-telephone-fill"></i></span>
{% else %}
<span class="badge bg-light text-dark"><i class="bi bi-telephone"></i> Frei</span>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% else %}
<div class="segment-bar empty">
<i class="bi bi-dash-circle me-2"></i> Nicht zugeordnet
</div> </div>
{% endif %} {% endif %}
{% else %}
<div class="seg-compact seg-empty">
<small class="text-muted">-- leer --</small>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -112,7 +95,6 @@
{% block scripts %} {% block scripts %}
<script> <script>
// Auto-refresh every 10 seconds
setTimeout(() => location.reload(), 10000); setTimeout(() => location.reload(), 10000);
document.getElementById('last-update').textContent = document.getElementById('last-update').textContent =
'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE'); 'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE');

48
templates/login.html Normal file
View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Busylight</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container">
<div class="row justify-content-center mt-5">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="bi bi-lightbulb-fill text-warning display-4"></i>
<h4 class="mt-2">Busylight</h4>
<small class="text-muted">cynfo GmbH</small>
</div>
{% if error %}
<div class="alert alert-danger py-2">
<small><i class="bi bi-x-circle"></i> Falsches Passwort</small>
</div>
{% endif %}
<form method="post" action="/login">
<input type="hidden" name="next" value="{{ next }}">
<div class="mb-3">
<label class="form-label">Passwort</label>
<input type="password" name="password" class="form-control" autofocus required>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right"></i> Anmelden
</button>
</form>
<div class="text-center mt-3">
<a href="/" class="text-muted small">Zurueck zum Dashboard</a>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -77,6 +77,24 @@
</div> </div>
</div> </div>
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-shield-lock"></i> Zugangsschutz</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Admin-Passwort</label>
<input type="password" name="admin_password" class="form-control"
placeholder="Neues Passwort eingeben (leer = nicht aendern)">
{% if settings.admin_password %}
<small class="text-success"><i class="bi bi-check-circle"></i> Passwort ist gesetzt</small>
{% else %}
<small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Kein Passwort gesetzt - alle Seiten sind frei zugaenglich</small>
{% endif %}
</div>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> AIDA Zeiterfassung</h6> <h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> AIDA Zeiterfassung</h6>