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:
parent
5294e8ef50
commit
8a578fbe8d
77
app.py
77
app.py
@ -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):
|
||||
|
||||
@ -119,6 +119,45 @@ body {
|
||||
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) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
{% if logged_in %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white {% if page == 'users' %}active{% endif %}" href="/users">
|
||||
<i class="bi bi-people"></i> Benutzer
|
||||
@ -57,6 +58,7 @@
|
||||
<i class="bi bi-journal-text"></i> Protokoll
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="p-3 border-top border-secondary">
|
||||
{% if authenticated %}
|
||||
@ -64,6 +66,13 @@
|
||||
{% else %}
|
||||
<small class="text-danger"><i class="bi bi-x-circle"></i> Azure getrennt</small>
|
||||
{% 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>
|
||||
</nav>
|
||||
|
||||
|
||||
@ -2,99 +2,82 @@
|
||||
{% block title %}Dashboard - Busylight{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">Dashboard</h4>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0">Dashboard</h5>
|
||||
<div>
|
||||
<form method="post" action="/api/poll" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-arrow-clockwise"></i> Jetzt abfragen
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary py-0">
|
||||
<i class="bi bi-arrow-clockwise"></i> Abfragen
|
||||
</button>
|
||||
</form>
|
||||
<span class="text-muted ms-2" id="last-update"></span>
|
||||
<small class="text-muted ms-2" id="last-update"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not authenticated %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
Azure ist nicht verbunden. Bitte unter <a href="/settings">Einstellungen</a> konfigurieren und
|
||||
<code>python app.py --auth</code> ausfuehren.
|
||||
<div class="alert alert-warning py-2 mb-3">
|
||||
<small><i class="bi bi-exclamation-triangle"></i> Azure nicht verbunden.
|
||||
<a href="/settings">Einstellungen</a> pruefen.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if devices %}
|
||||
<div class="row g-3">
|
||||
<div class="row g-2">
|
||||
{% 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-header bg-white border-0 pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong>{{ device.name }}</strong>
|
||||
<small class="text-muted">{{ device.hostname }}</small>
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<strong class="small">{{ device.name }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pt-2">
|
||||
{% for seg in device.segments|reverse %}
|
||||
<div class="mb-2">
|
||||
<small class="text-muted d-block mb-1">Segment {{ seg.index }}</small>
|
||||
{% if seg.user %}
|
||||
{% if seg.aida == '-' and seg.teams == '-' %}
|
||||
<div class="segment-bar" style="background: #f0f0f0; border-color: #ccc;">
|
||||
<span class="status-dot me-2" style="background: #ccc;"></span>
|
||||
{{ seg.user }}
|
||||
</div>
|
||||
<small class="text-muted"><i class="bi bi-hourglass-split"></i> Warte auf Status...</small>
|
||||
{% else %}
|
||||
<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.user %}
|
||||
{% if seg.aida == '-' and seg.teams == '-' %}
|
||||
<div class="seg-compact" style="background: #f0f0f0; border-left: 3px solid #ccc;">
|
||||
<span class="seg-name">{{ seg.user }}</span>
|
||||
<small class="text-muted"><i class="bi bi-hourglass-split"></i></small>
|
||||
</div>
|
||||
{% else %}
|
||||
<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 }});">
|
||||
<span class="seg-name">{{ seg.user }}</span>
|
||||
<div class="seg-badges">
|
||||
{% 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' %}
|
||||
<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' %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-house"></i> Homeoffice</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark">{{ seg.aida }}</span>
|
||||
<span class="badge-sm bg-warning text-dark">HO</span>
|
||||
{% endif %}
|
||||
|
||||
{% if 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 %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-microsoft-teams"></i> Abwesend</span>
|
||||
{% elif 'offline' in seg.teams %}
|
||||
<span class="badge bg-secondary"><i class="bi bi-microsoft-teams"></i> Offline</span>
|
||||
<span class="badge-sm bg-warning text-dark">Weg</span>
|
||||
{% 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 %}
|
||||
<span class="badge bg-secondary"><i class="bi bi-microsoft-teams"></i> Nicht im Buero</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>
|
||||
<span class="badge-sm bg-secondary">OoO</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if seg.phone %}
|
||||
<span class="badge bg-danger"><i class="bi bi-telephone-fill"></i> Telefoniert</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark"><i class="bi bi-telephone"></i> Frei</span>
|
||||
<span class="badge-sm bg-danger"><i class="bi bi-telephone-fill"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="segment-bar empty">
|
||||
<i class="bi bi-dash-circle me-2"></i> Nicht zugeordnet
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="seg-compact seg-empty">
|
||||
<small class="text-muted">-- leer --</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@ -112,7 +95,6 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Auto-refresh every 10 seconds
|
||||
setTimeout(() => location.reload(), 10000);
|
||||
document.getElementById('last-update').textContent =
|
||||
'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE');
|
||||
|
||||
48
templates/login.html
Normal file
48
templates/login.html
Normal 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>
|
||||
@ -77,6 +77,24 @@
|
||||
</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-header">
|
||||
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> AIDA Zeiterfassung</h6>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user