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
- Deaktivierung via `/mfa/disable` mit Passwort-Bestätigung
- 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
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
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
@@ -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>]`.
- 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).
- `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).
- 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).
@@ -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).
- `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.
- **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**.
- `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.
@@ -148,7 +152,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`.
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
- `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.
- `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`.

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
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung
- **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
- **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)
- **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
- Dark Mode (localStorage-persistiert)
- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot)
@@ -271,6 +272,17 @@ Beispiel:
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
```
@@ -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`).
**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)

172
app.py
View File

@@ -22,13 +22,15 @@ import pyotp
import qrcode
from functools import wraps
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from werkzeug.middleware.proxy_fix import ProxyFix
from flask import (
Flask, jsonify, render_template,
send_from_directory, redirect,
request, abort, flash, session
request, abort, flash, session, url_for
)
from flask_login import (
LoginManager, login_user, current_user,
@@ -53,6 +55,7 @@ UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
app = Flask(__name__)
app.secret_key = "CHANGE_THIS_SECRET!!!"
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
@app.context_processor
def inject_globals():
@@ -249,7 +252,8 @@ def load_priority_files():
"type": "url",
"size": "URL",
"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
@@ -340,7 +344,7 @@ class User(UserMixin):
def __init__(self, email, password_hash="", role="user", sites=None,
must_change_password=False, first_name="", last_name="",
department="", notes="", mfa_enabled=False, mfa_secret="",
mfa_recovery_codes=None):
mfa_recovery_codes=None, trusted_devices=None):
self.email = email
self.id = email
self.password_hash = password_hash
@@ -354,6 +358,7 @@ class User(UserMixin):
self.mfa_enabled = mfa_enabled
self.mfa_secret = mfa_secret
self.mfa_recovery_codes = mfa_recovery_codes or []
self.trusted_devices = trusted_devices or []
@property
def is_admin(self):
@@ -510,6 +515,33 @@ def login():
return redirect("/mfa/setup?recovered=1")
error = "Ungültiger Sicherheitscode"
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_password_ok"] = True
return redirect("/mfa/verify")
@@ -565,6 +597,29 @@ def mfa_verify():
if code and totp.verify(code):
login_user(user)
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)
if user.must_change_password:
return redirect("/change-password")
@@ -660,9 +715,12 @@ def mfa_disable():
users_data[email]["mfa_enabled"] = False
users_data[email]["mfa_secret"] = ""
users_data[email]["mfa_recovery_codes"] = []
users_data[email]["trusted_devices"] = []
save_users(users_data)
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
logo_urls = [None, None, None]
customer_names = ["", "", ""]
contacts = ["", "", ""]
site = request.args.get("site") or request.form.get("site") or ""
# 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_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]
print(f"🔍 APP.PY - Suche nach Kunden: {filled_names} (site: {site})")
@@ -722,6 +786,14 @@ def add_customer():
else:
valid_names = [n 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 j, (vn, vu) in enumerate(zip(valid_names, valid_urls)):
if vn == name:
@@ -729,14 +801,14 @@ def add_customer():
break
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 generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site):
# Letzte Kundendaten für Background-Update speichern
config = load_config()
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)
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!"
@@ -752,12 +824,26 @@ def add_customer():
config = load_config()
site_list = get_site_list(config)
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(
"customer.html",
error=error,
success=success,
logo_urls=logo_urls,
customer_names=customer_names,
contacts=contacts,
site=site,
site_list=site_list,
current_site=site,
@@ -769,6 +855,7 @@ def add_customer():
@app.route("/api/customer", methods=["POST"])
@login_required
def api_add_customer():
"""
API-Endpunkt für die Willkommensseite (JSON).
@@ -792,12 +879,17 @@ def api_add_customer():
if not site:
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)
if not logo_url:
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:
return jsonify({"error": "Failed to create welcome page"}), 500
@@ -889,7 +981,7 @@ def player(site, screen):
if is_url(item):
url_data = normalize_url(item)
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
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):
url_data = normalize_url(item)
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
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_label=screen_cfg.get("voice_agent_label", "Digitaler Assistent"),
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",
"size": "URL",
"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
@@ -1484,6 +1578,54 @@ def save_playlist(site, screen):
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)
# -------------------------------------------------
@@ -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)
users[email]["password_hash"] = generate_password_hash(temp_password)
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)
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}")

View File

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

View File

@@ -18,4 +18,5 @@ services:
- ALL
security_opt:
- 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():
"""Initialisiert den OpenAI-Client mit dem API-Key.
Der Key ist aktuell direkt im Code hinterlegt sollte in env var ausgelagert werden."""
api_key = "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"
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY environment variable not set")
return OpenAI(api_key=api_key)
@@ -97,14 +95,47 @@ def search_customer_logo(customer_name):
# 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.
- Links oben: CANCOM-Logo + "meets" + Kundenlogos (280px Breite)
- Rechts unten: "Herzlich Willkommen in unserer Niederlassung {Standort}"
- Links oben: CANCOM-Logo + "meets" + Kundenlogos
- Unter jedem Logo: Ansprechpartner-Kontaktdaten (Landscape)
- Portrait: Kontaktdaten links neben den Logos
customer_names: Liste der Kundennamen (max. 3)
logo_urls: Liste der Logo-URLs (gleiche Reihenfolge)
contacts: Liste der Ansprechpartner-Texte (Markdown, optional)
site: Standort-Name für die Anzeige
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})")
site_display = site.capitalize()
if contacts is None:
contacts = [""] * len(customer_names)
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>'''
logo_html = ""
for i, (name, url) in enumerate(zip(customer_names, logo_urls)):
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'''
<div class="customer-logo-item">
<img src="{url}" alt="{name}" class="customer-logo" crossorigin="anonymous">
<div class="customer-meta">
{contact_html}
</div>
</div>'''
html_content = f"""<!DOCTYPE html>
@@ -165,29 +205,59 @@ def generate_welcome_html(customer_names, logo_urls, site="stuttgart", backgroun
}}
.customer-logos {{
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%;
}}
.customer-logo-item {{
flex: 0 0 280px;
display: flex; align-items: center; justify-content: center;
min-height: 100px;
display: flex; flex-direction: column;
align-items: stretch; justify-content: flex-start;
}}
.customer-meta {{
width: 100%; text-align: left;
}}
.customer-logo {{
width: 100%; height: auto;
max-height: 180px;
width: 100%; height: 120px;
object-fit: contain;
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) {{
.customer-logos {{
flex-direction: column; gap: 24px;
flex-direction: column; gap: 20px;
}}
.customer-logo-item {{
flex: 0 0 200px;
flex: 0 0 auto;
flex-direction: row; gap: 40px;
width: 100%; max-width: 600px;
}}
.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 {{
@@ -226,15 +296,16 @@ def generate_welcome_html(customer_names, logo_urls, site="stuttgart", backgroun
# 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.
contacts: Liste der Ansprechpartner-Texte (Markdown, optional)
background_url: Benutzerdefiniertes Hintergrundbild oder None (Standard).
Gibt den Dateinamen zurück oder None bei Fehler."""
try:
welcome_dir = os.path.join(MEDIA_DIR, site, "lobby")
os.makedirs(welcome_dir, exist_ok=True)
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:
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",
"action": "user_login",

File diff suppressed because it is too large Load Diff

View File

@@ -43,11 +43,11 @@
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 {
display: grid;
grid-template-columns:
28px 1fr 90px 80px 90px 32px;
28px 1fr 90px 80px 50px 40px 32px;
align-items: center;
gap: 0.75rem;
}
@@ -250,7 +250,7 @@
data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% 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>
<!-- Typ-Badge -->
@@ -281,6 +281,23 @@
</button>
</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) -->
<span class="drag-handle" title="Reihenfolge ändern"></span>
</li>
@@ -523,9 +540,26 @@
</div> <!-- /page -->
<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 () {
/* ─── SortableJS Drag & Drop initialisieren ─── */
function initSortable(name) {
const el = document.getElementById("playlist-" + name);
if (!el || typeof Sortable === "undefined") return;
@@ -577,8 +611,77 @@ document.addEventListener("DOMContentLoaded", function () {
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>
<!-- 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>
</html>

View File

@@ -129,29 +129,42 @@
<input type="text" class="form-control" id="customer_name_1"
name="customer_name_1" placeholder="z. B. Beispiel GmbH"
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 class="col-md-4">
<label class="form-label" for="customer_name_2">Kunde 2 (optional)</label>
<input type="text" class="form-control" id="customer_name_2"
name="customer_name_2" placeholder="z. B. Muster AG"
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 class="col-md-4">
<label class="form-label" for="customer_name_3">Kunde 3 (optional)</label>
<input type="text" class="form-control" id="customer_name_3"
name="customer_name_3" placeholder="z. B. Firma GmbH"
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 class="mb-3 text-muted">
<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 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>
<button type="submit" class="btn btn-primary">
<i class="ti ti-file-plus me-1"></i>Logos &amp; Willkommensseite generieren
</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>
</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>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>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>
<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>
@@ -193,7 +194,22 @@
<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>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>
</div>
</div>

View File

@@ -42,6 +42,32 @@
maxlength="6" pattern="[0-9]{6}" required autofocus>
</div>
<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>
<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) ───
function openOverlay(url, pos) {
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");
backBtn.className = "overlay-back-" + (pos || "top-left");
}
@@ -352,6 +352,14 @@ const interval = {{ interval }} * 1000;
let stayOnFirst = {{ "true" if stay_on_first else "false" }};
const screen = "{{ screen }}";
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)
// ─── Player-Status ───
@@ -448,7 +456,7 @@ function playNext() {
// URL im iframe anzeigen (mit Zoom-Unterstützung)
if (item.kind === "url") {
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;
if (zoom !== 1.0) {
const scale = 1 / zoom;

View File

@@ -29,7 +29,7 @@
.playlist-row {
display: grid;
grid-template-columns:
28px 1fr 90px 80px 90px 32px;
28px 1fr 90px 80px 50px 40px 32px;
align-items: center;
gap: 0.75rem;
}
@@ -152,7 +152,7 @@
data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% 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>
{% if file.type == "image" %}
@@ -183,6 +183,23 @@
</button>
</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 -->
<span class="drag-handle" title="Reihenfolge ändern"></span>
</li>
@@ -236,6 +253,24 @@
</div>
<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 () {
function initSortable(name) {
@@ -278,14 +313,83 @@ document.addEventListener("DOMContentLoaded", function () {
window.savePlaylist = savePlaylist;
// Initialize tooltips
/* ─── Bootstrap Tooltips ─── */
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (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>
<!-- 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>
</html>

View File

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