Info Tab und MFA

This commit is contained in:
Erik Thiele
2026-06-28 15:14:06 +02:00
parent 1243910d4b
commit 297b52e50c
15 changed files with 5203 additions and 6363 deletions

View File

@@ -65,10 +65,33 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
| Route | Beschreibung |
|-------|-------------|
| `GET /admin/users` | User-Liste (Admin, mit MFA-Spalte) |
| `GET /admin/users/create` | User anlegen (Formular) |
| `POST /admin/users/create` | User anlegen |
| `GET /admin/users/edit/<email>` | User bearbeiten (Formular) |
| `POST /admin/users/edit/<email>` | User bearbeiten speichern |
| `POST /admin/users/delete/<email>` | User löschen (letzter Admin geschützt) |
| `POST /admin/users/reset-password/<email>` | Passwort-Reset (temporäres Passwort) |
| `POST /admin/users/edit/<email>` | User bearbeiten speichern |
| `GET /mfa/setup` | MFA einrichten (QR-Code + Verifikation) |
| `POST /mfa/setup` | TOTP-Code verifizieren → MFA aktivieren |
| `GET /mfa/verify` | TOTP-Code nach Login eingeben |
| `POST /mfa/verify` | Code prüfen oder Recovery-Code verwenden |
| `POST /mfa/disable` | MFA deaktivieren (mit Passwort) |
### MFA (Multi-Faktor-Authentifizierung)
- TOTP via `pyotp`, QR-Code via `qrcode[pil]` (nur in `.venv` installiert)
- Der TOTP-Secret wird sofort beim ersten Aufruf von `/mfa/setup` (GET) in `users.json` persistiert, sodass die nachfolgende POST-Verifikation denselben Secret verwendet
- Nach erfolgreicher Aktivierung: 8 Recovery-Codes (hex, 7 Stellen), einmalig im UI angezeigt, jeder nur einmal verwendbar
- Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung
- Deaktivierung via `/mfa/disable` mit Passwort-Bestätigung
- MFA-Status in der Userliste (`/admin/users`) als grünes "Aktiv"-Badge oder ""
### MFA-Login-Flow
1. POST `/login` mit Passwort → bei aktivem MFA: Session `mfa_pending` setzen → redirect `/mfa/verify`
2. GET/POST `/mfa/verify` → TOTP-Code prüfen → `login_user()` aufrufen → redirect `/admin/<site>`
3. Recovery-Code: POST `/mfa/verify` mit `recovery=true` → Code prüfen → `mfa_enabled=False` → Login erlauben, aber User muss MFA neu einrichten
### Zugriffs-Dekoratoren
@@ -106,13 +129,14 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- Neue Standorte können über den `+`-Button im Header oder über `GET /add-site?name=<name>` angelegt werden (nur Admins).
- `delete_site` entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv (nur Admins).
- `config.json["server_url"]` (z.B. `http://signage.ccmake.de`) wird in der Admin-Ansicht für die Player-URLs verwendet.
- Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Digital Voice Agent** (4), **Medien** (5); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**.
- Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Digital Voice Agent** (4), **Medien** (5), **Info** (6); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**.
- `stay_on_first`: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf).
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien.
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info.
- Player-URL im Screen-Header ist ein klickbarer Link in grauer Farbe.
- `add_customer` und `/customer` erfordern jetzt Login (`@login_required`) mit Site-Zugriffsprüfung.
- `/admin` redirectet zum ersten für den User zugänglichen Standort (nicht mehr global ersten).
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, Userverwaltung (Admin), Abmelden.
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.
- Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen
## Repo Quirks
@@ -120,7 +144,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
- The app has no configured tests, lint, typecheck, formatter, or CI.
- Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `playlist_item_enabled()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`.
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `playlist_item_enabled()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`.
- Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`.
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
@@ -142,6 +166,7 @@ Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermitte
| **Server zeichnet Heartbeat auf** | `record_heartbeat(site, screen)` erstellt/updated `/tmp/signage-heartbeat/<site>/<screen>` via `path.touch()` |
| **Admin-UI fragt Status ab** | `screen_is_active(site, screen)` prüft ob `mtime < 60s` alt |
| **Anzeige** | Grüner "Online"-Button (filled success) oder roter "Offline"-Button (filled danger) im Card-Header neben "Löschen" |
| **Client-Info** | Beim Hovern über den Online-Button: Tooltip mit Browser, Auflösung, IP, letztem Seen-Zeitpunkt |
### Helper-Funktionen in `app.py`
@@ -149,23 +174,32 @@ Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermitte
HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
HEARTBEAT_TIMEOUT = 60 # Sekunden
def record_heartbeat(site, screen):
path = HEARTBEAT_DIR / site / screen
def record_heartbeat(site, screen, ip="", ua="", resolution=""):
"""Schreibt JSON mit Client-Info (ip, ua, resolution, last_seen)."""
path = HEARTBEAT_DIR / site / f"{screen}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
data = {"ip": ip, "ua": ua[:200], "resolution": resolution,
"last_seen": datetime.now().strftime("%d.%m.%Y %H:%M:%S")}
path.write_text(json.dumps(data))
def screen_is_active(site, screen):
path = HEARTBEAT_DIR / site / screen
"""Prüft, ob Heartbeat < 60s alt ist (anhand .json-mtime)."""
path = HEARTBEAT_DIR / site / f"{screen}.json"
if not path.exists():
return False
return (time.time() - path.stat().st_mtime) < HEARTBEAT_TIMEOUT
return (time.time() - path.stat().st_mtime) < 60
def get_client_info(site, screen):
"""Gibt das gespeicherte Client-Info-JSON zurück."""
...
```
### Integration
- `playlist_hash()`-Route ruft `record_heartbeat()` direkt am Anfang auf
- `admin()`-Route setzt `screen_status["online"/"offline"]` via `screen_is_active()` für jeden Screen
- Admin-Template (`admin.html`) zeigt beide Buttons als `<button class="btn btn-sm btn-success/btn-danger" onclick="return false;">`
- `playlist_hash()`-Route liest Query-Parameter `w` (Breite) und `h` (Höhe) + `remote_addr` + `User-Agent` und übergibt sie an `record_heartbeat()`
- `admin()`-Route sammelt `client_info[screen] = get_client_info(...)` für jeden Screen
- Admin-Template (`admin.html`) zeigt den Online-Button mit `title`-Tooltip (Browser, Auflösung, IP, letztes Update) und den Info-Tab mit Client-Info-Tabelle
- Player (`player.html`) sendet `screen.width` und `screen.height` als `?w=...&h=...` im `checkForUpdates`-Fetch
- Die Lösch-Buttons (`btn-outline-danger btn-sm`) haben gleiche Größe durch gleiche btn-sm-Klasse, Form-Wrapper hat `m-0 p-0`
### Docker-Hinweis

