Info Tab und MFA
This commit is contained in:
60
AGENTS.md
60
AGENTS.md
@@ -65,10 +65,33 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
|||||||
|
|
||||||
| Route | Beschreibung |
|
| Route | Beschreibung |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
|
| `GET /admin/users` | User-Liste (Admin, mit MFA-Spalte) |
|
||||||
|
| `GET /admin/users/create` | User anlegen (Formular) |
|
||||||
| `POST /admin/users/create` | User anlegen |
|
| `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/delete/<email>` | User löschen (letzter Admin geschützt) |
|
||||||
| `POST /admin/users/reset-password/<email>` | Passwort-Reset (temporäres Passwort) |
|
| `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
|
### 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).
|
- 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).
|
- `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.
|
- `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).
|
- `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.
|
- 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.
|
- `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).
|
- `/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
|
## 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).
|
- `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.
|
- 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.
|
- 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`.
|
- 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.
|
- 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.
|
- `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()` |
|
| **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 |
|
| **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" |
|
| **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`
|
### 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_DIR = Path("/tmp/signage-heartbeat")
|
||||||
HEARTBEAT_TIMEOUT = 60 # Sekunden
|
HEARTBEAT_TIMEOUT = 60 # Sekunden
|
||||||
|
|
||||||
def record_heartbeat(site, screen):
|
def record_heartbeat(site, screen, ip="", ua="", resolution=""):
|
||||||
path = HEARTBEAT_DIR / site / screen
|
"""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.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):
|
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():
|
if not path.exists():
|
||||||
return False
|
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
|
### Integration
|
||||||
|
|
||||||
- `playlist_hash()`-Route ruft `record_heartbeat()` direkt am Anfang auf
|
- `playlist_hash()`-Route liest Query-Parameter `w` (Breite) und `h` (Höhe) + `remote_addr` + `User-Agent` und übergibt sie an `record_heartbeat()`
|
||||||
- `admin()`-Route setzt `screen_status["online"/"offline"]` via `screen_is_active()` für jeden Screen
|
- `admin()`-Route sammelt `client_info[screen] = get_client_info(...)` für jeden Screen
|
||||||
- Admin-Template (`admin.html`) zeigt beide Buttons als `<button class="btn btn-sm btn-success/btn-danger" onclick="return false;">`
|
- 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`
|
- 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
|
### Docker-Hinweis
|
||||||
|
|||||||
45
README.md
45
README.md
@@ -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)
|
- **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)
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
- Priority-Seite ebenfalls mit Tabs: Playlist und Medien
|
||||||
- Dark Mode (localStorage-persistiert)
|
- Dark Mode (localStorage-persistiert)
|
||||||
- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot)
|
- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot)
|
||||||
@@ -42,7 +44,7 @@ Flask App (Server)
|
|||||||
├── config.json
|
├── config.json
|
||||||
├── users.json
|
├── users.json
|
||||||
├── history.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/
|
├── media/
|
||||||
│ ├── <site>/
|
│ ├── <site>/
|
||||||
│ │ ├── <screen>/
|
│ │ ├── <screen>/
|
||||||
@@ -86,7 +88,10 @@ signage/
|
|||||||
│ ├── user_list.html # User-Liste (Admin)
|
│ ├── user_list.html # User-Liste (Admin)
|
||||||
│ ├── user_create.html # User anlegen (Admin)
|
│ ├── user_create.html # User anlegen (Admin)
|
||||||
│ ├── user_edit.html # User bearbeiten (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
|
│ ├── _footer.html # Gemeinsamer Footer
|
||||||
│ └── _styles.html # Zentrale CSS (Variablen, Dark Mode)
|
│ └── _styles.html # Zentrale CSS (Variablen, Dark Mode)
|
||||||
├── static/
|
├── 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` | User-Liste (Admin) |
|
||||||
| `GET /admin/users/create` | User anlegen (Admin) |
|
| `GET /admin/users/create` | User anlegen (Admin) |
|
||||||
| `GET /admin/users/edit/<email>` | User bearbeiten (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/create` | User anlegen (Admin) |
|
||||||
| `POST /admin/users/delete/<email>` | User löschen (Admin, letzter Admin geschützt) |
|
| `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) |
|
| `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
|
### 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
|
```bash
|
||||||
# Heartbeat-Dateien anzeigen
|
# Heartbeat-Dateien anzeigen
|
||||||
ls -la /tmp/signage-heartbeat/*/
|
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).
|
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>
|
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
|
- Medien hochladen / löschen
|
||||||
- Playlist per Drag & Drop sortieren
|
- Playlist per Drag & Drop sortieren
|
||||||
- Priority-Playlist verwalten (ebenfalls mit Tabs)
|
- 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)
|
- Digital Voice Agent pro Screen konfigurieren (Position, Bild ein/aus, Typewriter-Tagline)
|
||||||
- Stay-on-First-Modus pro Screen (kein automatischer Refresh)
|
- Stay-on-First-Modus pro Screen (kein automatischer Refresh)
|
||||||
- Standorte anlegen & löschen
|
- 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
|
### 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
|
- `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`
|
- **Notfall**: `users.json` löschen → Server-Neustart erzeugt neuen Admin aus `config.json`
|
||||||
|
|
||||||
|
### User-Felder (`users.json`)
|
||||||
|
|
||||||
| Feld | Typ | Beschreibung |
|
| Feld | Typ | Beschreibung |
|
||||||
|------|-----|-------------|
|
|------|-----|-------------|
|
||||||
| `server_url` | string | Öffentliche Server-URL für Player-Links |
|
| `password_hash` | string | scrypt-Hash des Passworts |
|
||||||
| `voice_agent_url` | string | URL des Digital Voice Agents (global, read-only im Admin) |
|
| `role` | string | `admin`, `superuser` oder `user` |
|
||||||
|
| `sites` | array | Freigegebene Standorte (nur für User-Rolle) |
|
||||||
## Konfiguration (`config.json`)
|
| `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
|
```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`
|
- `load_users()` / `save_users()` / `get_user()` – User aus `users.json`
|
||||||
- `init_user_db()` – initialen Admin aus `config.json` anlegen
|
- `init_user_db()` – initialen Admin aus `config.json` anlegen
|
||||||
- `get_accessible_sites(cfg, user)` – Standorte nach User-Berechtigung filtern
|
- `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
|
- `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
|
- `admin_required` / `site_access_required` – Zugriffs-Dekoratoren
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -385,6 +406,8 @@ Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Heade
|
|||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort)
|
- 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`
|
- Passwörter gehasht (scrypt) in `users.json`
|
||||||
- Drei Rollen: Admin (alle Standorte + Userverwaltung), Superuser (alle Standorte), User (nur zugewiesene Standorte)
|
- Drei Rollen: Admin (alle Standorte + Userverwaltung), Superuser (alle Standorte), User (nur zugewiesene Standorte)
|
||||||
- Zugriffskontrolle per Decorator (`@admin_required`, `@site_access_required`)
|
- Zugriffskontrolle per Decorator (`@admin_required`, `@site_access_required`)
|
||||||
|
|||||||
234
app.py
234
app.py
@@ -13,8 +13,14 @@ import json
|
|||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
import generate_welcome_page # Logo-Suche und Willkommensseite-Generierung
|
import generate_welcome_page # Logo-Suche und Willkommensseite-Generierung
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -22,7 +28,7 @@ from pathlib import Path
|
|||||||
from flask import (
|
from flask import (
|
||||||
Flask, jsonify, render_template,
|
Flask, jsonify, render_template,
|
||||||
send_from_directory, redirect,
|
send_from_directory, redirect,
|
||||||
request, abort
|
request, abort, flash, session
|
||||||
)
|
)
|
||||||
from flask_login import (
|
from flask_login import (
|
||||||
LoginManager, login_user, current_user,
|
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
|
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
|
||||||
HISTORY_FILE = os.path.join(BASE_DIR, "history.json") # Aktivitätsverlauf
|
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"}
|
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = "CHANGE_THIS_SECRET!!!"
|
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 = LoginManager(app)
|
||||||
login_manager.login_view = "login" # Nicht eingeloggte User werden zum Login umgeleitet
|
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
|
HEARTBEAT_TIMEOUT = 60 # Sekunden
|
||||||
|
|
||||||
|
|
||||||
def record_heartbeat(site, screen):
|
def record_heartbeat(site, screen, ip="", ua="", resolution=""):
|
||||||
"""Aktualisiert den Heartbeat-Timestamp für einen Screen."""
|
"""Aktualisiert den Heartbeat-Timestamp für einen Screen und speichert Client-Info."""
|
||||||
path = HEARTBEAT_DIR / site / screen
|
path = HEARTBEAT_DIR / site / f"{screen}.json"
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
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):
|
def screen_is_active(site, screen):
|
||||||
"""Prüft, ob ein Screen zuletzt innerhalb des Timeouts registriert wurde."""
|
"""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():
|
if not path.exists():
|
||||||
return False
|
return False
|
||||||
return (time.time() - path.stat().st_mtime) < HEARTBEAT_TIMEOUT
|
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):
|
def get_site_list(cfg):
|
||||||
"""Gibt eine sortierte Liste aller konfigurierten Standort-Namen zurück."""
|
"""Gibt eine sortierte Liste aller konfigurierten Standort-Namen zurück."""
|
||||||
return sorted(cfg.get("sites", {}).keys())
|
return sorted(cfg.get("sites", {}).keys())
|
||||||
@@ -293,7 +339,8 @@ class User(UserMixin):
|
|||||||
|
|
||||||
def __init__(self, email, password_hash="", role="user", sites=None,
|
def __init__(self, email, password_hash="", role="user", sites=None,
|
||||||
must_change_password=False, first_name="", last_name="",
|
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.email = email
|
||||||
self.id = email
|
self.id = email
|
||||||
self.password_hash = password_hash
|
self.password_hash = password_hash
|
||||||
@@ -304,6 +351,9 @@ class User(UserMixin):
|
|||||||
self.last_name = last_name
|
self.last_name = last_name
|
||||||
self.department = department
|
self.department = department
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
|
self.mfa_enabled = mfa_enabled
|
||||||
|
self.mfa_secret = mfa_secret
|
||||||
|
self.mfa_recovery_codes = mfa_recovery_codes or []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_admin(self):
|
def is_admin(self):
|
||||||
@@ -425,6 +475,7 @@ def login():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
email = request.form.get("username", "").strip().lower()
|
email = request.form.get("username", "").strip().lower()
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
|
mfa_code = request.form.get("mfa_code", "")
|
||||||
|
|
||||||
user = get_user(email)
|
user = get_user(email)
|
||||||
if user:
|
if user:
|
||||||
@@ -438,6 +489,31 @@ def login():
|
|||||||
user.must_change_password = True
|
user.must_change_password = True
|
||||||
|
|
||||||
if valid:
|
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)
|
login_user(user)
|
||||||
add_history_entry("user_login", f"User '{email}' angemeldet", email)
|
add_history_entry("user_login", f"User '{email}' angemeldet", email)
|
||||||
if user.must_change_password:
|
if user.must_change_password:
|
||||||
@@ -461,6 +537,134 @@ def logout():
|
|||||||
return redirect("/login")
|
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)
|
# Willkommensseite (Kunden-Logos + Generierung)
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@@ -758,8 +962,12 @@ def player(site, screen):
|
|||||||
def playlist_hash(site, screen):
|
def playlist_hash(site, screen):
|
||||||
"""Berechnet einen MD5-Hash über die aktuelle Konfiguration (Screen + Priority).
|
"""Berechnet einen MD5-Hash über die aktuelle Konfiguration (Screen + Priority).
|
||||||
Gibt JSON mit Hash und aktuellem stay_on_first-Wert zurück.
|
Gibt JSON mit Hash und aktuellem stay_on_first-Wert zurück.
|
||||||
Zeichnet gleichzeitig den Heartbeat auf (Player aktiv)."""
|
Zeichnet gleichzeitig den Heartbeat auf (Player aktiv).
|
||||||
record_heartbeat(site, screen)
|
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()
|
config = load_config()
|
||||||
|
|
||||||
relevant = {
|
relevant = {
|
||||||
@@ -858,6 +1066,7 @@ def admin(site):
|
|||||||
screens = {}
|
screens = {}
|
||||||
media_files = {}
|
media_files = {}
|
||||||
screen_status = {}
|
screen_status = {}
|
||||||
|
client_info = {}
|
||||||
|
|
||||||
site_media_base = os.path.join(MEDIA_DIR, current_site)
|
site_media_base = os.path.join(MEDIA_DIR, current_site)
|
||||||
|
|
||||||
@@ -944,6 +1153,7 @@ def admin(site):
|
|||||||
screens[screen_name] = screen_cfg
|
screens[screen_name] = screen_cfg
|
||||||
media_files[screen_name] = files
|
media_files[screen_name] = files
|
||||||
screen_status[screen_name] = "online" if screen_is_active(current_site, screen_name) else "offline"
|
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(
|
return render_template(
|
||||||
"admin.html",
|
"admin.html",
|
||||||
@@ -952,6 +1162,7 @@ def admin(site):
|
|||||||
screens=screens,
|
screens=screens,
|
||||||
media_files=media_files,
|
media_files=media_files,
|
||||||
screen_status=screen_status,
|
screen_status=screen_status,
|
||||||
|
client_info=client_info,
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
year=datetime.now().year,
|
year=datetime.now().year,
|
||||||
hostname=os.uname().nodename,
|
hostname=os.uname().nodename,
|
||||||
@@ -1427,7 +1638,8 @@ def admin_users():
|
|||||||
"first_name": data.get("first_name", ""),
|
"first_name": data.get("first_name", ""),
|
||||||
"last_name": data.get("last_name", ""),
|
"last_name": data.get("last_name", ""),
|
||||||
"department": data.get("department", ""),
|
"department": data.get("department", ""),
|
||||||
"notes": data.get("notes", "")
|
"notes": data.get("notes", ""),
|
||||||
|
"mfa_enabled": data.get("mfa_enabled", False)
|
||||||
})
|
})
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
|
|||||||
264
history.json
264
history.json
@@ -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",
|
"timestamp": "2026-06-26 14:49:09",
|
||||||
"action": "user_login",
|
"action": "user_login",
|
||||||
|
|||||||
10461
session-ses_1e96.md
10461
session-ses_1e96.md
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
<!-- Aktionen: Standort-Dropdown, +Button, Theme, Logout -->
|
<!-- Aktionen: Standort-Dropdown, +Button, Theme, Logout -->
|
||||||
<div class="navbar-nav flex-row order-md-last top-actions">
|
<div class="navbar-nav flex-row order-md-last top-actions">
|
||||||
<!-- Standort-Auswahl -->
|
<!-- Standort-Auswahl -->
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
<div class="dropdown me-2">
|
<div class="dropdown me-2">
|
||||||
<a class="btn btn-outline-secondary" href="#" data-bs-toggle="dropdown">
|
<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>
|
<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());"
|
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"
|
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>
|
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")) -->
|
<!-- Dark-Mode-Toggle (persistiert in localStorage("signage-theme")) -->
|
||||||
<div>
|
<div>
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-end">
|
<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="/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>
|
<a class="dropdown-item" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</a>
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<a class="dropdown-item" href="/admin/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</a>
|
<a class="dropdown-item" href="/admin/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</a>
|
||||||
|
|||||||
@@ -176,7 +176,8 @@
|
|||||||
</span>
|
</span>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
{% if screen_status[screen] == "online" %}
|
{% 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] }} Auflösung: {{ client_info[screen].get('resolution', '?') }} IP: {{ client_info[screen].get('ip', '?') }} Zuletzt: {{ client_info[screen].get('last_seen', '?') }}">Online</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button type="button" class="btn btn-sm btn-danger" onclick="return false;">Offline</button>
|
<button type="button" class="btn btn-sm btn-danger" onclick="return false;">Offline</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -188,7 +189,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="card-body">
|
||||||
|
|
||||||
<ul class="nav nav-tabs" data-bs-toggle="tabs" role="tablist">
|
<ul class="nav nav-tabs" data-bs-toggle="tabs" role="tablist">
|
||||||
@@ -217,6 +218,11 @@
|
|||||||
<i class="ti ti-upload me-1"></i>Medien
|
<i class="ti ti-upload me-1"></i>Medien
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content mt-3">
|
<div class="tab-content mt-3">
|
||||||
@@ -473,6 +479,35 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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> <!-- /tab-content -->
|
||||||
|
|
||||||
</div> <!-- /card-body -->
|
</div> <!-- /card-body -->
|
||||||
|
|||||||
@@ -41,9 +41,36 @@
|
|||||||
|
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<div class="container-xl mt-4">
|
<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 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">
|
<div class="card-body">
|
||||||
<p>Ein Standort (Site) gruppiert mehrere Screens, z. B. <code>stuttgart</code> oder <code>karlsruhe</code>.</p>
|
<p>Ein Standort (Site) gruppiert mehrere Screens, z. B. <code>stuttgart</code> oder <code>karlsruhe</code>.</p>
|
||||||
<ol>
|
<ol>
|
||||||
@@ -56,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<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">
|
<div class="card-body">
|
||||||
<p>Ein Screen ist ein einzelner Player (z. B. <code>lobby</code>, <code>casino</code>), der eine Playlist abspielt.</p>
|
<p>Ein Screen ist ein einzelner Player (z. B. <code>lobby</code>, <code>casino</code>), der eine Playlist abspielt.</p>
|
||||||
<ol>
|
<ol>
|
||||||
@@ -69,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<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">
|
<div class="card-body">
|
||||||
<p>Jeder Screen hat eine eigene Playlist mit Medien und URLs.</p>
|
<p>Jeder Screen hat eine eigene Playlist mit Medien und URLs.</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -84,7 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<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">
|
<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>
|
<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>
|
<ul>
|
||||||
@@ -96,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<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">
|
<div class="card-body">
|
||||||
<p>Der Action Button ist ein konfigurierbarer Button im Player, der eine frei wählbare URL öffnet.</p>
|
<p>Der Action Button ist ein konfigurierbarer Button im Player, der eine frei wählbare URL öffnet.</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -116,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<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">
|
<div class="card-body">
|
||||||
<p>Der Digital Voice Agent zeigt einen Button im Player, der eine Typewriter-Animation mit wechselnden mehrsprachigen Texten anzeigt.</p>
|
<p>Der Digital Voice Agent zeigt einen Button im Player, der eine Typewriter-Animation mit wechselnden mehrsprachigen Texten anzeigt.</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -132,7 +159,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<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">
|
<div class="card-body">
|
||||||
<p>Im Tab <strong>Einstellungen</strong> können pro Screen konfiguriert werden:</p>
|
<p>Im Tab <strong>Einstellungen</strong> können pro Screen konfiguriert werden:</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -145,7 +172,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<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">
|
<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>
|
<p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -159,7 +200,7 @@
|
|||||||
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<div class="card mb-3">
|
<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">
|
<div class="card-body">
|
||||||
<p>Admins können über <code>/admin/users</code> die User-Verwaltung aufrufen.</p>
|
<p>Admins können über <code>/admin/users</code> die User-Verwaltung aufrufen.</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -179,7 +220,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<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">
|
<div class="card-body">
|
||||||
<p>Das Admin Dashboard (<code>/admin/dashboard</code>) bietet eine Übersicht über das gesamte System:</p>
|
<p>Das Admin Dashboard (<code>/admin/dashboard</code>) bietet eine Übersicht über das gesamte System:</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -193,24 +234,40 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card mb-3">
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<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">
|
<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>
|
<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>
|
</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>
|
||||||
|
|
||||||
|
</div> <!-- /col-md-9 main -->
|
||||||
|
</div> <!-- /row -->
|
||||||
|
|
||||||
|
</div> <!-- /container-xl -->
|
||||||
|
</div> <!-- /page-wrapper -->
|
||||||
|
|
||||||
{% include "_footer.html" %}
|
{% include "_footer.html" %}
|
||||||
</div>
|
</div> <!-- /page -->
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -2,29 +2,32 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>CANCOM Simple Signage Admin</title>
|
<title>CANCOM Simple Signage Admin</title>
|
||||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
<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">
|
<link rel="stylesheet"
|
||||||
|
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||||
<style>
|
<link rel="stylesheet"
|
||||||
body {
|
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||||
background-color: #F4F6F8;
|
{% include "_styles.html" %}
|
||||||
color: #212121;
|
<script>
|
||||||
}
|
(() => {
|
||||||
.btn-primary {
|
const stored = window.localStorage.getItem("signage-theme");
|
||||||
background-color: #DA002D;
|
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||||
border-color: #DA002D;
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
}
|
})();
|
||||||
.btn-primary:hover {
|
</script>
|
||||||
background-color: #B00024;
|
|
||||||
border-color: #B00024;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
<!-- ─── Login-Seite (zentriert) ─── -->
|
{% include "_header.html" %}
|
||||||
<body class="d-flex align-items-center justify-content-center" style="height:100vh;">
|
|
||||||
<div class="card card-md">
|
<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">
|
<div class="card-body">
|
||||||
<h2 class="text-center mb-4">Signage Admin</h2>
|
<h2 class="text-center mb-4">Signage Admin</h2>
|
||||||
|
|
||||||
@@ -46,6 +49,13 @@
|
|||||||
<button class="btn btn-primary w-100">Login</button>
|
<button class="btn btn-primary w-100">Login</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "_footer.html" %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
64
templates/mfa_recovery.html
Normal file
64
templates/mfa_recovery.html
Normal 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
101
templates/mfa_setup.html
Normal 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
70
templates/mfa_verify.html
Normal 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>
|
||||||
@@ -488,7 +488,9 @@ let lastHash = null;
|
|||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
try {
|
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 data = await res.json();
|
||||||
const hash = data.hash;
|
const hash = data.hash;
|
||||||
const serverStayOnFirst = data.stay_on_first;
|
const serverStayOnFirst = data.stay_on_first;
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
<th>Rolle</th>
|
<th>Rolle</th>
|
||||||
<th>Standorte</th>
|
<th>Standorte</th>
|
||||||
<th>Abteilung</th>
|
<th>Abteilung</th>
|
||||||
|
<th>MFA</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -106,6 +107,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">{{ u.department }}</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>
|
<td>
|
||||||
{% if u.must_change_password %}
|
{% if u.must_change_password %}
|
||||||
<span class="badge bg-warning text-white">Passwort-Änderung erforderlich</span>
|
<span class="badge bg-warning text-white">Passwort-Änderung erforderlich</span>
|
||||||
|
|||||||
28
users.json
28
users.json
@@ -3,7 +3,19 @@
|
|||||||
"password_hash": "scrypt:32768:8:1$JNEBAJBSSigcWCxK$4809dcc38b2abcaa3f6397d052560f9f59dedf7c95cf99b61bac0df08a65b64287907522841cbb23e06b36760814007d5757542db1c968a8536be7fd6604a382",
|
"password_hash": "scrypt:32768:8:1$JNEBAJBSSigcWCxK$4809dcc38b2abcaa3f6397d052560f9f59dedf7c95cf99b61bac0df08a65b64287907522841cbb23e06b36760814007d5757542db1c968a8536be7fd6604a382",
|
||||||
"role": "admin",
|
"role": "admin",
|
||||||
"sites": [],
|
"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": {
|
"erik.thiele@cancom.de": {
|
||||||
"password_hash": "scrypt:32768:8:1$gyBs4dA7DhH9wA0u$c80f25262388095545733c95dd4e569eee610fa849a11dd28d4f504d09e23d3768adad26d4ed671975bd3dcbae5dc43bd982f4d475e6bc9d2ede088cb0b6c025",
|
"password_hash": "scrypt:32768:8:1$gyBs4dA7DhH9wA0u$c80f25262388095545733c95dd4e569eee610fa849a11dd28d4f504d09e23d3768adad26d4ed671975bd3dcbae5dc43bd982f4d475e6bc9d2ede088cb0b6c025",
|
||||||
@@ -13,7 +25,19 @@
|
|||||||
"first_name": "Erik",
|
"first_name": "Erik",
|
||||||
"last_name": "Thiele",
|
"last_name": "Thiele",
|
||||||
"department": "CSO",
|
"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": {
|
"test1@test.de": {
|
||||||
"password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1",
|
"password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1",
|
||||||
|
|||||||
Reference in New Issue
Block a user