Compare commits

..

23 Commits

Author SHA1 Message Date
Erik Thiele
7870c889f8 README: OpenAI API-Key-Doku ergänzt 2026-07-22 13:16:20 +02:00
Erik Thiele
6f82bda70c OpenAI-Key aus env var statt Hardcode: OPENAI_API_KEY in generate_welcome_page.py, docker-compose reicht Variable durch 2026-07-22 12:51:52 +02:00
Erik Thiele
bf95b3173b Doku: Link-Icon für Passthrough-Status in Playlist-Zeile beschrieben 2026-07-10 17:46:52 +02:00
Erik Thiele
22104b54cd Query-Parameter-Passthrough per URL steuerbar (Checkbox im Edit-Modal), Standard aus, sichtbar via Link-Icon in Playlist
- Neue Config-Felder: passthrough_params (true/false) pro URL-Item
- Edit-Modal in admin.html + priority.html mit Checkbox
- Player checkt item.passthrough === true vor appendParams()
- Default: false (keine Parameter-Übergabe ohne Checkbox)
- Link-Icon (ti-link / ti-link-off) in Playlist-Zeile als Status-Indikator
- Doku: help.html, README.md, AGENTS.md
2026-07-10 17:44:57 +02:00
Erik Thiele
6efce5b2ee AGENTS.md: Query-Parameter-Passthrough dokumentiert 2026-07-10 17:20:55 +02:00
Erik Thiele
5ee9e0b373 Query-Parameter der Player-URL an Playlist-URLs übergeben
- player_params als dict(request.args) an player.html übergeben
- appendParams() fügt Parameter an iframe.src + Overlay-URLs an
- Funktioniert mit & ohne bestehende Query-Parameter in der Ziel-URL
- Beispiel: /player/stuttgart/lobby?customer=acme → https://dashboard?customer=acme
2026-07-10 17:20:37 +02:00
Erik Thiele
fca129b367 Fix: Leeres Span für Edit-Spalte bei Nicht-URL-Items (Grid-Alignment) 2026-07-10 16:50:01 +02:00
Erik Thiele
1480960801 Button-Reihenfolge: Löschen vor Edit in der Playlist-Zeile 2026-07-10 16:47:41 +02:00
Erik Thiele
b8093521a0 Fix: Edit-Button-Klick-Handler vor DOMContentLoaded ziehen
Die globale Event-Delegation lag versehentlich innerhalb
des DOMContentLoaded-Callbacks. Wenn initSortable fehlschlug,
wurde der Click-Handler nie registriert.
- editState + document click handler jetzt global (vor DOMContentLoaded)
- Duplicate Code entfernt
- Debug-alerts entfernt
2026-07-10 16:40:59 +02:00
Erik Thiele
be00342109 Debug: inline onclick alert test 2026-07-10 16:37:47 +02:00
Erik Thiele
a8cbeb089f Debug: Edit-Button mit globaler Event-Delegation + alert() 2026-07-10 16:35:34 +02:00
Erik Thiele
f1c36f3c99 Switch to event delegation for edit button (robuster) + btn-outline-secondary 2026-07-10 16:34:16 +02:00
Erik Thiele
f8b7a94845 Use 'Edit' text instead of pencil emoji for URL edit button 2026-07-10 16:25:13 +02:00
Erik Thiele
ce4cd4486b URLs in Playlist inline bearbeiten (Edit-Button + Modal)
- Neue Route POST /admin/<site>/update-playlist-item/<screen>
- Bearbeiten-Button (✎) für URL-Items in admin.html + priority.html
- Modal mit URL + Zoom-Feld, Validierung und Speichern
- Grid um Edit-Spalte erweitert (28px|1fr|90px|80px|40px|50px|32px)
2026-07-10 16:24:47 +02:00
Erik Thiele
106ec2d897 Fix: Trusted-Device-Cookie überlebt jetzt den Logout
- resp.delete_cookie('mfa_trust') aus /logout entfernt
- Cookie wird nur noch bei MFA-Deaktivierung/Passwort-Reset gelöscht
- Debug-Prints entfernt, users.json aufgeräumt
2026-07-10 16:17:08 +02:00
Erik Thiele
b9e7b2e074 Fix: Trusted-Device-Cookie wurde nicht korrekt gespeichert
- Cookie path explizit auf '/' gesetzt
- login_user() vor Cookie-Setzung aufgerufen (konsistenter Zustand)
- Kein Überschreiben der Response mehr bei must_change_password
2026-07-10 15:44:00 +02:00
Erik Thiele
bfbd970f3b Fix: Passwort-Reset deaktiviert jetzt auch MFA
Beim Admin-Passwort-Reset werden mfa_enabled, mfa_secret,
mfa_recovery_codes und trusted_devices zurückgesetzt.
2026-07-10 15:33:37 +02:00
Erik Thiele
23a33955aa MFA: Trusted Device Feature — Gerät für X Tage merken, überspringt MFA
- User kann nach TOTP-Eingabe das Gerät für 1–30 Tage speichern
- Cookie mfa_trust mit zufälligem Token, gespeichert in trusted_devices[]
- Nächstes POST /login: Cookie prüfen → gültiges Token → MFA überspringen
- Abgelaufene Einträge werden beim Login automatisch bereinigt
- Logout + MFA-Deaktivierung löschen Cookie + trusted_devices
- mfa_verify.html: checkbox + days-select (1/3/7/14/30 Tage)
2026-07-10 15:27:17 +02:00
Erik Thiele
317f2a6eb2 Doku: README, AGENTS und Hilfeseite aktualisiert
- README: Willkommensseite-Sektion mit Ansprechpartner/Markdown/Layout
- AGENTS: generate_welcome_html Signatur + Verhalten dokumentiert
- help.html: Ansprechpartner-Felder, Markdown-Hilfe, Portrait-Layout
2026-07-04 10:34:11 +02:00
Erik Thiele
a0753ea4c3 Willkommensseite: Kontaktinfo linksbündig mit Aufzählungen, Portrait-Modus verbessert
- md_to_html: Listen-Support (- /*) und Zeilenumbrüche erhalten
- Kein Firmenname mehr unter den Logos (customer-name entfernt)
- text-align: left + align-items: stretch für linksbündige Texte
- Feste Logo-Höhe 120px für gleiche Text-Startposition
- Portrait-Modus: Text links vom Logo (via order), gap 40px
- Schrift 1.3rem, margin-top 24px, Listeneinzug entfernt
- Reset-Button im Willkommensseite-Formular
- Markdown-Hilfetext aktualisiert
2026-07-04 10:31:02 +02:00
Erik Thiele
c1a043fc75 Ansprechpartner-Felder für Willkommensseite mit Markdown-Support 2026-07-04 09:14:38 +02:00
Erik Thiele
b1f525c714 proxyfix 2026-06-30 19:11:44 +02:00
Erik Thiele
f8c0d7d72c Session-Log aus Tracking entfernt 2026-06-30 19:02:27 +02:00
15 changed files with 779 additions and 3147 deletions

View File

@@ -86,12 +86,14 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung - Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung
- Deaktivierung via `/mfa/disable` mit Passwort-Bestätigung - Deaktivierung via `/mfa/disable` mit Passwort-Bestätigung
- MFA-Status in der Userliste (`/admin/users`) als grünes "Aktiv"-Badge oder "" - MFA-Status in der Userliste (`/admin/users`) als grünes "Aktiv"-Badge oder ""
- **Trusted Device**: Nach erfolgreichem TOTP-Code kann das Gerät für 130 Tage gespeichert werden. Bei erneuter Anmeldung wird der MFA-Code dann übersprungen (Cookie `mfa_trust` mit Token, gespeichert in `users.json[email].trusted_devices`). Beim Logout oder Deaktivieren von MFA wird der Cookie gelöscht.
### MFA-Login-Flow ### MFA-Login-Flow
1. POST `/login` mit Passwort → bei aktivem MFA: Session `mfa_pending` setzen → redirect `/mfa/verify` 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>` 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 3. Recovery-Code: POST `/mfa/verify` mit `recovery=true` → Code prüfen → `mfa_enabled=False` → Login erlauben, aber User muss MFA neu einrichten
4. **Trusted Device**: POST `/mfa/verify` mit `remember` (Tage) → Token generieren → in `users.json[email].trusted_devices` speichern → Cookie `mfa_trust` setzen. Nächstes POST `/login`: Cookie erkannt → MFA überspringen → direkt einloggen
### Zugriffs-Dekoratoren ### Zugriffs-Dekoratoren
@@ -112,7 +114,8 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`. - New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`.
- Priority playlist (`config.priority`) is global and affects all sites/screens. - Priority playlist (`config.priority`) is global and affects all sites/screens.
- Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column). - Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).
- `generate_welcome_html(customer_names, logo_urls)` takes lists for up to 3 customers; logos have equal width (280px) with `max-height: 180px`. - `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.
- Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`).
- `customer_names` are preserved in form fields after POST (via `value`-Attribute). - `customer_names` are preserved in form fields after POST (via `value`-Attribute).
- Admin-UI nutzt keyadmin-Design: `brand-surface` (#2b2f36), `nav-surface` (rot #DA002D), Dark Mode per `localStorage("signage-theme")`. - Admin-UI nutzt keyadmin-Design: `brand-surface` (#2b2f36), `nav-surface` (rot #DA002D), Dark Mode per `localStorage("signage-theme")`.
- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem). - Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem).
@@ -129,6 +132,7 @@ 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.
- **Query-Parameter-Passthrough**: Query-Parameter an der Player-URL (`/player/<site>/<screen>`) können pro Playlist-URL im Edit-Modal (Checkbox) gesteuert werden. Standardmäßig **aus** nur URLs mit aktivierter Checkbox erhalten die Parameter. Beispiel: `/player/stuttgart/lobby?customer=acme` hängt `?customer=acme` nur an URLs mit Passthrough=an. In der Playlist-Zeile zeigt ein Link-Icon (`ti-link`, grau) den Status an: sichtbar = an, durchgestrichen (`ti-link-off`, hellgrau) = aus. Funktioniert über `appendParams()` in `player.html` + `player_params` im Flask-Route.
- 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**. - 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 → Info. - Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info.
@@ -148,7 +152,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- 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.
- `generate_welcome_html(customer_names, logo_urls, background_url=None)` akzeptiert optionalen `background_url`-Parameter. - `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` akzeptiert optionale `contacts`-, `site`- und `background_url`-Parameter.
- `welcome.html` wird in `media/<site>/lobby/` gespeichert. - `welcome.html` wird in `media/<site>/lobby/` gespeichert.
- `search_customer_logo` in `generate_welcome_page.py` nutzt OpenAI GPT-4 + Brandfetch CDN. - `search_customer_logo` in `generate_welcome_page.py` nutzt OpenAI GPT-4 + Brandfetch CDN.
- `admin_priority` rendert `priority.html` mit `site_list`, `current_site`, `priority_files`, `server_url`. - `admin_priority` rendert `priority.html` mit `site_list`, `current_site`, `priority_files`, `server_url`.

View File

@@ -21,10 +21,11 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- **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 - **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 - **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, **Trusted Device** (Gerät für 130 Tage speichern, überspringt MFA beim nächsten Login)
- **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, Info (Tabler Tabs) - Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
- **Query-Parameter-Passthrough**: Per Player-URL übergebene Query-Parameter (z. B. `/player/stuttgart/lobby?customer=acme`) können pro Playlist-URL im Edit-Modal aktiviert werden standardmäßig aus. Link-Icon in der Playlist-Zeile zeigt Status (an/aus) an.
- 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)
@@ -271,6 +272,17 @@ Beispiel:
http://localhost:5005/player/stuttgart/lobby http://localhost:5005/player/stuttgart/lobby
``` ```
### Query-Parameter-Passthrough
Die Player-URL akzeptiert beliebige Query-Parameter, z.&thinsp;B. `?customer=acme&token=xyz`. Ob diese Parameter an eine Playlist-URL weitergegeben werden, wird pro URL im Admin-Playlist-Edit-Modal gesteuert (Checkbox "Query-Parameter an diese URL übergeben", standardmäßig aus). In der Playlist-Zeile zeigt ein Link-Icon (`ti-link`, grau) den Status an: sichtbar = an, durchgestrichen (`ti-link-off`, hellgrau) = aus.
Beispiel:
```
/player/stuttgart/lobby?customer=acme&token=xyz
```
- URL in Playlist mit **aktiviertem** Passthrough: `https://dashboard.acme.com/status?customer=acme&token=xyz`
- URL in Playlist mit **deaktiviertem** Passthrough: `https://dashboard.acme.com/status`
### Willkommensseite ### Willkommensseite
``` ```
@@ -279,6 +291,33 @@ http://localhost:5005/willkommen?site=stuttgart
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in der Lobby-Playlist vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`). Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in der Lobby-Playlist vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
**Ansprechpartner:** Pro Kunde können Kontaktdaten eingegeben werden (Markdown-Format).
- **Fett**: `**Text**`
- *Kursiv*: `*Text*`
- `Code`: `` `Code` ``
- Aufzählungen: `- Punkt` oder `* Punkt`
- Zeilenumbrüche werden als `<br>` übernommen
**Layout:**
- Landscape: Logos nebeneinander (280px breit, 120px hoch), Texte linksbündig darunter
- Portrait: Texte links vom Logo, per `order`-CSS gesteuert
---
### OpenAI API-Key
Die Logo-Suche via OpenAI benötigt einen API-Key. Diesen als Umgebungsvariable setzen:
```bash
export OPENAI_API_KEY=sk-...
```
Oder via `.env`-Datei (wird von Docker Compose automatisch geladen):
```
OPENAI_API_KEY=sk-...
```
--- ---
### Globale Konfiguration (config.json) ### Globale Konfiguration (config.json)

172
app.py
View File

@@ -22,13 +22,15 @@ import pyotp
import qrcode import qrcode
from functools import wraps from functools import wraps
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from werkzeug.middleware.proxy_fix import ProxyFix
from flask import ( from flask import (
Flask, jsonify, render_template, Flask, jsonify, render_template,
send_from_directory, redirect, send_from_directory, redirect,
request, abort, flash, session request, abort, flash, session, url_for
) )
from flask_login import ( from flask_login import (
LoginManager, login_user, current_user, LoginManager, login_user, current_user,
@@ -53,6 +55,7 @@ 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.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
@@ -249,7 +252,8 @@ def load_priority_files():
"type": "url", "type": "url",
"size": "URL", "size": "URL",
"zoom": url_data.get("zoom", 1.0), "zoom": url_data.get("zoom", 1.0),
"enabled": playlist_item_enabled(item) "enabled": playlist_item_enabled(item),
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False
}) })
continue continue
@@ -340,7 +344,7 @@ 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="", mfa_enabled=False, mfa_secret="", department="", notes="", mfa_enabled=False, mfa_secret="",
mfa_recovery_codes=None): mfa_recovery_codes=None, trusted_devices=None):
self.email = email self.email = email
self.id = email self.id = email
self.password_hash = password_hash self.password_hash = password_hash
@@ -354,6 +358,7 @@ class User(UserMixin):
self.mfa_enabled = mfa_enabled self.mfa_enabled = mfa_enabled
self.mfa_secret = mfa_secret self.mfa_secret = mfa_secret
self.mfa_recovery_codes = mfa_recovery_codes or [] self.mfa_recovery_codes = mfa_recovery_codes or []
self.trusted_devices = trusted_devices or []
@property @property
def is_admin(self): def is_admin(self):
@@ -510,6 +515,33 @@ def login():
return redirect("/mfa/setup?recovered=1") return redirect("/mfa/setup?recovered=1")
error = "Ungültiger Sicherheitscode" error = "Ungültiger Sicherheitscode"
else: else:
# Trusted Device prüfen (Cookie überspringt MFA)
trust_token = request.cookies.get("mfa_trust")
if trust_token:
now = datetime.now()
# Abgelaufene Einträge bereinigen
active = [d for d in trusted
if d.get("token") != trust_token or
datetime.fromisoformat(d["expires"]) > now]
changed = len(active) != len(trusted)
for d in trusted:
if d.get("token") == trust_token:
try:
expires = datetime.fromisoformat(d["expires"])
if expires > now:
# Trusted Device gültig → MFA überspringen
login_user(user)
add_history_entry("user_login", f"User '{email}' angemeldet (Trusted Device)", email)
if user.must_change_password:
return redirect("/change-password")
return redirect("/admin")
else:
pass
except (ValueError, KeyError):
pass
if changed:
users_data[email]["trusted_devices"] = active
save_users(users_data)
session["mfa_user"] = email session["mfa_user"] = email
session["mfa_password_ok"] = True session["mfa_password_ok"] = True
return redirect("/mfa/verify") return redirect("/mfa/verify")
@@ -565,6 +597,29 @@ def mfa_verify():
if code and totp.verify(code): if code and totp.verify(code):
login_user(user) login_user(user)
session.pop("mfa_user", None) session.pop("mfa_user", None)
# Trusted Device speichern wenn gewünscht
remember_days = request.form.get("remember")
if remember_days:
try:
days = int(remember_days)
if 1 <= days <= 365:
token = secrets.token_urlsafe(32)
exp_date = datetime.now() + timedelta(days=days)
expires = exp_date.isoformat()
users_data = load_users()
trusted = users_data.setdefault(email, {}).setdefault("trusted_devices", [])
trusted.append({"token": token, "expires": expires})
save_users(users_data)
add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA, Gerät für {days} Tage gespeichert)", email)
target = "/change-password" if user.must_change_password else "/admin"
resp = redirect(target)
resp.set_cookie("mfa_trust", token, max_age=days * 86400,
path="/",
httponly=True, samesite="Lax",
secure=False)
return resp
except (ValueError, TypeError):
pass
add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA)", email) add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA)", email)
if user.must_change_password: if user.must_change_password:
return redirect("/change-password") return redirect("/change-password")
@@ -660,9 +715,12 @@ def mfa_disable():
users_data[email]["mfa_enabled"] = False users_data[email]["mfa_enabled"] = False
users_data[email]["mfa_secret"] = "" users_data[email]["mfa_secret"] = ""
users_data[email]["mfa_recovery_codes"] = [] users_data[email]["mfa_recovery_codes"] = []
users_data[email]["trusted_devices"] = []
save_users(users_data) save_users(users_data)
add_history_entry("mfa_disabled", f"User '{email}' hat MFA deaktiviert", email) add_history_entry("mfa_disabled", f"User '{email}' hat MFA deaktiviert", email)
return redirect("/mfa/setup?disabled=1") resp = redirect("/mfa/setup?disabled=1")
resp.delete_cookie("mfa_trust")
return resp
# ------------------------------------------------- # -------------------------------------------------
@@ -682,6 +740,7 @@ def add_customer():
success = None success = None
logo_urls = [None, None, None] logo_urls = [None, None, None]
customer_names = ["", "", ""] customer_names = ["", "", ""]
contacts = ["", "", ""]
site = request.args.get("site") or request.form.get("site") or "" site = request.args.get("site") or request.form.get("site") or ""
# Fallback auf ersten für den User zugänglichen Standort # Fallback auf ersten für den User zugänglichen Standort
@@ -700,6 +759,11 @@ def add_customer():
request.form.get("customer_name_2", "").strip(), request.form.get("customer_name_2", "").strip(),
request.form.get("customer_name_3", "").strip(), request.form.get("customer_name_3", "").strip(),
] ]
contacts = [
request.form.get("contact_1", "").strip(),
request.form.get("contact_2", "").strip(),
request.form.get("contact_3", "").strip(),
]
filled_names = [n for n in customer_names if n] filled_names = [n for n in customer_names if n]
print(f"🔍 APP.PY - Suche nach Kunden: {filled_names} (site: {site})") print(f"🔍 APP.PY - Suche nach Kunden: {filled_names} (site: {site})")
@@ -722,6 +786,14 @@ def add_customer():
else: else:
valid_names = [n for n, l in valid] valid_names = [n for n, l in valid]
valid_urls = [l for n, l in valid] valid_urls = [l for n, l in valid]
valid_contacts = []
for name in valid_names:
for i, cn in enumerate(customer_names):
if cn == name:
valid_contacts.append(contacts[i] if i < len(contacts) else "")
break
else:
valid_contacts.append("")
for i, name in enumerate(customer_names): for i, name in enumerate(customer_names):
for j, (vn, vu) in enumerate(zip(valid_names, valid_urls)): for j, (vn, vu) in enumerate(zip(valid_names, valid_urls)):
if vn == name: if vn == name:
@@ -729,14 +801,14 @@ def add_customer():
break break
bg_url = get_background_url(site) bg_url = get_background_url(site)
html_filename = generate_welcome_page.save_welcome_page(valid_names, valid_urls, site=site, background_url=bg_url) html_filename = generate_welcome_page.save_welcome_page(valid_names, valid_urls, contacts=valid_contacts, site=site, background_url=bg_url)
if html_filename: if html_filename:
if generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site): if generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site):
# Letzte Kundendaten für Background-Update speichern # Letzte Kundendaten für Background-Update speichern
config = load_config() config = load_config()
site_cfg = config.setdefault("sites", {}).setdefault(site, {}) site_cfg = config.setdefault("sites", {}).setdefault(site, {})
site_cfg["welcome_data"] = {"names": valid_names, "logo_urls": valid_urls} site_cfg["welcome_data"] = {"names": valid_names, "logo_urls": valid_urls, "contacts": valid_contacts}
save_config(config) save_config(config)
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt", current_user.email) add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt", current_user.email)
success = f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt!" success = f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt!"
@@ -752,12 +824,26 @@ def add_customer():
config = load_config() config = load_config()
site_list = get_site_list(config) site_list = get_site_list(config)
background_url = get_background_url(site) background_url = get_background_url(site)
if request.args.get("reset") and site:
site_cfg = config.setdefault("sites", {}).setdefault(site, {})
site_cfg.pop("welcome_data", None)
save_config(config)
return redirect(url_for("add_customer", site=site))
welcome_data = config.get("sites", {}).get(site, {}).get("welcome_data", {})
saved_contacts = welcome_data.get("contacts", ["", "", ""])
if request.method != "POST":
contacts = saved_contacts
customer_names = welcome_data.get("names", ["", "", ""])
logo_urls = welcome_data.get("logo_urls", [None, None, None])
return render_template( return render_template(
"customer.html", "customer.html",
error=error, error=error,
success=success, success=success,
logo_urls=logo_urls, logo_urls=logo_urls,
customer_names=customer_names, customer_names=customer_names,
contacts=contacts,
site=site, site=site,
site_list=site_list, site_list=site_list,
current_site=site, current_site=site,
@@ -769,6 +855,7 @@ def add_customer():
@app.route("/api/customer", methods=["POST"]) @app.route("/api/customer", methods=["POST"])
@login_required
def api_add_customer(): def api_add_customer():
""" """
API-Endpunkt für die Willkommensseite (JSON). API-Endpunkt für die Willkommensseite (JSON).
@@ -792,12 +879,17 @@ def api_add_customer():
if not site: if not site:
return jsonify({"error": "No site configured"}), 400 return jsonify({"error": "No site configured"}), 400
if not current_user.can_access_site(site):
return jsonify({"error": "Access denied"}), 403
logo_url = generate_welcome_page.search_customer_logo(customer_name) logo_url = generate_welcome_page.search_customer_logo(customer_name)
if not logo_url: if not logo_url:
return jsonify({"error": "Logo not found"}), 400 return jsonify({"error": "Logo not found"}), 400
html_filename = generate_welcome_page.save_welcome_page(customer_name, logo_url, site=site) contact = data.get("contact", "").strip()
html_filename = generate_welcome_page.save_welcome_page([customer_name], [logo_url], contacts=[contact], site=site)
if not html_filename: if not html_filename:
return jsonify({"error": "Failed to create welcome page"}), 500 return jsonify({"error": "Failed to create welcome page"}), 500
@@ -889,7 +981,7 @@ def player(site, screen):
if is_url(item): if is_url(item):
url_data = normalize_url(item) url_data = normalize_url(item)
if url_data: if url_data:
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True}) normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False})
continue continue
if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name): if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name):
@@ -919,7 +1011,7 @@ def player(site, screen):
if is_url(item): if is_url(item):
url_data = normalize_url(item) url_data = normalize_url(item)
if url_data: if url_data:
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True}) prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False})
continue continue
file_path = os.path.join(MEDIA_DIR, "priority", item_name) file_path = os.path.join(MEDIA_DIR, "priority", item_name)
@@ -948,7 +1040,8 @@ def player(site, screen):
voice_agent_url=config.get("voice_agent_url", ""), voice_agent_url=config.get("voice_agent_url", ""),
voice_agent_label=screen_cfg.get("voice_agent_label", "Digitaler Assistent"), voice_agent_label=screen_cfg.get("voice_agent_label", "Digitaler Assistent"),
voice_agent_target=screen_cfg.get("voice_agent_target", "overlay"), voice_agent_target=screen_cfg.get("voice_agent_target", "overlay"),
voice_agent_position=screen_cfg.get("voice_agent_position", "top-left") voice_agent_position=screen_cfg.get("voice_agent_position", "top-left"),
player_params=dict(request.args)
) )
@@ -1108,7 +1201,8 @@ def admin(site):
"type": "url", "type": "url",
"size": "URL", "size": "URL",
"zoom": url_data.get("zoom", 1.0), "zoom": url_data.get("zoom", 1.0),
"enabled": playlist_item_enabled(item) "enabled": playlist_item_enabled(item),
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False
}) })
continue continue
@@ -1484,6 +1578,54 @@ def save_playlist(site, screen):
return "", 204 return "", 204
# -------------------------------------------------
# Admin: Playlist-Item bearbeiten (URL + Zoom)
# -------------------------------------------------
@app.route("/admin/<site>/update-playlist-item/<screen>", methods=["POST"])
@site_access_required
def update_playlist_item(site, screen):
"""Aktualisiert URL und Zoom eines einzelnen Playlist-Items."""
data = request.get_json()
old_name = data.get("old_name")
new_url = data.get("new_url", "").strip()
zoom = data.get("zoom", 1.0)
if not is_url(new_url):
return jsonify({"error": "Ungültige URL"}), 400
try:
zoom = float(zoom)
if zoom <= 0:
zoom = 1.0
except (ValueError, TypeError):
zoom = 1.0
config = load_config()
if screen == "priority":
playlist = config.setdefault("priority", {}).setdefault("playlist", [])
else:
playlist = config["sites"][site]["screens"][screen].setdefault("playlist", [])
for i, item in enumerate(playlist):
name = playlist_item_name(item)
if name == old_name:
updated = {"url": new_url, "zoom": zoom}
passthrough = data.get("passthrough", None)
if passthrough is not None:
updated["passthrough_params"] = bool(passthrough)
else:
# Bestehenden Wert übernehmen falls vorhanden
existing = item.get("passthrough_params") if isinstance(item, dict) else None
if existing is not None:
updated["passthrough_params"] = existing
playlist[i] = updated
save_config(config)
return jsonify({"success": True})
return jsonify({"error": "Item nicht gefunden"}), 404
# ------------------------------------------------- # -------------------------------------------------
# Change-Password (für First-Login / Reset) # Change-Password (für First-Login / Reset)
# ------------------------------------------------- # -------------------------------------------------
@@ -1824,8 +1966,12 @@ def admin_users_reset_password(email):
temp_password = secrets.choice(string.ascii_lowercase) + secrets.choice(string.ascii_uppercase) + secrets.choice(string.digits) + secrets.token_hex(4) temp_password = secrets.choice(string.ascii_lowercase) + secrets.choice(string.ascii_uppercase) + secrets.choice(string.digits) + secrets.token_hex(4)
users[email]["password_hash"] = generate_password_hash(temp_password) users[email]["password_hash"] = generate_password_hash(temp_password)
users[email]["must_change_password"] = True users[email]["must_change_password"] = True
users[email]["mfa_enabled"] = False
users[email]["mfa_secret"] = ""
users[email]["mfa_recovery_codes"] = []
users[email]["trusted_devices"] = []
save_users(users) save_users(users)
add_history_entry("user_password_reset", f"Passwort für User '{email}' zurückgesetzt", current_user.email) add_history_entry("user_password_reset", f"Passwort für User '{email}' zurückgesetzt (MFA deaktiviert)", current_user.email)
return redirect(f"/admin/users?reset={email}&temp={temp_password}") return redirect(f"/admin/users?reset={email}&temp={temp_password}")

View File

@@ -19,7 +19,10 @@
"show_images": true, "show_images": true,
"show_videos": true, "show_videos": true,
"playlist": [ "playlist": [
"welcome.html", {
"name": "welcome.html",
"enabled": true
},
{ {
"url": "https://www.meteoblue.com/en/meteotv/d7b0fd", "url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
"zoom": 1.0, "zoom": 1.0,
@@ -99,11 +102,6 @@
{ {
"name": "welcome.html", "name": "welcome.html",
"enabled": true "enabled": true
},
{
"url": "https://dva-stage.cancom-ds.de/",
"zoom": 1.0,
"enabled": true
} }
], ],
"interval": 10, "interval": 10,

View File

@@ -18,4 +18,5 @@ services:
- ALL - ALL
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY:-}

View File

@@ -21,9 +21,7 @@ WELCOME_FILENAME = "welcome.html"
# ------------------------------------------------- # -------------------------------------------------
def get_openai_client(): def get_openai_client():
"""Initialisiert den OpenAI-Client mit dem API-Key. api_key = os.environ.get("OPENAI_API_KEY")
Der Key ist aktuell direkt im Code hinterlegt sollte in env var ausgelagert werden."""
api_key = "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"
if not api_key: if not api_key:
raise ValueError("OPENAI_API_KEY environment variable not set") raise ValueError("OPENAI_API_KEY environment variable not set")
return OpenAI(api_key=api_key) return OpenAI(api_key=api_key)
@@ -97,14 +95,47 @@ def search_customer_logo(customer_name):
# HTML-Generierung # HTML-Generierung
# ------------------------------------------------- # -------------------------------------------------
def generate_welcome_html(customer_names, logo_urls, site="stuttgart", background_url=None): def md_to_html(text):
"""Wandelt einfaches Markdown in HTML um (fett, kursiv, code, zeilenumbrüche, aufzählungen)."""
import re
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
text = re.sub(r'`(.+?)`', r'<code>\1</code>', text)
lines = text.split('\n')
parts = []
in_list = False
for line in lines:
stripped = line.strip()
is_li = stripped.startswith('- ') or stripped.startswith('* ')
if is_li:
if not in_list:
parts.append('<ul>')
in_list = True
parts.append(f'<li>{stripped[2:]}</li>')
else:
if in_list:
parts.append('</ul>')
in_list = False
if stripped:
parts.append(stripped)
parts.append('<br>')
if in_list:
parts.append('</ul>')
if parts and parts[-1] == '<br>':
parts.pop()
return ''.join(parts)
def generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None):
""" """
Generiert den HTML-Code für die Willkommensseite. Generiert den HTML-Code für die Willkommensseite.
- Links oben: CANCOM-Logo + "meets" + Kundenlogos (280px Breite) - Links oben: CANCOM-Logo + "meets" + Kundenlogos
- Rechts unten: "Herzlich Willkommen in unserer Niederlassung {Standort}" - Unter jedem Logo: Ansprechpartner-Kontaktdaten (Landscape)
- Portrait: Kontaktdaten links neben den Logos
customer_names: Liste der Kundennamen (max. 3) customer_names: Liste der Kundennamen (max. 3)
logo_urls: Liste der Logo-URLs (gleiche Reihenfolge) logo_urls: Liste der Logo-URLs (gleiche Reihenfolge)
contacts: Liste der Ansprechpartner-Texte (Markdown, optional)
site: Standort-Name für die Anzeige site: Standort-Name für die Anzeige
background_url: URLs zum Hintergrundbild (None → Standard-Wallpaper) background_url: URLs zum Hintergrundbild (None → Standard-Wallpaper)
""" """
@@ -113,15 +144,24 @@ def generate_welcome_html(customer_names, logo_urls, site="stuttgart", backgroun
print(f"✅ Generate - Willkommensseite wird generiert für: {names_str} (site: {site})") print(f"✅ Generate - Willkommensseite wird generiert für: {names_str} (site: {site})")
site_display = site.capitalize() site_display = site.capitalize()
if contacts is None:
contacts = [""] * len(customer_names)
bg_image = background_url if background_url else "/static/wallpaper.png" bg_image = background_url if background_url else "/static/wallpaper.png"
cancom_svg = '''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xcs="https://www.xtool.com/pages/software" version="1.1" preserveAspectRatio="xMinYMin meet" width="61.682394106284505mm" height="9.909010347638684mm" viewBox="136.55880294685778 209.2954948261807 61.682394106284505 9.909010347638684" xcs:version="2.7.22"><style></style><path transform="matrix(0.158038,0,0,0.158038,133.2558,163.322122)" stroke="none" fill="currentColor" data-view-type="laser" d="M125.9,352.1h18.9L119,291.7h-16.2l-25,60.4h18.2l14.6-38.9L125.9,352.1z M323.6,322 c0,8.8-6.1,16.4-15.4,16.4c-9.1,0-15.4-7.6-15.4-16.4s6.1-16.4,15.2-16.4C317,305.6,323.6,313.4,323.6,322 M340.5,322.3 c0-17.4-13.4-31.3-32.3-31.3c-18.7,0-32.3,14.1-32.3,31.3c0,17.4,13.4,31.3,32.3,31.3C326.9,353.6,340.5,339.4,340.5,322.3 M230.4,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.2,0-31.6,14.1-31.6,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C236.5,338.4,230.4,331.4,230.4,322.5 M38,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.4,0-31.8,14.1-31.8,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C44,338.4,38,331.4,38,322.5 M191.8,352.1h14.4v-59.8H190v33.1l-25-33.1h-15.4v60.1h16.2v-34.1L191.8,352.1z M411.2,352.1v-59.8h-17.7l-14.4,23.2l-14.4-23.2 h-17.7v60.1h16.4V318l15.4,23.2h0.3l15.4-23.5v34.6L411.2,352.1L411.2,352.1z" fill-rule="nonzero"></path></svg>''' cancom_svg = '''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xcs="https://www.xtool.com/pages/software" version="1.1" preserveAspectRatio="xMinYMin meet" width="61.682394106284505mm" height="9.909010347638684mm" viewBox="136.55880294685778 209.2954948261807 61.682394106284505 9.909010347638684" xcs:version="2.7.22"><style></style><path transform="matrix(0.158038,0,0,0.158038,133.2558,163.322122)" stroke="none" fill="currentColor" data-view-type="laser" d="M125.9,352.1h18.9L119,291.7h-16.2l-25,60.4h18.2l14.6-38.9L125.9,352.1z M323.6,322 c0,8.8-6.1,16.4-15.4,16.4c-9.1,0-15.4-7.6-15.4-16.4s6.1-16.4,15.2-16.4C317,305.6,323.6,313.4,323.6,322 M340.5,322.3 c0-17.4-13.4-31.3-32.3-31.3c-18.7,0-32.3,14.1-32.3,31.3c0,17.4,13.4,31.3,32.3,31.3C326.9,353.6,340.5,339.4,340.5,322.3 M230.4,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.2,0-31.6,14.1-31.6,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C236.5,338.4,230.4,331.4,230.4,322.5 M38,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.4,0-31.8,14.1-31.8,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C44,338.4,38,331.4,38,322.5 M191.8,352.1h14.4v-59.8H190v33.1l-25-33.1h-15.4v60.1h16.2v-34.1L191.8,352.1z M411.2,352.1v-59.8h-17.7l-14.4,23.2l-14.4-23.2 h-17.7v60.1h16.4V318l15.4,23.2h0.3l15.4-23.5v34.6L411.2,352.1L411.2,352.1z" fill-rule="nonzero"></path></svg>'''
logo_html = "" logo_html = ""
for i, (name, url) in enumerate(zip(customer_names, logo_urls)): for i, (name, url) in enumerate(zip(customer_names, logo_urls)):
if url: if url:
contact_html = ""
if i < len(contacts) and contacts[i]:
contact_html = f'''<div class="contact-info">{md_to_html(contacts[i])}</div>'''
logo_html += f''' logo_html += f'''
<div class="customer-logo-item"> <div class="customer-logo-item">
<img src="{url}" alt="{name}" class="customer-logo" crossorigin="anonymous"> <img src="{url}" alt="{name}" class="customer-logo" crossorigin="anonymous">
<div class="customer-meta">
{contact_html}
</div>
</div>''' </div>'''
html_content = f"""<!DOCTYPE html> html_content = f"""<!DOCTYPE html>
@@ -165,29 +205,59 @@ def generate_welcome_html(customer_names, logo_urls, site="stuttgart", backgroun
}} }}
.customer-logos {{ .customer-logos {{
display: flex; flex-direction: row; display: flex; flex-direction: row;
align-items: center; justify-content: flex-end; align-items: flex-start; justify-content: flex-end;
gap: 48px; max-width: 100%; gap: 48px; max-width: 100%;
}} }}
.customer-logo-item {{ .customer-logo-item {{
flex: 0 0 280px; flex: 0 0 280px;
display: flex; align-items: center; justify-content: center; display: flex; flex-direction: column;
min-height: 100px; align-items: stretch; justify-content: flex-start;
}}
.customer-meta {{
width: 100%; text-align: left;
}} }}
.customer-logo {{ .customer-logo {{
width: 100%; height: auto; width: 100%; height: 120px;
max-height: 180px;
object-fit: contain; object-fit: contain;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.15)); filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.15));
display: block;
}}
.contact-info {{
font-size: 1.3rem; color: #333; line-height: 1.3;
margin-top: 24px; text-align: left;
}}
.contact-info strong {{ color: #000; }}
.contact-info code {{
font-size: 0.8rem; padding: 1px 4px;
background: rgba(0,0,0,0.06); border-radius: 3px;
}}
.contact-info ul {{
margin: 0; padding-left: 0;
list-style-position: inside;
}}
.contact-info li {{
margin: 0; padding: 0;
}} }}
@media (orientation: portrait) {{ @media (orientation: portrait) {{
.customer-logos {{ .customer-logos {{
flex-direction: column; gap: 24px; flex-direction: column; gap: 20px;
}} }}
.customer-logo-item {{ .customer-logo-item {{
flex: 0 0 200px; flex: 0 0 auto;
flex-direction: row; gap: 40px;
width: 100%; max-width: 600px;
}} }}
.customer-logo {{ .customer-logo {{
max-height: 120px; max-height: 100px; width: 180px; flex-shrink: 0;
order: 2;
}}
.contact-info {{
text-align: left;
}}
.customer-meta {{
display: flex; flex-direction: column;
align-items: flex-start; flex: 1;
order: 1;
}} }}
}} }}
.content {{ .content {{
@@ -226,15 +296,16 @@ def generate_welcome_html(customer_names, logo_urls, site="stuttgart", backgroun
# Speichern + Playlist-Einfügung # Speichern + Playlist-Einfügung
# ------------------------------------------------- # -------------------------------------------------
def save_welcome_page(customer_names, logo_urls, site="stuttgart", background_url=None): def save_welcome_page(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None):
"""Speichert die generierte Willkommensseite als welcome.html im Lobby-Verzeichnis. """Speichert die generierte Willkommensseite als welcome.html im Lobby-Verzeichnis.
contacts: Liste der Ansprechpartner-Texte (Markdown, optional)
background_url: Benutzerdefiniertes Hintergrundbild oder None (Standard). background_url: Benutzerdefiniertes Hintergrundbild oder None (Standard).
Gibt den Dateinamen zurück oder None bei Fehler.""" Gibt den Dateinamen zurück oder None bei Fehler."""
try: try:
welcome_dir = os.path.join(MEDIA_DIR, site, "lobby") welcome_dir = os.path.join(MEDIA_DIR, site, "lobby")
os.makedirs(welcome_dir, exist_ok=True) os.makedirs(welcome_dir, exist_ok=True)
filepath = os.path.join(os.path.abspath(welcome_dir), WELCOME_FILENAME) filepath = os.path.join(os.path.abspath(welcome_dir), WELCOME_FILENAME)
html_content = generate_welcome_html(customer_names, logo_urls, site=site, background_url=background_url) html_content = generate_welcome_html(customer_names, logo_urls, contacts=contacts, site=site, background_url=background_url)
with open(filepath, "w", encoding="utf-8") as f: with open(filepath, "w", encoding="utf-8") as f:
f.write(html_content) f.write(html_content)

View File

@@ -1,4 +1,184 @@
[ [
{
"timestamp": "2026-07-10 16:15:59",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (Trusted Device)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:15:56",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:15:51",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:13:50",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:13:46",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:11:43",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:11:39",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:10:18",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:10:14",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:07:35",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:07:27",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:45:29",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:45:20",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:44:47",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:41:11",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:40:59",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:40:56",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:40:37",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:40:28",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 14 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:40:03",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:39:57",
"action": "mfa_enabled",
"detail": "User 'erik.thiele@cancom.de' hat MFA aktiviert",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:39:32",
"action": "password_changed",
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' ge\u00e4ndert",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:39:22",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:39:15",
"action": "user_logout",
"detail": "User 'admin' abgemeldet",
"user": "admin"
},
{
"timestamp": "2026-07-10 15:38:55",
"action": "user_password_reset",
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' zur\u00fcckgesetzt (MFA deaktiviert)",
"user": "admin"
},
{
"timestamp": "2026-07-10 15:38:49",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-07-10 15:31:55",
"action": "user_logout",
"detail": "User 'admin' abgemeldet",
"user": "admin"
},
{
"timestamp": "2026-07-10 15:31:42",
"action": "user_password_reset",
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' zur\u00fcckgesetzt",
"user": "admin"
},
{
"timestamp": "2026-07-10 15:31:14",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-07-02 12:25:24",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{ {
"timestamp": "2026-06-28 16:04:39", "timestamp": "2026-06-28 16:04:39",
"action": "user_login", "action": "user_login",

File diff suppressed because it is too large Load Diff

View File

@@ -43,11 +43,11 @@
background-color: #ffffff; background-color: #ffffff;
} }
/* ─── Playlist-Zeile als Grid (Checkbox | Name | Typ | Größe | Löschen | Drag) ─── */ /* ─── Playlist-Zeile als Grid (Checkbox | Name | Typ | Größe | Löschen | Edit | Drag) ─── */
.playlist-row { .playlist-row {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
28px 1fr 90px 80px 90px 32px; 28px 1fr 90px 80px 50px 40px 32px;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
@@ -250,7 +250,7 @@
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}" title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
{% endif %}> {% endif %}>
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %} {{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
</span> </span>
<!-- Typ-Badge --> <!-- Typ-Badge -->
@@ -281,6 +281,23 @@
</button> </button>
</form> </form>
<!-- Bearbeiten (nur URLs) -->
{% if file.type == "url" %}
<button type="button"
class="btn btn-outline-secondary btn-sm edit-url-btn"
title="URL bearbeiten"
data-name="{{ file.name | e }}"
data-zoom="{{ file.zoom | default(1.0) }}"
data-site="{{ current_site }}"
data-screen="{{ screen }}"
data-oldname="{{ file.name | e }}"
data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}">
Edit
</button>
{% else %}
<span></span>
{% endif %}
<!-- Drag-Handle (SortableJS) --> <!-- Drag-Handle (SortableJS) -->
<span class="drag-handle" title="Reihenfolge ändern"></span> <span class="drag-handle" title="Reihenfolge ändern"></span>
</li> </li>
@@ -523,9 +540,26 @@
</div> <!-- /page --> </div> <!-- /page -->
<script> <script>
/* ─── Playlist-Item bearbeiten (URL + Zoom) ─── */
var editState = { site: "", screen: "", oldName: "" };
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */
document.addEventListener("click", function (e) {
var btn = e.target.closest(".edit-url-btn");
if (!btn) return;
e.preventDefault();
editState.site = btn.dataset.site;
editState.screen = btn.dataset.screen;
editState.oldName = btn.dataset.oldname;
document.getElementById("edit-url-input").value = btn.dataset.oldname;
document.getElementById("edit-zoom-input").value = btn.dataset.zoom;
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true";
document.getElementById("edit-url-error").style.display = "none";
new bootstrap.Modal(document.getElementById("edit-url-modal")).show();
});
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
/* ─── SortableJS Drag & Drop initialisieren ─── */
function initSortable(name) { function initSortable(name) {
const el = document.getElementById("playlist-" + name); const el = document.getElementById("playlist-" + name);
if (!el || typeof Sortable === "undefined") return; if (!el || typeof Sortable === "undefined") return;
@@ -577,8 +611,77 @@ document.addEventListener("DOMContentLoaded", function () {
return new bootstrap.Tooltip(tooltipTriggerEl); return new bootstrap.Tooltip(tooltipTriggerEl);
}); });
/* ─── Edit-URL-Modal: Speichern-Button ─── */
document.getElementById("edit-url-save").addEventListener("click", function () {
const newUrl = document.getElementById("edit-url-input").value.trim();
const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0;
const errorEl = document.getElementById("edit-url-error");
if (!newUrl.startsWith("http://") && !newUrl.startsWith("https://")) {
errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)";
errorEl.style.display = "block";
return;
}
errorEl.style.display = "none";
fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_name: editState.oldName,
new_url: newUrl,
zoom: zoom,
passthrough: document.getElementById("edit-passthrough-input").checked
})
}).then(function (r) {
if (r.ok) {
location.reload();
} else {
return r.json().then(function (d) {
errorEl.textContent = d.error || "Fehler beim Speichern";
errorEl.style.display = "block";
});
}
}).catch(function () {
errorEl.textContent = "Netzwerkfehler";
errorEl.style.display = "block";
});
});
}); });
</script> </script>
<!-- Modal: URL bearbeiten -->
<div class="modal fade" id="edit-url-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">URL bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">URL</label>
<input type="url" class="form-control" id="edit-url-input">
</div>
<div class="mb-3">
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
<input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="edit-passthrough-input">
<label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label>
</div>
<div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" id="edit-url-save">Speichern</button>
</div>
</div>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -129,29 +129,42 @@
<input type="text" class="form-control" id="customer_name_1" <input type="text" class="form-control" id="customer_name_1"
name="customer_name_1" placeholder="z. B. Beispiel GmbH" name="customer_name_1" placeholder="z. B. Beispiel GmbH"
value="{{ customer_names[0] if customer_names else '' }}"> value="{{ customer_names[0] if customer_names else '' }}">
<label class="form-label mt-2" for="contact_1">Ansprechpartner (optional)</label>
<textarea class="form-control" id="contact_1" name="contact_1" rows="4"
placeholder="**Max Mustermann**&#10;+49 123 4567890&#10;*E-Mail*: max@beispiel.de">{{ contacts[0] if contacts else '' }}</textarea>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label" for="customer_name_2">Kunde 2 (optional)</label> <label class="form-label" for="customer_name_2">Kunde 2 (optional)</label>
<input type="text" class="form-control" id="customer_name_2" <input type="text" class="form-control" id="customer_name_2"
name="customer_name_2" placeholder="z. B. Muster AG" name="customer_name_2" placeholder="z. B. Muster AG"
value="{{ customer_names[1] if customer_names else '' }}"> value="{{ customer_names[1] if customer_names else '' }}">
<label class="form-label mt-2" for="contact_2">Ansprechpartner (optional)</label>
<textarea class="form-control" id="contact_2" name="contact_2" rows="4"
placeholder="**Maria Schmidt**&#10;+49 456 7890123&#10;*E-Mail*: maria@muster.de">{{ contacts[1] if contacts else '' }}</textarea>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label" for="customer_name_3">Kunde 3 (optional)</label> <label class="form-label" for="customer_name_3">Kunde 3 (optional)</label>
<input type="text" class="form-control" id="customer_name_3" <input type="text" class="form-control" id="customer_name_3"
name="customer_name_3" placeholder="z. B. Firma GmbH" name="customer_name_3" placeholder="z. B. Firma GmbH"
value="{{ customer_names[2] if customer_names else '' }}"> value="{{ customer_names[2] if customer_names else '' }}">
<label class="form-label mt-2" for="contact_3">Ansprechpartner (optional)</label>
<textarea class="form-control" id="contact_3" name="contact_3" rows="4"
placeholder="**Sabine Weber**&#10;+49 789 0123456&#10;*E-Mail*: sabine@firma.de">{{ contacts[2] if contacts else '' }}</textarea>
</div> </div>
</div> </div>
<div class="mb-3 text-muted"> <div class="mb-3 text-muted">
<p class="mb-1">Das System sucht automatisch nach den Kundenlogos</p> <p class="mb-1">Das System sucht automatisch nach den Kundenlogos</p>
<p class="mb-0">und erstellt eine angepasste Willkommensseite für die Lobby-Playlist.</p> <p class="mb-0">und erstellt eine angepasste Willkommensseite für die Lobby-Playlist.</p>
<p class="mb-0 small"><i class="ti ti-markdown me-1"></i>Ansprechpartner unterstützen <strong>fett</strong>, <em>kursiv</em>, <code>Code</code>, Aufzählungen (<code>- </code>) sowie Zeilenumbrüche.</p>
</div> </div>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="ti ti-file-plus me-1"></i>Logos &amp; Willkommensseite generieren <i class="ti ti-file-plus me-1"></i>Logos &amp; Willkommensseite generieren
</button> </button>
<a href="?reset=1&amp;site={{ site }}" class="btn btn-outline-secondary ms-2">
<i class="ti ti-trash me-1"></i>Zurücksetzen
</a>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -105,6 +105,7 @@
<li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li> <li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li>
<li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden.</li> <li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden.</li>
<li><strong>Löschen:</strong> Über den roten Papierkorb-Button.</li> <li><strong>Löschen:</strong> Über den roten Papierkorb-Button.</li>
<li><strong>Query-Parameter-Passthrough:</strong> Per URL-Edit-Button (Stift-Icon) im Playlist-Eintrag kann pro URL gesteuert werden, ob Query-Parameter von der Player-URL an die Playlist-URL übergeben werden. Standardmäßig <strong>aus</strong> Checkbox "Query-Parameter an diese URL übergeben" aktivieren wenn gewünscht. Ein Link-Icon in der Playlist-Zeile zeigt den Status: <i class="ti ti-link"></i> = an, <i class="ti ti-link-off"></i> = aus.</li>
</ul> </ul>
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Änderungen an der Playlist werden automatisch gespeichert. Der Player lädt die Seite automatisch neu (via Hash-Prüfung).</p> <p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Änderungen an der Playlist werden automatisch gespeichert. Der Player lädt die Seite automatisch neu (via Hash-Prüfung).</p>
</div> </div>
@@ -193,7 +194,22 @@
<li>Aufruf über <code>/willkommen?site=&lt;standort&gt;</code> (oder Menüpunkt)</li> <li>Aufruf über <code>/willkommen?site=&lt;standort&gt;</code> (oder Menüpunkt)</li>
<li>Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Lobby-Playlist gesetzt</li> <li>Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Lobby-Playlist gesetzt</li>
<li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</li> <li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</li>
<li>Im Hochformat (Portrait) werden die Logos untereinander angezeigt</li> <li>Pro Kunde können <strong>Ansprechpartner</strong> eingegeben werden (Markdown-Format):
<ul>
<li><strong>Fett</strong>: <code>**Text**</code></li>
<li><em>Kursiv</em>: <code>*Text*</code></li>
<li><code>Code</code>: <code>`Code`</code></li>
<li>Aufzählungen: <code>- Punkt</code> oder <code>* Punkt</code></li>
<li>Zeilenumbrüche werden automatisch übernommen</li>
</ul>
</li>
<li><strong>Layout:</strong>
<ul>
<li>Landscape: Logos nebeneinander, Texte linksbündig darunter</li>
<li>Portrait: Texte <em>links</em> vom Logo, Abstand 40px</li>
</ul>
</li>
<li>Reset-Button setzt das Formular und die gespeicherten Daten zurück</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -42,6 +42,32 @@
maxlength="6" pattern="[0-9]{6}" required autofocus> maxlength="6" pattern="[0-9]{6}" required autofocus>
</div> </div>
<button class="btn btn-primary w-100 mb-2" type="submit">Bestätigen</button> <button class="btn btn-primary w-100 mb-2" type="submit">Bestätigen</button>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="remember" name="remember" value="7">
<label class="form-check-label" for="remember">
Gerät für
<select class="form-select form-select-sm d-inline-block w-auto" id="remember_days" onchange="document.getElementById('remember').value=this.value">
<option value="1">1 Tag</option>
<option value="3">3 Tage</option>
<option value="7" selected>7 Tage</option>
<option value="14">14 Tage</option>
<option value="30">30 Tage</option>
</select>
merken
</label>
</div>
<script>
(function() {
var cb = document.getElementById('remember');
var sel = document.getElementById('remember_days');
cb.addEventListener('change', function() {
if (this.checked) this.value = sel.value;
});
sel.addEventListener('change', function() {
if (cb.checked) cb.value = this.value;
});
})();
</script>
</form> </form>
<p class="text-center text-muted mt-3 small"> <p class="text-center text-muted mt-3 small">