View File

@@ -18,11 +18,13 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- **Digital Voice Agent**: global konfigurierbarer Voice-Agent-Button pro Screen (9 Positionen, iframe-Overlay mit positionsgetreuem Zurück-Button, Typewriter-Tagline mit mehrsprachigen Wechseltexten, optionales Bild über dem Button)
- **Stay-on-First**: Screen kann auf erstem Playlist-Element stehen bleiben (kein automatischer Refresh)
- **Online/Offline-Status**: Player-Status wird via dateibasiertem Heartbeat ermittelt grün (online) bei aktivem Player, rot (offline) nach 60s ohne Poll
- **Client-Info**: Tooltip beim Hovern über den Online-Button zeigt Browser, Bildschirmauflösung, IP-Adresse und letzten Heartbeat-Zeitpunkt; Info-Tab pro Screen zeigt detaillierte Client-Info-Tabelle
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung
- **Admin Dashboard**: `/admin/dashboard` Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien (Tabler Tabs)
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
- Priority-Seite ebenfalls mit Tabs: Playlist und Medien
- Dark Mode (localStorage-persistiert)
- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot)
@@ -42,7 +44,7 @@ Flask App (Server)
├── config.json
├── users.json
├── history.json
├── /tmp/signage-heartbeat/ (Heartbeat-Dateien für Online-Status)
├── /tmp/signage-heartbeat/ (Heartbeat-JSONs mit Client-Info für Online-Status)
├── media/
│ ├── <site>/
│ │ ├── <screen>/
@@ -86,7 +88,10 @@ signage/
│ ├── user_list.html # User-Liste (Admin)
│ ├── user_create.html # User anlegen (Admin)
│ ├── user_edit.html # User bearbeiten (Admin)
│ ├── _header.html # Gemeinsamer Header
│ ├── mfa_setup.html # MFA einrichten (QR-Code + Deaktivierung)
│ ├── mfa_verify.html # TOTP-Code nach Login eingeben
│ ├── mfa_recovery.html # Einmalige Recovery-Codes-Anzeige
│ ├── _header.html # Gemeinsamer Header (MFA-Link + Aktiv-Badge)
│ ├── _footer.html # Gemeinsamer Footer
│ └── _styles.html # Zentrale CSS (Variablen, Dark Mode)
├── static/
@@ -194,6 +199,11 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `GET /admin/users` | User-Liste (Admin) |
| `GET /admin/users/create` | User anlegen (Admin) |
| `GET /admin/users/edit/<email>` | User bearbeiten (Admin) |
| `GET /mfa/setup` | MFA einrichten / deaktivieren (QR-Code + Verifikation) |
| `POST /mfa/setup` | TOTP-Code verifizieren und MFA aktivieren |
| `GET /mfa/verify` | TOTP-Code nach Login eingeben |
| `POST /mfa/verify` | TOTP-Code prüfen oder Recovery-Code verwenden |
| `POST /mfa/disable` | MFA deaktivieren (mit Passwort-Bestätigung) |
| `POST /admin/users/create` | User anlegen (Admin) |
| `POST /admin/users/delete/<email>` | User löschen (Admin, letzter Admin geschützt) |
| `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) |
@@ -204,11 +214,12 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
### Heartbeat / Online-Status
Der Player ruft alle 5s `/playlist/<site>/<screen>/hash` auf. Der Server schreibt dabei einen Timestamp nach `/tmp/signage-heartbeat/<site>/<screen>`. Im Admin-Dashboard wird geprüft, ob dieser Timestamp < 60s alt ist → grüner "Online"- oder roter "Offline"-Button pro Screen.
Der Player ruft alle 5s `/playlist/<site>/<screen>/hash?w=<breite>&h=<höhe>` auf. Der Server speichert dabei ein JSON mit Client-Info (IP, User-Agent, Auflösung) nach `/tmp/signage-heartbeat/<site>/<screen>.json`. Im Admin-Dashboard wird geprüft, ob der Timestamp < 60s alt ist → grüner "Online"-Button mit Tooltip (Browser, Auflösung, IP, letztes Update) oder roter "Offline"-Button pro Screen.
```bash
# Heartbeat-Dateien anzeigen
ls -la /tmp/signage-heartbeat/*/
cat /tmp/signage-heartbeat/stuttgart/lobby.json
```
Nach einem Server-Neustart werden alle Screens kurz rot, bis die Player wieder pollen (max. 60s).
@@ -219,7 +230,8 @@ Nach einem Server-Neustart werden alle Screens kurz rot, bis die Player wieder p
http://localhost:5005/admin/<standort>
```
- Screens konfigurieren (Tab-basiert: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien)
- Screens konfigurieren (Tab-basiert: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info)
- Info-Tab pro Screen zeigt Client-Info-Tabelle: IP-Adresse, Browser (User-Agent), Bildschirmauflösung und letzter Heartbeat-Zeitpunkt
- Medien hochladen / löschen
- Playlist per Drag & Drop sortieren
- Priority-Playlist verwalten (ebenfalls mit Tabs)
@@ -228,7 +240,7 @@ http://localhost:5005/admin/<standort>
- Digital Voice Agent pro Screen konfigurieren (Position, Bild ein/aus, Typewriter-Tagline)
- Stay-on-First-Modus pro Screen (kein automatischer Refresh)
- Standorte anlegen & löschen
- **Userverwaltung**: User anlegen, bearbeiten, löschen, Passwort-Reset (temporäres Passwort wird angezeigt)
- **Userverwaltung**: User anlegen, bearbeiten, löschen, Passwort-Reset (temporäres Passwort wird angezeigt), MFA-Status pro User in der Liste (grünes "Aktiv"-Badge oder "")
### Admin Dashboard
@@ -281,12 +293,20 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
- `users.json` wird beim ersten Start aus `config.json.admin` befüllt
- **Notfall**: `users.json` löschen → Server-Neustart erzeugt neuen Admin aus `config.json`
### User-Felder (`users.json`)
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| `server_url` | string | Öffentliche Server-URL für Player-Links |
| `voice_agent_url` | string | URL des Digital Voice Agents (global, read-only im Admin) |
## Konfiguration (`config.json`)
| `password_hash` | string | scrypt-Hash des Passworts |
| `role` | string | `admin`, `superuser` oder `user` |
| `sites` | array | Freigegebene Standorte (nur für User-Rolle) |
| `must_change_password` | bool | Erzwingt Passwort-Änderung beim nächsten Login |
| `first_name` / `last_name` | string | Vor- und Nachname |
| `department` | string | Abteilung |
| `notes` | string | Interne Notizen |
| `mfa_enabled` | bool | MFA aktiv (TOTP) |
| `mfa_secret` | string | TOTP-Secret (sofort beim ersten GET `/mfa/setup` persistiert) |
| `mfa_recovery_codes` | array | 8 Recovery-Codes (hex, 7 Stellen, einmalig verwendbar) |
```json
{
@@ -370,8 +390,9 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
- `load_users()` / `save_users()` / `get_user()` User aus `users.json`
- `init_user_db()` initialen Admin aus `config.json` anlegen
- `get_accessible_sites(cfg, user)` Standorte nach User-Berechtigung filtern
- `record_heartbeat(site, screen)` Heartbeat-Datei schreiben (wird vom Player-Poll getriggert)
- `record_heartbeat(site, screen, ip, ua, resolution)` Heartbeat-JSON mit Client-Info schreiben
- `screen_is_active(site, screen)` Prüft ob Heartbeat < 60s alt ist
- `get_client_info(site, screen)` Gibt gespeicherte Client-Info zurück (Dict mit ip, ua, resolution, last_seen)
- `admin_required` / `site_access_required` Zugriffs-Dekoratoren
---
@@ -385,6 +406,8 @@ Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Heade
## Sicherheit
- Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort)
- Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy)
- 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt
- Passwörter gehasht (scrypt) in `users.json`
- Drei Rollen: Admin (alle Standorte + Userverwaltung), Superuser (alle Standorte), User (nur zugewiesene Standorte)
- Zugriffskontrolle per Decorator (`@admin_required`, `@site_access_required`)

234
app.py
View File

@@ -13,8 +13,14 @@ import json
import hashlib
import re
import time
import io
import base64
import secrets
import generate_welcome_page # Logo-Suche und Willkommensseite-Generierung
import pyotp
import qrcode
from functools import wraps
from datetime import datetime
from pathlib import Path
@@ -22,7 +28,7 @@ from pathlib import Path
from flask import (
Flask, jsonify, render_template,
send_from_directory, redirect,
request, abort
request, abort, flash, session
)
from flask_login import (
LoginManager, login_user, current_user,
@@ -42,12 +48,35 @@ CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
HISTORY_FILE = os.path.join(BASE_DIR, "history.json") # Aktivitätsverlauf
APP_VERSION = "6.0.3"
APP_VERSION = "6.1.0"
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
app = Flask(__name__)
app.secret_key = "CHANGE_THIS_SECRET!!!"
@app.context_processor
def inject_globals():
"""Stellt globale Template-Variablen für Header/Footer bereit."""
import os as _os
hostname = _os.uname().nodename
cfg = load_config()
from flask_login import current_user
if current_user.is_authenticated:
site_list = get_accessible_sites(cfg, current_user)
current_site_val = get_site_list(cfg)[0] if get_site_list(cfg) else ""
else:
site_list = get_site_list(cfg)
current_site_val = site_list[0] if site_list else ""
return dict(
site_list=site_list,
current_site=current_site_val,
brand_subtitle="",
site_dropdown_url_prefix="/admin/",
version=APP_VERSION,
year=datetime.now().year,
hostname=hostname
)
login_manager = LoginManager(app)
login_manager.login_view = "login" # Nicht eingeloggte User werden zum Login umgeleitet
@@ -76,21 +105,38 @@ HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
HEARTBEAT_TIMEOUT = 60 # Sekunden
def record_heartbeat(site, screen):
"""Aktualisiert den Heartbeat-Timestamp für einen Screen."""
path = HEARTBEAT_DIR / site / screen
def record_heartbeat(site, screen, ip="", ua="", resolution=""):
"""Aktualisiert den Heartbeat-Timestamp für einen Screen und speichert Client-Info."""
path = HEARTBEAT_DIR / site / f"{screen}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
data = {
"ip": ip,
"ua": ua[:200] if ua else "",
"resolution": resolution,
"last_seen": datetime.now().strftime("%d.%m.%Y %H:%M:%S")
}
path.write_text(json.dumps(data))
def screen_is_active(site, screen):
"""Prüft, ob ein Screen zuletzt innerhalb des Timeouts registriert wurde."""
path = HEARTBEAT_DIR / site / screen
path = HEARTBEAT_DIR / site / f"{screen}.json"
if not path.exists():
return False
return (time.time() - path.stat().st_mtime) < HEARTBEAT_TIMEOUT
def get_client_info(site, screen):
"""Gibt die gespeicherten Client-Informationen eines Screens zurück."""
path = HEARTBEAT_DIR / site / f"{screen}.json"
if not path.exists():
return {}
try:
return json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
return {}
def get_site_list(cfg):
"""Gibt eine sortierte Liste aller konfigurierten Standort-Namen zurück."""
return sorted(cfg.get("sites", {}).keys())
@@ -293,7 +339,8 @@ class User(UserMixin):
def __init__(self, email, password_hash="", role="user", sites=None,
must_change_password=False, first_name="", last_name="",
department="", notes=""):
department="", notes="", mfa_enabled=False, mfa_secret="",
mfa_recovery_codes=None):
self.email = email
self.id = email
self.password_hash = password_hash
@@ -304,6 +351,9 @@ class User(UserMixin):
self.last_name = last_name
self.department = department
self.notes = notes
self.mfa_enabled = mfa_enabled
self.mfa_secret = mfa_secret
self.mfa_recovery_codes = mfa_recovery_codes or []
@property
def is_admin(self):
@@ -425,6 +475,7 @@ def login():
if request.method == "POST":
email = request.form.get("username", "").strip().lower()
password = request.form.get("password", "")
mfa_code = request.form.get("mfa_code", "")
user = get_user(email)
if user:
@@ -438,6 +489,31 @@ def login():
user.must_change_password = True
if valid:
if user.mfa_enabled:
if mfa_code:
totp = pyotp.TOTP(user.mfa_secret)
if totp.verify(mfa_code):
login_user(user)
add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA)", email)
if user.must_change_password:
return redirect("/change-password")
return redirect("/admin")
# Recovery-Code versuchen
if mfa_code in user.mfa_recovery_codes:
codes = user.mfa_recovery_codes[:]
codes.remove(mfa_code)
users_data = load_users()
users_data[email]["mfa_recovery_codes"] = codes
save_users(users_data)
login_user(user)
add_history_entry("user_login", f"User '{email}' angemeldet (mit Recovery-Code)", email)
return redirect("/mfa/setup?recovered=1")
error = "Ungültiger Sicherheitscode"
else:
session["mfa_user"] = email
session["mfa_password_ok"] = True
return redirect("/mfa/verify")
else:
login_user(user)
add_history_entry("user_login", f"User '{email}' angemeldet", email)
if user.must_change_password:
@@ -461,6 +537,134 @@ def logout():
return redirect("/login")
# -------------------------------------------------
# MFA (Multi-Faktor-Authentifizierung per TOTP)
# -------------------------------------------------
@app.route("/mfa/verify", methods=["GET", "POST"])
def mfa_verify():
"""TOTP-Code nach erfolgreichem Login (Passwort) eingeben."""
email = session.get("mfa_user")
if not email:
return redirect("/login")
if request.method == "POST":
code = request.form.get("code", "").strip()
action = request.form.get("action", "")
user = get_user(email)
if not user:
session.pop("mfa_user", None)
return redirect("/login")
if action == "recovery":
return render_template("login.html", error=None, show_mfa=True, mfa_user=email, recovery_mode=True)
totp = pyotp.TOTP(user.mfa_secret)
if code and totp.verify(code):
login_user(user)
session.pop("mfa_user", None)
add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA)", email)
if user.must_change_password:
return redirect("/change-password")
return redirect("/admin")
# Recovery-Code prüfen
if code in user.mfa_recovery_codes:
codes = user.mfa_recovery_codes[:]
codes.remove(code)
users_data = load_users()
users_data[email]["mfa_recovery_codes"] = codes
save_users(users_data)
login_user(user)
session.pop("mfa_user", None)
add_history_entry("user_login", f"User '{email}' angemeldet (mit Recovery-Code)", email)
return redirect("/mfa/setup?recovered=1")
return render_template("mfa_verify.html", error="Ungültiger Code", email=email)
return render_template("mfa_verify.html", error=None, email=email)
@app.route("/mfa/setup", methods=["GET", "POST"])
@login_required
def mfa_setup():
"""MFA einrichten: QR-Code anzeigen, ersten Code verifizieren."""
cfg = load_config()
site_list = get_accessible_sites(cfg, current_user)
email = current_user.email
users_data = load_users()
user_data = users_data.get(email, {})
recovered = request.args.get("recovered")
disabled = request.args.get("disabled")
error_param = request.args.get("error")
secret = user_data.get("mfa_secret") or pyotp.random_base32()
# Secret sofort persistieren, damit GET (QR-Code) und POST (Verifikation) dasselbe Secret verwenden
if not user_data.get("mfa_secret"):
users_data.setdefault(email, {})["mfa_secret"] = secret
save_users(users_data)
totp = pyotp.TOTP(secret)
if request.method == "POST":
code = request.form.get("code", "").strip()
if totp.verify(code):
recovery = [secrets.token_hex(3).upper()[:7] for _ in range(8)]
users_data[email]["mfa_enabled"] = True
users_data[email]["mfa_recovery_codes"] = recovery
save_users(users_data)
add_history_entry("mfa_enabled", f"User '{email}' hat MFA aktiviert", email)
return render_template("mfa_recovery.html", codes=recovery, email=email,
site_list=site_list, current_site=site_list[0] if site_list else "",
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
return render_template("mfa_setup.html", error="Code ungültig. Bitte versuche es erneut.",
email=email, secret=secret, qr_data=None,
site_list=site_list, current_site=site_list[0] if site_list else "",
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
uri = totp.provisioning_uri(name=email, issuer_name="CANCOM Signage")
img = qrcode.make(uri)
buf = io.BytesIO()
img.save(buf, format="PNG")
qr_b64 = base64.b64encode(buf.getvalue()).decode()
return render_template("mfa_setup.html", error=None if not error_param else "Passwort ist falsch",
email=email, secret=secret, qr_data=qr_b64,
recovered=recovered, disabled=disabled,
site_list=site_list, current_site=site_list[0] if site_list else "",
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
@app.route("/mfa/disable", methods=["POST"])
@login_required
def mfa_disable():
"""MFA deaktivieren (mit Passwort-Bestätigung)."""
email = current_user.email
password = request.form.get("password", "")
user = get_user(email)
if user and user.password_hash:
try:
valid = check_password_hash(user.password_hash, password)
except (ValueError, TypeError):
valid = False
else:
valid = False
if not valid:
return redirect("/mfa/setup?error=1")
users_data = load_users()
users_data[email]["mfa_enabled"] = False
users_data[email]["mfa_secret"] = ""
users_data[email]["mfa_recovery_codes"] = []
save_users(users_data)
add_history_entry("mfa_disabled", f"User '{email}' hat MFA deaktiviert", email)
return redirect("/mfa/setup?disabled=1")
# -------------------------------------------------
# Willkommensseite (Kunden-Logos + Generierung)
# -------------------------------------------------
@@ -758,8 +962,12 @@ def player(site, screen):
def playlist_hash(site, screen):
"""Berechnet einen MD5-Hash über die aktuelle Konfiguration (Screen + Priority).
Gibt JSON mit Hash und aktuellem stay_on_first-Wert zurück.
Zeichnet gleichzeitig den Heartbeat auf (Player aktiv)."""
record_heartbeat(site, screen)
Zeichnet gleichzeitig den Heartbeat auf (Player aktiv).
Nimmt optionale Query-Parameter w (Breite) und h (Höhe) für Client-Info."""
ip = request.remote_addr or ""
ua = request.headers.get("User-Agent", "")
resolution = f"{request.args.get('w', '?')}x{request.args.get('h', '?')}"
record_heartbeat(site, screen, ip=ip, ua=ua, resolution=resolution)
config = load_config()
relevant = {
@@ -858,6 +1066,7 @@ def admin(site):
screens = {}
media_files = {}
screen_status = {}
client_info = {}
site_media_base = os.path.join(MEDIA_DIR, current_site)
@@ -944,6 +1153,7 @@ def admin(site):
screens[screen_name] = screen_cfg
media_files[screen_name] = files
screen_status[screen_name] = "online" if screen_is_active(current_site, screen_name) else "offline"
client_info[screen_name] = get_client_info(current_site, screen_name)
return render_template(
"admin.html",
@@ -952,6 +1162,7 @@ def admin(site):
screens=screens,
media_files=media_files,
screen_status=screen_status,
client_info=client_info,
version=APP_VERSION,
year=datetime.now().year,
hostname=os.uname().nodename,
@@ -1427,7 +1638,8 @@ def admin_users():
"first_name": data.get("first_name", ""),
"last_name": data.get("last_name", ""),
"department": data.get("department", ""),
"notes": data.get("notes", "")
"notes": data.get("notes", ""),
"mfa_enabled": data.get("mfa_enabled", False)
})
return render_template(

View File

@@ -1,4 +1,268 @@
[
{
"timestamp": "2026-06-28 15:12:39",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 15:08:14",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 15:04:00",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 15:00:36",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 15:00:15",
"action": "user_logout",
"detail": "User 'admin' abgemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:59:59",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:59:34",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:57:59",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:55:28",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:55:09",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:53:29",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:52:57",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:52:52",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:52:45",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:49:44",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:49:08",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:48:51",
"action": "mfa_enabled",
"detail": "User 'erik.thiele@cancom.de' hat MFA aktiviert",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:46:23",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:46:19",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:45:59",
"action": "mfa_disabled",
"detail": "User 'erik.thiele@cancom.de' hat MFA deaktiviert",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:42:00",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:35:45",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:33:52",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:33:35",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:32:41",
"action": "mfa_enabled",
"detail": "User 'erik.thiele@cancom.de' hat MFA aktiviert",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:31:07",
"action": "user_login",
"detail": "User 'admin' angemeldet (mit MFA)",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:30:25",
"action": "mfa_enabled",
"detail": "User 'admin' hat MFA aktiviert",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:30:25",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:29:49",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:17:41",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:17:35",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 14:12:52",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:11:41",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:10:12",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 14:00:10",
"action": "user_login",
"detail": "User 'admin' angemeldet (mit MFA)",
"user": "admin"
},
{
"timestamp": "2026-06-28 13:59:43",
"action": "user_login",
"detail": "User 'admin' angemeldet (mit MFA)",
"user": "admin"
},
{
"timestamp": "2026-06-28 13:59:16",
"action": "user_login",
"detail": "User 'admin' angemeldet (mit MFA)",
"user": "admin"
},
{
"timestamp": "2026-06-28 13:58:30",
"action": "mfa_enabled",
"detail": "User 'admin' hat MFA aktiviert",
"user": "admin"
},
{
"timestamp": "2026-06-28 13:58:30",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 13:58:23",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 13:57:59",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 13:28:44",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-28 13:25:50",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-28 13:25:28",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-06-26 14:49:09",
"action": "user_login",

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@
<!-- Aktionen: Standort-Dropdown, +Button, Theme, Logout -->
<div class="navbar-nav flex-row order-md-last top-actions">
<!-- Standort-Auswahl -->
{% if current_user.is_authenticated %}
<div class="dropdown me-2">
<a class="btn btn-outline-secondary" href="#" data-bs-toggle="dropdown">
<i class="ti ti-building me-1"></i>{{ current_site | capitalize }} <i class="ti ti-chevron-down"></i>
@@ -34,6 +35,7 @@
onclick="var n=prompt('Name des neuen Standorts:'); if(n&&n.trim()) location.href='/add-site?name='+encodeURIComponent(n.trim().toLowerCase());"
title="Neuen Standort anlegen"
style="display:inline-flex;align-items:center;justify-content:center;min-width:2.5rem;min-height:2.5rem;padding:0;"><i class="ti ti-plus"></i></a>
{% endif %}
<!-- Dark-Mode-Toggle (persistiert in localStorage("signage-theme")) -->
<div>
@@ -50,6 +52,7 @@
</a>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" href="/change-password"><i class="ti ti-key me-1"></i>Passwort ändern</a>
<a class="dropdown-item" href="/mfa/setup"><i class="ti ti-shield me-1"></i>MFA einrichten{% if current_user.mfa_enabled %} <span class="badge bg-green text-green-fg ms-1" style="font-size:0.6rem;">Aktiv</span>{% endif %}</a>
<a class="dropdown-item" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</a>
{% if current_user.is_admin %}
<a class="dropdown-item" href="/admin/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</a>

View File

@@ -176,7 +176,8 @@
</span>
<div class="d-flex align-items-center gap-2">
{% if screen_status[screen] == "online" %}
<button type="button" class="btn btn-sm btn-success" onclick="return false;">Online</button>
<button type="button" class="btn btn-sm btn-success" onclick="return false;"
title="Browser: {{ client_info[screen].get('ua', '?')[:60] }}&#10;Auflösung: {{ client_info[screen].get('resolution', '?') }}&#10;IP: {{ client_info[screen].get('ip', '?') }}&#10;Zuletzt: {{ client_info[screen].get('last_seen', '?') }}">Online</button>
{% else %}
<button type="button" class="btn btn-sm btn-danger" onclick="return false;">Offline</button>
{% endif %}
@@ -188,7 +189,7 @@
</div>
</div>
<!-- Card-Body: Tabs (Playlist | Einstellungen | Aktionen | Digital Voice Agent | Medien) -->
<!-- Card-Body: Tabs (Playlist | Einstellungen | Aktionen | Digital Voice Agent | Medien | Info) -->
<div class="card-body">
<ul class="nav nav-tabs" data-bs-toggle="tabs" role="tablist">
@@ -217,6 +218,11 @@
<i class="ti ti-upload me-1"></i>Medien
</a>
</li>
<li class="nav-item" role="presentation">
<a href="#info-{{ screen }}" class="nav-link" data-bs-toggle="tab" role="tab">
<i class="ti ti-info-circle me-1"></i>Info
</a>
</li>
</ul>
<div class="tab-content mt-3">
@@ -473,6 +479,35 @@
</form>
</div>
<!-- ═══ Tab: Info (Client-Informationen) ═══ -->
<div class="tab-pane" id="info-{{ screen }}" role="tabpanel">
<h3>Client-Informationen</h3>
{% if client_info[screen] %}
<table class="table table-sm">
<tbody>
<tr>
<th style="width:140px">IP-Adresse</th>
<td><code>{{ client_info[screen].get('ip', '') }}</code></td>
</tr>
<tr>
<th>Browser</th>
<td style="word-break:break-all"><small>{{ client_info[screen].get('ua', '') }}</small></td>
</tr>
<tr>
<th>Auflösung</th>
<td>{{ client_info[screen].get('resolution', '') }}</td>
</tr>
<tr>
<th>Zuletzt gesehen</th>
<td>{{ client_info[screen].get('last_seen', '') }}</td>
</tr>
</tbody>
</table>
{% else %}
<p class="text-muted">Noch keine Client-Daten vorhanden. Der Player muss mindestens einmal die Playlist aufgerufen haben.</p>
{% endif %}
</div>
</div> <!-- /tab-content -->
</div> <!-- /card-body -->

View File

@@ -41,9 +41,36 @@
<div class="page-wrapper">
<div class="container-xl mt-4">
<div class="row g-4">
<!-- Sidebar Navigation -->
<div class="col-md-3">
<div class="card" style="position:sticky;top:1rem;">
<div class="card-header"><h4 class="card-title" style="font-size:0.9rem;"><i class="ti ti-menu-2 me-1"></i>Themen</h4></div>
<div class="card-body p-2">
<nav class="nav flex-column">
<a class="nav-link py-1 px-2 small" href="#help-standort"><i class="ti ti-building-community me-1"></i>Standort</a>
<a class="nav-link py-1 px-2 small" href="#help-screen"><i class="ti ti-devices me-1"></i>Screen</a>
<a class="nav-link py-1 px-2 small" href="#help-playlist"><i class="ti ti-list me-1"></i>Playlist</a>
<a class="nav-link py-1 px-2 small" href="#help-priority"><i class="ti ti-player-play me-1"></i>Priority</a>
<a class="nav-link py-1 px-2 small" href="#help-actions"><i class="ti ti-external-link me-1"></i>Action Button</a>
<a class="nav-link py-1 px-2 small" href="#help-voice"><i class="ti ti-robot me-1"></i>Voice Agent</a>
<a class="nav-link py-1 px-2 small" href="#help-einstellungen"><i class="ti ti-settings me-1"></i>Einstellungen</a>
<a class="nav-link py-1 px-2 small" href="#help-info"><i class="ti ti-info-circle me-1"></i>Info-Tab</a>
<a class="nav-link py-1 px-2 small" href="#help-willkommen"><i class="ti ti-users me-1"></i>Willkommen</a>
<a class="nav-link py-1 px-2 small" href="#help-userverwaltung"><i class="ti ti-shield me-1"></i>User</a>
<a class="nav-link py-1 px-2 small" href="#help-dashboard"><i class="ti ti-dashboard me-1"></i>Dashboard</a>
<a class="nav-link py-1 px-2 small" href="#help-darkmode"><i class="ti ti-moon me-1"></i>Dark Mode</a>
<a class="nav-link py-1 px-2 small" href="#help-passwort"><i class="ti ti-key me-1"></i>Passwort</a>
<a class="nav-link py-1 px-2 small" href="#help-mfa"><i class="ti ti-shield-lock me-1"></i>MFA</a>
</nav>
</div>
</div>
</div>
<!-- Main Content -->
<div class="col-md-9">
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-building-community me-1"></i>Standort anlegen</h3></div>
<div class="card-header"><h3 class="card-title" id="help-standort"><i class="ti ti-building-community me-1"></i>Standort anlegen</h3></div>
<div class="card-body">
<p>Ein Standort (Site) gruppiert mehrere Screens, z.&thinsp;B. <code>stuttgart</code> oder <code>karlsruhe</code>.</p>
<ol>
@@ -56,7 +83,7 @@
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-devices me-1"></i>Screen hinzufügen</h3></div>
<div class="card-header"><h3 class="card-title" id="help-screen"><i class="ti ti-devices me-1"></i>Screen hinzufügen</h3></div>
<div class="card-body">
<p>Ein Screen ist ein einzelner Player (z.&thinsp;B. <code>lobby</code>, <code>casino</code>), der eine Playlist abspielt.</p>
<ol>
@@ -69,7 +96,7 @@
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-list me-1"></i>Playlist verwalten</h3></div>
<div class="card-header"><h3 class="card-title" id="help-playlist"><i class="ti ti-list me-1"></i>Playlist verwalten</h3></div>
<div class="card-body">
<p>Jeder Screen hat eine eigene Playlist mit Medien und URLs.</p>
<ul>
@@ -84,7 +111,7 @@
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div>
<div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div>
<div class="card-body">
<p>Die Priority-Playlist ist <strong>global</strong> und wird auf <em>allen</em> Playern eines Standorts eingeblendet zwischen den normalen Playlist-Einträgen.</p>
<ul>
@@ -96,7 +123,7 @@
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-external-link me-1"></i>Action Button (Custom-URL)</h3></div>
<div class="card-header"><h3 class="card-title" id="help-actions"><i class="ti ti-external-link me-1"></i>Action Button (Custom-URL)</h3></div>
<div class="card-body">
<p>Der Action Button ist ein konfigurierbarer Button im Player, der eine frei wählbare URL öffnet.</p>
<ul>
@@ -116,7 +143,7 @@
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-robot me-1"></i>Digital Voice Agent</h3></div>
<div class="card-header"><h3 class="card-title" id="help-voice"><i class="ti ti-robot me-1"></i>Digital Voice Agent</h3></div>
<div class="card-body">
<p>Der Digital Voice Agent zeigt einen Button im Player, der eine Typewriter-Animation mit wechselnden mehrsprachigen Texten anzeigt.</p>
<ul>
@@ -132,7 +159,7 @@
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-settings me-1"></i>Screen-Einstellungen</h3></div>
<div class="card-header"><h3 class="card-title" id="help-einstellungen"><i class="ti ti-settings me-1"></i>Screen-Einstellungen</h3></div>
<div class="card-body">
<p>Im Tab <strong>Einstellungen</strong> können pro Screen konfiguriert werden:</p>
<ul>
@@ -145,7 +172,21 @@
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div>
<div class="card-header"><h3 class="card-title" id="help-info"><i class="ti ti-info-circle me-1"></i>Info-Tab im Screen</h3></div>
<div class="card-body">
<p>Jeder Screen hat einen <strong>Info-Tab</strong> (Tab 6), der detaillierte Client-Informationen zum aktuell verbundenen Player anzeigt:</p>
<ul>
<li><strong>IP-Adresse</strong> des Players</li>
<li><strong>Browser</strong> (User-Agent) des Players</li>
<li><strong>Bildschirmauflösung</strong> (Breite × Höhe)</li>
<li><strong>Zuletzt gesehen</strong> Zeitstempel des letzten Heartbeats</li>
</ul>
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Die Daten werden alle 5 Sekunden vom Player gesendet und im Heartbeat-JSON gespeichert. Der Online-Status (grüner/roter Button im Screen-Header) basiert auf dem gleichen Mechanismus.</p>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title" id="help-willkommen"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div>
<div class="card-body">
<p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p>
<ul>
@@ -159,7 +200,7 @@
{% if current_user.is_admin %}
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-shield me-1"></i>User-Verwaltung (Admin)</h3></div>
<div class="card-header"><h3 class="card-title" id="help-userverwaltung"><i class="ti ti-shield me-1"></i>User-Verwaltung (Admin)</h3></div>
<div class="card-body">
<p>Admins können über <code>/admin/users</code> die User-Verwaltung aufrufen.</p>
<ul>
@@ -179,7 +220,7 @@
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</h3></div>
<div class="card-header"><h3 class="card-title" id="help-dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</h3></div>
<div class="card-body">
<p>Das Admin Dashboard (<code>/admin/dashboard</code>) bietet eine Übersicht über das gesamte System:</p>
<ul>
@@ -193,24 +234,40 @@
{% endif %}
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-moon me-1"></i>Dark Mode</h3></div>
<div class="card-header"><h3 class="card-title" id="help-darkmode"><i class="ti ti-moon me-1"></i>Dark Mode</h3></div>
<div class="card-body">
<p>Der Dark Mode kann über den Mond-Button im Header umgeschaltet werden. Die Einstellung wird im Browser gespeichert (localStorage) und bleibt auch nach Seitenwechsel erhalten.</p>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><i class="ti ti-key me-1"></i>Passwort ändern</h3></div>
<div class="card-header"><h3 class="card-title" id="help-passwort"><i class="ti ti-key me-1"></i>Passwort ändern</h3></div>
<div class="card-body">
<p>Das Passwort kann über das User-Menü (oben rechts) → <strong>Passwort ändern</strong> geändert werden. Nach einem Admin-Reset muss der User beim ersten Login ein neues Passwort vergeben.</p>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title" id="help-mfa"><i class="ti ti-shield-lock me-1"></i>MFA (Multi-Faktor-Authentifizierung)</h3></div>
<div class="card-body">
<p>MFA bietet eine optionale zweite Sicherheitsstufe per TOTP (Google Authenticator, Authy).</p>
<ul>
<li><strong>Aktivierung:</strong> Über das User-Menü → <strong>MFA einrichten</strong> → QR-Code mit einer Authenticator-App scannen → 6-stelligen Code eingeben → Bestätigen</li>
<li><strong>Recovery-Codes:</strong> Nach erfolgreicher Aktivierung werden 8 Recovery-Codes (einmalig) angezeigt. Jeder Code kann nur einmal verwendet werden. Nach Einsatz eines Recovery-Codes muss MFA neu eingerichtet werden.</li>
<li><strong>Login mit MFA:</strong> Nach Eingabe von E-Mail + Passwort erscheint ein zweites Formular für den TOTP-Code. Erst nach erfolgreichem Code wird der Zugriff gewährt.</li>
<li><strong>Deaktivierung:</strong> Über <code>/mfa/setup</code> → Button "MFA deaktivieren" → aktuelles Passwort eingeben</li>
<li><strong>MFA-Status:</strong> In der Userliste (<code>/admin/users</code>) wird der MFA-Status als grünes "Aktiv"-Badge oder "" angezeigt.</li>
</ul>
</div>
</div>
</div>
</div> <!-- /col-md-9 main -->
</div> <!-- /row -->
</div> <!-- /container-xl -->
</div> <!-- /page-wrapper -->
{% include "_footer.html" %}
</div>
</div> <!-- /page -->
</body>
</html>

View File

@@ -2,29 +2,32 @@
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CANCOM Simple Signage Admin</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css" rel="stylesheet">
<style>
body {
background-color: #F4F6F8;
color: #212121;
}
.btn-primary {
background-color: #DA002D;
border-color: #DA002D;
}
.btn-primary:hover {
background-color: #B00024;
border-color: #B00024;
}
</style>
<link rel="stylesheet"
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
{% include "_styles.html" %}
<script>
(() => {
const stored = window.localStorage.getItem("signage-theme");
const theme = stored === "dark" || stored === "light" ? stored : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
})();
</script>
</head>
<body>
<div class="page">
<!-- ─── Login-Seite (zentriert) ─── -->
<body class="d-flex align-items-center justify-content-center" style="height:100vh;">
<div class="card card-md">
{% include "_header.html" %}
<div class="page-wrapper d-flex align-items-center justify-content-center">
<div class="container-xl">
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card card-md">
<div class="card-body">
<h2 class="text-center mb-4">Signage Admin</h2>
@@ -46,6 +49,13 @@
<button class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{% include "_footer.html" %}
</div>
</body>
</html>

View File

@@ -0,0 +1,64 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>MFA eingerichtet CANCOM Signage</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script>
(() => {
const stored = window.localStorage.getItem("signage-theme");
const theme = stored === "dark" || stored === "light" ? stored : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
})();
</script>
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
<script defer src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
{% include '_styles.html' %}
</head>
<body>
{% include '_header.html' %}
<div class="page-wrapper">
<div class="page-body" style="min-height:calc(100vh - 120px);display:flex;align-items:center;justify-content:center;">
<div class="container-xl">
<div class="row justify-content-center">
<div class="col-lg-8 col-xl-6">
<div class="card">
<div class="card-body text-center py-5">
<h2 class="mb-2">✅ MFA aktiviert</h2>
<p class="text-muted mb-3">
Speichere diese Recovery-Codes an einem sicheren Ort.<br>
Jeder Code kann <strong>einmal</strong> verwendet werden.
</p>
<div class="alert alert-warning">
<strong>⚠️ Wichtig:</strong> Diese Codes werden nur <strong>einmal</strong> angezeigt.
</div>
<div class="my-4 d-flex flex-wrap justify-content-center gap-2">
{% for code in codes %}
<kbd style="font-size:1.1rem;padding:8px 16px;border-radius:6px;user-select:all;cursor:pointer;" onclick="navigator.clipboard.writeText(this.textContent);this.textContent='✓ Kopiert!';setTimeout(()=>{this.textContent='{{ code }}';},1500);">{{ code }}</kbd>
{% endfor %}
</div>
<p class="text-muted small mb-4">Klicke auf einen Code, um ihn zu kopieren.</p>
<a href="/admin" class="btn btn-primary">← Zum Admin</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% include '_footer.html' %}
</div>
</body>
</html>

101
templates/mfa_setup.html Normal file
View File

@@ -0,0 +1,101 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>MFA einrichten CANCOM Signage</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script>
(() => {
const stored = window.localStorage.getItem("signage-theme");
const theme = stored === "dark" || stored === "light" ? stored : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
})();
</script>
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
<script defer src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
{% include '_styles.html' %}
</head>
<body>
{% include '_header.html' %}
<div class="page-wrapper">
<div class="page-body" style="min-height:calc(100vh - 120px);display:flex;align-items:center;justify-content:center;">
<div class="container-xl">
<div class="row justify-content-center">
<div class="col-lg-8 col-xl-6">
{% if recovered %}
<div class="alert alert-warning">
<strong>Hinweis:</strong> Du hast einen Recovery-Code verwendet. Bitte richte MFA neu ein.
</div>
{% endif %}
{% if disabled %}
<div class="alert alert-info">MFA wurde deaktiviert.</div>
{% endif %}
<div class="card">
<div class="card-body text-center py-5">
{% if current_user.mfa_enabled %}
<h2 class="mb-2">✅ MFA ist aktiv</h2>
<p class="text-muted mb-4">Dein Konto ist durch Zwei-Faktor-Authentifizierung geschützt.</p>
<p class="text-muted small mb-2">Möchtest du MFA deaktivieren? Gib dein Passwort ein:</p>
<form method="post" action="/mfa/disable" class="row justify-content-center">
<div class="col-auto">
<div class="input-group">
<input type="password" name="password" class="form-control" placeholder="Passwort" required>
<button class="btn btn-danger" type="submit">Deaktivieren</button>
</div>
</div>
</form>
{% else %}
<h2 class="mb-2">MFA einrichten</h2>
<p class="text-muted mb-4">Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy).</p>
{% if error %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endif %}
{% if qr_data %}
<div class="mb-4">
<img src="data:image/png;base64,{{ qr_data }}" alt="QR-Code"
style="width:220px;height:220px;border:1px solid #ddd;border-radius:8px;">
</div>
<p class="text-muted small">Alternativ: Secret manuell eingeben</p>
<div class="mb-4">
<kbd style="font-size:1.1rem;padding:6px 14px;border-radius:6px;user-select:all;">{{ secret }}</kbd>
</div>
<hr class="my-4" style="max-width:300px;margin-left:auto;margin-right:auto;">
<h4>Code verifizieren</h4>
<p class="text-muted small mb-3">Gib den 6-stelligen Code aus der App ein, um MFA zu aktivieren.</p>
<form method="post" class="row justify-content-center">
<div class="col-auto">
<div class="d-flex gap-2">
<input type="text" name="code" class="form-control form-control-lg text-center"
placeholder="000000" inputmode="numeric" maxlength="6"
pattern="[0-9]{6}" required autofocus style="max-width:160px;">
<button class="btn btn-success" type="submit">Aktivieren</button>
</div>
</div>
</form>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
{% include '_footer.html' %}
</div>
</body>
</html>

70
templates/mfa_verify.html Normal file
View File

@@ -0,0 +1,70 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sicherheitscode eingeben - CANCOM Signage</title>
<link rel="stylesheet"
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
{% include "_styles.html" %}
<script>
(() => {
const stored = window.localStorage.getItem("signage-theme");
const theme = stored === "dark" || stored === "light" ? stored : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
})();
</script>
</head>
<body>
<div class="page">
{% include "_header.html" %}
<div class="page-wrapper d-flex align-items-center justify-content-center">
<div class="container-xl">
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card card-md">
<div class="card-body">
<h2 class="text-center mb-2">Sicherheitscode</h2>
<p class="text-center text-muted mb-4">Gib den 6-stelligen Code aus deiner Authenticator-App ein.</p>
{% if error %}
<div class="alert alert-danger text-center" role="alert">{{ error }}</div>
{% endif %}
<form method="post">
<div class="mb-3">
<input type="text" name="code" class="form-control form-control-lg text-center"
placeholder="000000" inputmode="numeric" autocomplete="one-time-code"
maxlength="6" pattern="[0-9]{6}" required autofocus>
</div>
<button class="btn btn-primary w-100 mb-2" type="submit">Bestätigen</button>
</form>
<p class="text-center text-muted mt-3 small">
<a href="#" onclick="document.getElementById('recovery-form').style.display='block';this.style.display='none';return false;">
Code verloren?
</a>
</p>
<form id="recovery-form" method="post" style="display:none;">
<input type="hidden" name="action" value="recovery">
<div class="mb-2">
<label class="form-label">Recovery-Code eingeben</label>
<input type="text" name="code" class="form-control" placeholder="ABCD-123">
</div>
<button class="btn btn-warning w-100" type="submit">Mit Recovery-Code anmelden</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{% include "_footer.html" %}
</div>
</body>
</html>

View File

@@ -488,7 +488,9 @@ let lastHash = null;
async function checkForUpdates() {
try {
const res = await fetch(`/playlist/${site}/${screen}/hash`, { cache: "no-store" });
const w = window.screen ? window.screen.width : '?';
const h = window.screen ? window.screen.height : '?';
const res = await fetch(`/playlist/${site}/${screen}/hash?w=${w}&h=${h}`, { cache: "no-store" });
const data = await res.json();
const hash = data.hash;
const serverStayOnFirst = data.stay_on_first;

View File

@@ -76,6 +76,7 @@
<th>Rolle</th>
<th>Standorte</th>
<th>Abteilung</th>
<th>MFA</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
@@ -106,6 +107,13 @@
{% endif %}
</td>
<td class="text-muted small">{{ u.department }}</td>
<td>
{% if u.mfa_enabled %}
<span class="badge bg-green text-green-fg">Aktiv</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if u.must_change_password %}
<span class="badge bg-warning text-white">Passwort-Änderung erforderlich</span>

View File

@@ -3,7 +3,19 @@
"password_hash": "scrypt:32768:8:1$JNEBAJBSSigcWCxK$4809dcc38b2abcaa3f6397d052560f9f59dedf7c95cf99b61bac0df08a65b64287907522841cbb23e06b36760814007d5757542db1c968a8536be7fd6604a382",
"role": "admin",
"sites": [],
"must_change_password": false
"must_change_password": false,
"mfa_enabled": false,
"mfa_recovery_codes": [
"923EFA",
"AED258",
"E8EAF6",
"A659E6",
"946F07",
"880C07",
"FC204A",
"1D09D7"
],
"mfa_secret": "VOLBXT7HYK6BXBD32EGW7CMFLVD53QON"
},
"erik.thiele@cancom.de": {
"password_hash": "scrypt:32768:8:1$gyBs4dA7DhH9wA0u$c80f25262388095545733c95dd4e569eee610fa849a11dd28d4f504d09e23d3768adad26d4ed671975bd3dcbae5dc43bd982f4d475e6bc9d2ede088cb0b6c025",
@@ -13,7 +25,19 @@
"first_name": "Erik",
"last_name": "Thiele",
"department": "CSO",
"notes": "Standort Stuttgart"
"notes": "Standort Stuttgart",
"mfa_secret": "CHKS2QZOJ45TSCLUN7FWO3MLTMRSS6Q3",
"mfa_enabled": true,
"mfa_recovery_codes": [
"1FB378",
"AAD593",
"B4BCB9",
"9CB5C3",
"22D614",
"26F3B7",
"47FB5C",
"50BB59"
]
},
"test1@test.de": {
"password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1",