View File

@@ -324,7 +324,7 @@
// ─── Custom-URL/Voice-Agent-Overlay (open/close mit Zurück-Button-Position-Tracking) ─── // ─── Custom-URL/Voice-Agent-Overlay (open/close mit Zurück-Button-Position-Tracking) ───
function openOverlay(url, pos) { function openOverlay(url, pos) {
document.getElementById("overlay").style.display = "block"; document.getElementById("overlay").style.display = "block";
document.getElementById("overlay-iframe").src = url; document.getElementById("overlay-iframe").src = appendParams(url, playerParams);
var backBtn = document.getElementById("overlay-back"); var backBtn = document.getElementById("overlay-back");
backBtn.className = "overlay-back-" + (pos || "top-left"); backBtn.className = "overlay-back-" + (pos || "top-left");
} }
@@ -352,6 +352,14 @@ const interval = {{ interval }} * 1000;
let stayOnFirst = {{ "true" if stay_on_first else "false" }}; let stayOnFirst = {{ "true" if stay_on_first else "false" }};
const screen = "{{ screen }}"; const screen = "{{ screen }}";
const site = "{{ site }}"; const site = "{{ site }}";
const playerParams = {{ player_params | tojson }};
// Query-Parameter aus der Player-URL an URLs anhängen
function appendParams(url, params) {
if (!params || Object.keys(params).length === 0) return url;
const sep = url.includes('?') ? '&' : '?';
return url + sep + new URLSearchParams(params).toString();
}
// stayOnFirst: Player bleibt auf erstem Element (kein Timer/Advance) // stayOnFirst: Player bleibt auf erstem Element (kein Timer/Advance)
// ─── Player-Status ─── // ─── Player-Status ───
@@ -448,7 +456,7 @@ function playNext() {
// URL im iframe anzeigen (mit Zoom-Unterstützung) // URL im iframe anzeigen (mit Zoom-Unterstützung)
if (item.kind === "url") { if (item.kind === "url") {
iframe.style.display = "block"; iframe.style.display = "block";
iframe.src = item.url; iframe.src = (item.passthrough === true) ? appendParams(item.url, playerParams) : item.url;
const zoom = item.zoom || 1.0; const zoom = item.zoom || 1.0;
if (zoom !== 1.0) { if (zoom !== 1.0) {
const scale = 1 / zoom; const scale = 1 / zoom;

View File

@@ -29,7 +29,7 @@
.playlist-row { .playlist-row {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
28px 1fr 90px 80px 90px 32px; 28px 1fr 90px 80px 50px 40px 32px;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
@@ -152,7 +152,7 @@
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}" title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
{% endif %}> {% endif %}>
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %} {{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
</span> </span>
{% if file.type == "image" %} {% if file.type == "image" %}
@@ -183,6 +183,23 @@
</button> </button>
</form> </form>
<!-- Bearbeiten (nur URLs) -->
{% if file.type == "url" %}
<button type="button"
class="btn btn-outline-secondary btn-sm edit-url-btn"
title="URL bearbeiten"
data-name="{{ file.name | e }}"
data-zoom="{{ file.zoom | default(1.0) }}"
data-site="{{ current_site }}"
data-screen="priority"
data-oldname="{{ file.name | e }}"
data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}">
Edit
</button>
{% else %}
<span></span>
{% endif %}
<!-- Drag --> <!-- Drag -->
<span class="drag-handle" title="Reihenfolge ändern"></span> <span class="drag-handle" title="Reihenfolge ändern"></span>
</li> </li>
@@ -236,6 +253,24 @@
</div> </div>
<script> <script>
/* ─── Global: editState ─── */
var editState = { site: "", screen: "", oldName: "" };
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */
document.addEventListener("click", function (e) {
var btn = e.target.closest(".edit-url-btn");
if (!btn) return;
e.preventDefault();
editState.site = btn.dataset.site;
editState.screen = btn.dataset.screen;
editState.oldName = btn.dataset.oldname;
document.getElementById("edit-url-input").value = btn.dataset.oldname;
document.getElementById("edit-zoom-input").value = btn.dataset.zoom;
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true";
document.getElementById("edit-url-error").style.display = "none";
new bootstrap.Modal(document.getElementById("edit-url-modal")).show();
});
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
function initSortable(name) { function initSortable(name) {
@@ -278,14 +313,83 @@ document.addEventListener("DOMContentLoaded", function () {
window.savePlaylist = savePlaylist; window.savePlaylist = savePlaylist;
// Initialize tooltips /* ─── Bootstrap Tooltips ─── */
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl); return new bootstrap.Tooltip(tooltipTriggerEl);
}); });
/* ─── Edit-URL-Modal: Speichern-Button ─── */
document.getElementById("edit-url-save").addEventListener("click", function () {
const newUrl = document.getElementById("edit-url-input").value.trim();
const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0;
const errorEl = document.getElementById("edit-url-error");
if (!newUrl.startsWith("http://") && !newUrl.startsWith("https://")) {
errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)";
errorEl.style.display = "block";
return;
}
errorEl.style.display = "none";
fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_name: editState.oldName,
new_url: newUrl,
zoom: zoom,
passthrough: document.getElementById("edit-passthrough-input").checked
})
}).then(function (r) {
if (r.ok) {
location.reload();
} else {
return r.json().then(function (d) {
errorEl.textContent = d.error || "Fehler beim Speichern";
errorEl.style.display = "block";
});
}
}).catch(function () {
errorEl.textContent = "Netzwerkfehler";
errorEl.style.display = "block";
});
});
}); });
</script> </script>
<!-- Modal: URL bearbeiten -->
<div class="modal fade" id="edit-url-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">URL bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">URL</label>
<input type="url" class="form-control" id="edit-url-input">
</div>
<div class="mb-3">
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
<input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="edit-passthrough-input">
<label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label>
</div>
<div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" id="edit-url-save">Speichern</button>
</div>
</div>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -15,10 +15,11 @@
"FC204A", "FC204A",
"1D09D7" "1D09D7"
], ],
"mfa_secret": "VOLBXT7HYK6BXBD32EGW7CMFLVD53QON" "mfa_secret": "VOLBXT7HYK6BXBD32EGW7CMFLVD53QON",
"trusted_devices": []
}, },
"erik.thiele@cancom.de": { "erik.thiele@cancom.de": {
"password_hash": "scrypt:32768:8:1$gyBs4dA7DhH9wA0u$c80f25262388095545733c95dd4e569eee610fa849a11dd28d4f504d09e23d3768adad26d4ed671975bd3dcbae5dc43bd982f4d475e6bc9d2ede088cb0b6c025", "password_hash": "scrypt:32768:8:1$D8XYprsEYMO5EPrG$2829b18f77bbd7d9f0cbb945b0bdbc32e16d7889f8711881f82dd35accd26ddb7c31d3fa85e3b89d19d6d1f0b92d8d18765e1c82044188cab8fd0db2d35a0d15",
"role": "admin", "role": "admin",
"sites": [], "sites": [],
"must_change_password": false, "must_change_password": false,
@@ -26,17 +27,23 @@
"last_name": "Thiele", "last_name": "Thiele",
"department": "CSO", "department": "CSO",
"notes": "Standort Stuttgart", "notes": "Standort Stuttgart",
"mfa_secret": "CHKS2QZOJ45TSCLUN7FWO3MLTMRSS6Q3", "mfa_secret": "LR5DYI3SQPMW5XWKO33HPETHY5TPOWRG",
"mfa_enabled": true, "mfa_enabled": true,
"mfa_recovery_codes": [ "mfa_recovery_codes": [
"1FB378", "7BBD93",
"AAD593", "EB1D3B",
"B4BCB9", "BEE291",
"9CB5C3", "D471D5",
"22D614", "D43C00",
"26F3B7", "75DBAC",
"47FB5C", "8B00CB",
"50BB59" "7AF72D"
],
"trusted_devices": [
{
"token": "5kMgj4JPxd2IHuzV5MagAAcYyi_DBImyGimBSvZUNVg",
"expires": "2026-07-24T15:40:28.955130"
}
] ]
}, },
"test1@test.de": { "test1@test.de": {
@@ -51,6 +58,7 @@
"first_name": "test", "first_name": "test",
"last_name": "tester", "last_name": "tester",
"department": "addff", "department": "addff",
"notes": "asdasdasd" "notes": "asdasdasd",
"trusted_devices": []
} }
} }