Compare commits
2 Commits
35e22718e3
...
39aed78b91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39aed78b91 | ||
|
|
2a29025386 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,5 +8,4 @@ test*
|
|||||||
media/
|
media/
|
||||||
*.mp4
|
*.mp4
|
||||||
*.mov
|
*.mov
|
||||||
*.png
|
|
||||||
*.jpg
|
|
||||||
|
|||||||
418
app.py
418
app.py
@@ -11,8 +11,10 @@ zugeschaltet werden, die auf allen Playern abwechselnd eingeblendet wird.
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import re
|
||||||
import generate_welcome_page # Logo-Suche und Willkommensseite-Generierung
|
import generate_welcome_page # Logo-Suche und Willkommensseite-Generierung
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
@@ -21,11 +23,12 @@ from flask import (
|
|||||||
request, abort
|
request, abort
|
||||||
)
|
)
|
||||||
from flask_login import (
|
from flask_login import (
|
||||||
LoginManager, login_user,
|
LoginManager, login_user, current_user,
|
||||||
login_required, logout_user,
|
login_required, logout_user,
|
||||||
UserMixin
|
UserMixin
|
||||||
)
|
)
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@@ -34,8 +37,9 @@ from werkzeug.utils import secure_filename
|
|||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/Screen
|
MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/Screen
|
||||||
CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
|
CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
|
||||||
|
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
|
||||||
|
|
||||||
APP_VERSION = "5.5.0"
|
APP_VERSION = "6.0.1"
|
||||||
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
|
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -224,17 +228,112 @@ def media_background(site, filename):
|
|||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# User & Login (Flask-Login)
|
# User-Verwaltung (Flask-Login mit users.json)
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
class Admin(UserMixin):
|
class User(UserMixin):
|
||||||
"""Einfacher Admin-User. Es gibt nur einen Admin (keine DB)."""
|
"""User mit E-Mail (id), Rolle und Standort-Berechtigungen."""
|
||||||
id = 1
|
|
||||||
|
def __init__(self, email, password_hash="", role="user", sites=None, must_change_password=False):
|
||||||
|
self.email = email
|
||||||
|
self.id = email
|
||||||
|
self.password_hash = password_hash
|
||||||
|
self.role = role
|
||||||
|
self.sites = sites or []
|
||||||
|
self.must_change_password = must_change_password
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self):
|
||||||
|
return self.role == "admin"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_superuser(self):
|
||||||
|
return self.role == "superuser"
|
||||||
|
|
||||||
|
def can_access_site(self, site):
|
||||||
|
return self.is_admin or self.is_superuser or site in self.sites
|
||||||
|
|
||||||
|
|
||||||
|
def load_users():
|
||||||
|
"""Liest die users.json. Gibt leeres Dict zurück, wenn nicht vorhanden."""
|
||||||
|
if not os.path.exists(USERS_FILE):
|
||||||
|
return {}
|
||||||
|
with open(USERS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def save_users(users):
|
||||||
|
"""Schreibt das User-Dict zurück in die users.json."""
|
||||||
|
with open(USERS_FILE, "w") as f:
|
||||||
|
json.dump(users, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(email):
|
||||||
|
"""Lädt einen einzelnen User aus users.json. Gibt None zurück, wenn nicht gefunden."""
|
||||||
|
users = load_users()
|
||||||
|
data = users.get(email)
|
||||||
|
if data:
|
||||||
|
return User(email, **data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def init_user_db():
|
||||||
|
"""Legt users.json mit einem Admin-Konto aus config.json.admin an, falls sie nicht existiert."""
|
||||||
|
if os.path.exists(USERS_FILE):
|
||||||
|
return
|
||||||
|
cfg = load_config()
|
||||||
|
admin = cfg.get("admin", {})
|
||||||
|
username = admin.get("username", "admin@signage.local")
|
||||||
|
password = admin.get("password", "admin")
|
||||||
|
users = {
|
||||||
|
username: {
|
||||||
|
"password_hash": generate_password_hash(password),
|
||||||
|
"role": "admin",
|
||||||
|
"sites": [],
|
||||||
|
"must_change_password": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save_users(users)
|
||||||
|
print(f"✅ users.json mit Admin-Konto ({username}) angelegt (Passwort aus config.json)")
|
||||||
|
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
"""Lädt den Admin-User für Flask-Login. user_id wird ignoriert – es gibt nur einen User."""
|
"""Lädt einen User aus users.json anhand der E-Mail (Flask-Login-Callback)."""
|
||||||
return Admin()
|
return get_user(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Dekoratoren: Admin / Standort-Zugriff
|
||||||
|
# -------------------------------------------------
|
||||||
|
def admin_required(f):
|
||||||
|
"""Erfordert einen Admin-User (role=admin)."""
|
||||||
|
@wraps(f)
|
||||||
|
@login_required
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not current_user.is_admin:
|
||||||
|
abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def site_access_required(f):
|
||||||
|
"""Erfordert Zugriff auf den Standort (admin →alle, user →nur freigegebene)."""
|
||||||
|
@wraps(f)
|
||||||
|
@login_required
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
site = kwargs.get("site")
|
||||||
|
if site and not current_user.can_access_site(site):
|
||||||
|
abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def get_accessible_sites(cfg, user):
|
||||||
|
"""Gibt die Liste der Standorte zurück, auf die ein User Zugriff hat."""
|
||||||
|
all_sites = sorted(cfg.get("sites", {}).keys())
|
||||||
|
if user.is_admin or user.is_superuser:
|
||||||
|
return all_sites
|
||||||
|
return [s for s in all_sites if s in user.sites]
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@@ -242,20 +341,36 @@ def load_user(user_id):
|
|||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/login", methods=["GET", "POST"])
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login-Seite. Bei POST werden die Zugangsdaten aus config.json.admin geprüft."""
|
"""Login-Seite. Prüft Zugangsdaten gegen users.json (gehashte Passwörter)."""
|
||||||
config = load_config()
|
|
||||||
error = None
|
error = None
|
||||||
|
next_url = None
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if (
|
email = request.form.get("username", "").strip().lower()
|
||||||
request.form.get("username") == config["admin"].get("username")
|
password = request.form.get("password", "")
|
||||||
and request.form.get("password") == config["admin"].get("password")
|
|
||||||
):
|
user = get_user(email)
|
||||||
login_user(Admin())
|
if user:
|
||||||
return redirect("/admin")
|
if user.password_hash:
|
||||||
|
try:
|
||||||
|
valid = check_password_hash(user.password_hash, password)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
valid = False
|
||||||
|
else:
|
||||||
|
valid = False
|
||||||
|
user.must_change_password = True
|
||||||
|
|
||||||
|
if valid:
|
||||||
|
login_user(user)
|
||||||
|
if user.must_change_password:
|
||||||
|
return redirect("/change-password")
|
||||||
|
return redirect("/admin")
|
||||||
|
elif not user.password_hash:
|
||||||
|
login_user(user)
|
||||||
|
return redirect("/change-password")
|
||||||
error = "Ungültige Zugangsdaten"
|
error = "Ungültige Zugangsdaten"
|
||||||
|
|
||||||
return render_template("login.html", error=error)
|
return render_template("login.html", error=error, next=next_url)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/logout")
|
@app.route("/logout")
|
||||||
@@ -270,6 +385,7 @@ def logout():
|
|||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/willkommen", methods=["GET", "POST"])
|
@app.route("/willkommen", methods=["GET", "POST"])
|
||||||
@app.route("/customer", methods=["GET", "POST"])
|
@app.route("/customer", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
def add_customer():
|
def add_customer():
|
||||||
"""
|
"""
|
||||||
Formular für die Willkommensseite:
|
Formular für die Willkommensseite:
|
||||||
@@ -283,12 +399,16 @@ def add_customer():
|
|||||||
customer_names = ["", "", ""]
|
customer_names = ["", "", ""]
|
||||||
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 Standort, wenn keiner angegeben
|
# Fallback auf ersten für den User zugänglichen Standort
|
||||||
if not site:
|
if not site:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
sites = get_site_list(config)
|
sites = get_accessible_sites(config, current_user)
|
||||||
site = sites[0] if sites else ""
|
site = sites[0] if sites else ""
|
||||||
|
|
||||||
|
# Prüfen, ob der User Zugriff auf den Standort hat
|
||||||
|
if site and not current_user.can_access_site(site):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
customer_names = [
|
customer_names = [
|
||||||
request.form.get("customer_name_1", "").strip(),
|
request.form.get("customer_name_1", "").strip(),
|
||||||
@@ -582,18 +702,18 @@ def root_redirect():
|
|||||||
@app.route("/admin", strict_slashes=False)
|
@app.route("/admin", strict_slashes=False)
|
||||||
@login_required
|
@login_required
|
||||||
def admin_redirect():
|
def admin_redirect():
|
||||||
"""Leitet zum ersten konfigurierten Standort weiter."""
|
"""Leitet zum ersten für den User zugänglichen Standort weiter."""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
sites = get_site_list(cfg)
|
sites = get_accessible_sites(cfg, current_user)
|
||||||
if sites:
|
if sites:
|
||||||
return redirect(f"/admin/{sites[0]}")
|
return redirect(f"/admin/{sites[0]}")
|
||||||
return "<h1>Kein Standort konfiguriert</h1><p>Bitte lege einen Standort im media-Verzeichnis an.</p>", 200
|
return "<h1>Kein Standort konfiguriert</h1><p>Bitte lege einen Standort im media-Verzeichnis an.</p>", 200
|
||||||
|
|
||||||
|
|
||||||
@app.route("/add-site")
|
@app.route("/add-site")
|
||||||
@login_required
|
@admin_required
|
||||||
def add_site():
|
def add_site():
|
||||||
"""Legt einen neuen Standort an (Verzeichnis + Config-Eintrag)."""
|
"""Legt einen neuen Standort an (Verzeichnis + Config-Eintrag). Nur für Admins."""
|
||||||
name = request.args.get("name", "").strip().lower()
|
name = request.args.get("name", "").strip().lower()
|
||||||
if not name:
|
if not name:
|
||||||
return redirect("/admin")
|
return redirect("/admin")
|
||||||
@@ -607,9 +727,9 @@ def add_site():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/admin/<site>/delete-site", methods=["POST"])
|
@app.route("/admin/<site>/delete-site", methods=["POST"])
|
||||||
@login_required
|
@admin_required
|
||||||
def delete_site(site):
|
def delete_site(site):
|
||||||
"""Löscht einen Standort inkl. aller Screens und Medien (Config + Verzeichnis)."""
|
"""Löscht einen Standort inkl. aller Screens und Medien (Config + Verzeichnis). Nur für Admins."""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
cfg.get("sites", {}).pop(site, None)
|
cfg.get("sites", {}).pop(site, None)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
@@ -624,7 +744,7 @@ def delete_site(site):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/admin/<site>", strict_slashes=False)
|
@app.route("/admin/<site>", strict_slashes=False)
|
||||||
@login_required
|
@site_access_required
|
||||||
def admin(site):
|
def admin(site):
|
||||||
"""
|
"""
|
||||||
Admin-Dashboard für einen Standort.
|
Admin-Dashboard für einen Standort.
|
||||||
@@ -632,10 +752,10 @@ def admin(site):
|
|||||||
"""
|
"""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
|
||||||
site_list = get_site_list(cfg)
|
site_list = get_accessible_sites(cfg, current_user)
|
||||||
|
|
||||||
# Prüfen, ob der Standort existiert
|
# Prüfen, ob der Standort existiert
|
||||||
if site not in site_list:
|
if site not in get_site_list(cfg):
|
||||||
if os.path.isdir(os.path.join(MEDIA_DIR, site)):
|
if os.path.isdir(os.path.join(MEDIA_DIR, site)):
|
||||||
cfg.setdefault("sites", {})[site] = {"screens": {}}
|
cfg.setdefault("sites", {})[site] = {"screens": {}}
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
@@ -753,11 +873,11 @@ def admin(site):
|
|||||||
# Admin: Priority-Playlist (eigene Seite)
|
# Admin: Priority-Playlist (eigene Seite)
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/admin/<site>/priority")
|
@app.route("/admin/<site>/priority")
|
||||||
@login_required
|
@site_access_required
|
||||||
def admin_priority(site):
|
def admin_priority(site):
|
||||||
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
|
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
site_list = sorted(cfg.get("sites", {}).keys())
|
site_list = get_accessible_sites(cfg, current_user)
|
||||||
priority_files = load_priority_files()
|
priority_files = load_priority_files()
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -776,7 +896,7 @@ def admin_priority(site):
|
|||||||
# Admin: Hintergrundbild für Willkommensseite hochladen
|
# Admin: Hintergrundbild für Willkommensseite hochladen
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/admin/<site>/upload-background", methods=["POST"])
|
@app.route("/admin/<site>/upload-background", methods=["POST"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def upload_background(site):
|
def upload_background(site):
|
||||||
"""Lädt ein Hintergrundbild für die Willkommensseite hoch (pro Standort).
|
"""Lädt ein Hintergrundbild für die Willkommensseite hoch (pro Standort).
|
||||||
Gespeichert als media/<site>/background.<ext>."""
|
Gespeichert als media/<site>/background.<ext>."""
|
||||||
@@ -806,7 +926,7 @@ def upload_background(site):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/admin/<site>/delete-background", methods=["POST"])
|
@app.route("/admin/<site>/delete-background", methods=["POST"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def delete_background(site):
|
def delete_background(site):
|
||||||
"""Löscht das Hintergrundbild für die Willkommensseite."""
|
"""Löscht das Hintergrundbild für die Willkommensseite."""
|
||||||
site_dir = os.path.join(MEDIA_DIR, site)
|
site_dir = os.path.join(MEDIA_DIR, site)
|
||||||
@@ -825,7 +945,7 @@ def delete_background(site):
|
|||||||
# Admin: Screen-Einstellungen speichern
|
# Admin: Screen-Einstellungen speichern
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/admin/<site>/update/<screen>", methods=["POST"])
|
@app.route("/admin/<site>/update/<screen>", methods=["POST"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def update_screen(site, screen):
|
def update_screen(site, screen):
|
||||||
"""Speichert die allgemeinen Einstellungen für einen Screen (Intervall, Newsticker, Show-Bilder/Videos)."""
|
"""Speichert die allgemeinen Einstellungen für einen Screen (Intervall, Newsticker, Show-Bilder/Videos)."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -843,7 +963,7 @@ def update_screen(site, screen):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/admin/<site>/update-actions/<screen>", methods=["POST"])
|
@app.route("/admin/<site>/update-actions/<screen>", methods=["POST"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def update_screen_actions(site, screen):
|
def update_screen_actions(site, screen):
|
||||||
"""Speichert die Aktionen-Einstellungen für einen Screen (Custom-URL + Position)."""
|
"""Speichert die Aktionen-Einstellungen für einen Screen (Custom-URL + Position)."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -863,7 +983,7 @@ def update_screen_actions(site, screen):
|
|||||||
# Admin: Voice-Agent-Einstellungen speichern
|
# Admin: Voice-Agent-Einstellungen speichern
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/admin/<site>/update-voice/<screen>", methods=["POST"])
|
@app.route("/admin/<site>/update-voice/<screen>", methods=["POST"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def update_screen_voice(site, screen):
|
def update_screen_voice(site, screen):
|
||||||
"""Speichert die Voice-Agent-Einstellungen für einen Screen (Enabled, Label, Target, Position)."""
|
"""Speichert die Voice-Agent-Einstellungen für einen Screen (Enabled, Label, Target, Position)."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -883,7 +1003,7 @@ def update_screen_voice(site, screen):
|
|||||||
# Admin: Screen löschen
|
# Admin: Screen löschen
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/admin/<site>/delete-screen/<screen>", methods=["POST"])
|
@app.route("/admin/<site>/delete-screen/<screen>", methods=["POST"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def delete_screen(site, screen):
|
def delete_screen(site, screen):
|
||||||
"""Löscht einen Screen (Config-Eintrag + Medien-Verzeichnis)."""
|
"""Löscht einen Screen (Config-Eintrag + Medien-Verzeichnis)."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -904,7 +1024,7 @@ def delete_screen(site, screen):
|
|||||||
# Admin: Neuen Screen anlegen
|
# Admin: Neuen Screen anlegen
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/admin/<site>/add-screen", methods=["GET"])
|
@app.route("/admin/<site>/add-screen", methods=["GET"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def add_screen(site):
|
def add_screen(site):
|
||||||
"""Legt einen neuen Screen an (Verzeichnis + Config-Eintrag)."""
|
"""Legt einen neuen Screen an (Verzeichnis + Config-Eintrag)."""
|
||||||
screen_name = request.args.get("name", "").strip().lower()
|
screen_name = request.args.get("name", "").strip().lower()
|
||||||
@@ -927,7 +1047,7 @@ def add_screen(site):
|
|||||||
# Admin: Medien-Upload
|
# Admin: Medien-Upload
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/admin/<site>/upload/<screen>", methods=["POST"])
|
@app.route("/admin/<site>/upload/<screen>", methods=["POST"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def upload(site, screen):
|
def upload(site, screen):
|
||||||
"""Lädt eine Datei hoch (Bild oder Video). Bei screen="priority" ins globale Verzeichnis."""
|
"""Lädt eine Datei hoch (Bild oder Video). Bei screen="priority" ins globale Verzeichnis."""
|
||||||
file = request.files.get("file")
|
file = request.files.get("file")
|
||||||
@@ -952,7 +1072,7 @@ def upload(site, screen):
|
|||||||
# Admin: URL zur Playlist hinzufügen
|
# Admin: URL zur Playlist hinzufügen
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/admin/<site>/add-url/<screen>", methods=["POST"])
|
@app.route("/admin/<site>/add-url/<screen>", methods=["POST"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def add_url(site, screen):
|
def add_url(site, screen):
|
||||||
"""Fügt eine URL (mit Zoom-Faktor) zur Playlist eines Screens oder der Priority-Playlist hinzu."""
|
"""Fügt eine URL (mit Zoom-Faktor) zur Playlist eines Screens oder der Priority-Playlist hinzu."""
|
||||||
url = request.form.get("url", "").strip()
|
url = request.form.get("url", "").strip()
|
||||||
@@ -985,7 +1105,7 @@ def add_url(site, screen):
|
|||||||
# Admin: Datei aus Playlist löschen
|
# Admin: Datei aus Playlist löschen
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/admin/<site>/delete/<screen>/<path:filename>", methods=["POST"])
|
@app.route("/admin/<site>/delete/<screen>/<path:filename>", methods=["POST"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def delete_file(site, screen, filename):
|
def delete_file(site, screen, filename):
|
||||||
"""Löscht eine Datei aus der Playlist (Config + Dateisystem)."""
|
"""Löscht eine Datei aus der Playlist (Config + Dateisystem)."""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
@@ -1017,7 +1137,7 @@ def delete_file(site, screen, filename):
|
|||||||
# Admin: Playlist-Reihenfolge speichern (Drag & Drop)
|
# Admin: Playlist-Reihenfolge speichern (Drag & Drop)
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/admin/<site>/playlist/<screen>", methods=["POST"])
|
@app.route("/admin/<site>/playlist/<screen>", methods=["POST"])
|
||||||
@login_required
|
@site_access_required
|
||||||
def save_playlist(site, screen):
|
def save_playlist(site, screen):
|
||||||
"""
|
"""
|
||||||
Speichert die über SortableJS per Drag & Drop neu sortierte Playlist.
|
Speichert die über SortableJS per Drag & Drop neu sortierte Playlist.
|
||||||
@@ -1058,8 +1178,224 @@ def save_playlist(site, screen):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Change-Password (für First-Login / Reset)
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/change-password", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def change_password():
|
||||||
|
"""Seite zum Ändern des Passworts (bei First-Login oder nach Admin-Reset)."""
|
||||||
|
cfg = load_config()
|
||||||
|
site_list = get_accessible_sites(cfg, current_user)
|
||||||
|
current_site = site_list[0] if site_list else ""
|
||||||
|
error = None
|
||||||
|
success = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
new_pw = request.form.get("new_password", "")
|
||||||
|
confirm = request.form.get("confirm_password", "")
|
||||||
|
|
||||||
|
if not new_pw or len(new_pw) < 6:
|
||||||
|
error = "Passwort muss mindestens 6 Zeichen lang sein"
|
||||||
|
elif new_pw != confirm:
|
||||||
|
error = "Passwörter stimmen nicht überein"
|
||||||
|
else:
|
||||||
|
users = load_users()
|
||||||
|
email = current_user.email
|
||||||
|
if email in users:
|
||||||
|
users[email]["password_hash"] = generate_password_hash(new_pw)
|
||||||
|
users[email]["must_change_password"] = False
|
||||||
|
save_users(users)
|
||||||
|
return redirect("/admin")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"change_password.html",
|
||||||
|
error=error,
|
||||||
|
success=success,
|
||||||
|
site_list=site_list,
|
||||||
|
current_site=current_site,
|
||||||
|
version=APP_VERSION,
|
||||||
|
year=datetime.now().year,
|
||||||
|
hostname=os.uname().nodename,
|
||||||
|
server_url=cfg.get("server_url", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# User-Verwaltung (nur für Admins)
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/admin/users")
|
||||||
|
@admin_required
|
||||||
|
def admin_users():
|
||||||
|
"""Listet alle User."""
|
||||||
|
cfg = load_config()
|
||||||
|
users_data = load_users()
|
||||||
|
|
||||||
|
users_list = []
|
||||||
|
for email, data in users_data.items():
|
||||||
|
users_list.append({
|
||||||
|
"email": email,
|
||||||
|
"role": data.get("role", "user"),
|
||||||
|
"sites": data.get("sites", []),
|
||||||
|
"must_change_password": data.get("must_change_password", False)
|
||||||
|
})
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"user_list.html",
|
||||||
|
users=sorted(users_list, key=lambda u: u["email"]),
|
||||||
|
site_list=get_accessible_sites(cfg, current_user),
|
||||||
|
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
||||||
|
version=APP_VERSION,
|
||||||
|
year=datetime.now().year,
|
||||||
|
hostname=os.uname().nodename,
|
||||||
|
server_url=cfg.get("server_url", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users/create", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def admin_users_create_page():
|
||||||
|
"""Zeigt das Formular zum Anlegen eines neuen Users."""
|
||||||
|
cfg = load_config()
|
||||||
|
all_sites = get_site_list(cfg)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"user_create.html",
|
||||||
|
all_sites=all_sites,
|
||||||
|
site_list=get_accessible_sites(cfg, current_user),
|
||||||
|
current_site=all_sites[0] if all_sites else "",
|
||||||
|
version=APP_VERSION,
|
||||||
|
year=datetime.now().year,
|
||||||
|
hostname=os.uname().nodename,
|
||||||
|
server_url=cfg.get("server_url", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users/create", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def admin_users_create():
|
||||||
|
"""Legt einen neuen User an (E-Mail=Username, Passwort, Rolle, Standorte)."""
|
||||||
|
email = request.form.get("email", "").strip().lower()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
role = request.form.get("role", "user")
|
||||||
|
sites = request.form.getlist("sites")
|
||||||
|
|
||||||
|
# Validierung
|
||||||
|
if not email or not re.match(r"[^@]+@[^@]+\.[^@]+", email):
|
||||||
|
return "Ungültige E-Mail-Adresse", 400
|
||||||
|
if not password or len(password) < 6:
|
||||||
|
return "Passwort muss mindestens 6 Zeichen lang sein", 400
|
||||||
|
if role not in ("admin", "superuser", "user"):
|
||||||
|
return "Ungültige Rolle", 400
|
||||||
|
|
||||||
|
users = load_users()
|
||||||
|
if email in users:
|
||||||
|
return "User existiert bereits", 400
|
||||||
|
|
||||||
|
users[email] = {
|
||||||
|
"password_hash": generate_password_hash(password),
|
||||||
|
"role": role,
|
||||||
|
"sites": sites if role == "user" else [],
|
||||||
|
"must_change_password": True
|
||||||
|
}
|
||||||
|
save_users(users)
|
||||||
|
return redirect("/admin/users")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users/edit/<path:email>", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def admin_users_edit(email):
|
||||||
|
"""Bearbeitet einen User (E-Mail, Rolle, Standorte)."""
|
||||||
|
cfg = load_config()
|
||||||
|
all_sites = get_site_list(cfg)
|
||||||
|
users_data = load_users()
|
||||||
|
|
||||||
|
if email not in users_data:
|
||||||
|
return "User nicht gefunden", 404
|
||||||
|
|
||||||
|
error = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
new_email = request.form.get("email", "").strip().lower()
|
||||||
|
new_role = request.form.get("role", "user")
|
||||||
|
new_sites = request.form.getlist("sites")
|
||||||
|
|
||||||
|
if not new_email or not re.match(r"[^@]+@[^@]+\.[^@]+", new_email):
|
||||||
|
error = "Ungültige E-Mail-Adresse"
|
||||||
|
elif new_role not in ("admin", "superuser", "user"):
|
||||||
|
error = "Ungültige Rolle"
|
||||||
|
else:
|
||||||
|
user_data = users_data.pop(email)
|
||||||
|
user_data["role"] = new_role
|
||||||
|
user_data["sites"] = new_sites if new_role == "user" else []
|
||||||
|
users_data[new_email] = user_data
|
||||||
|
save_users(users_data)
|
||||||
|
return redirect(f"/admin/users?updated={new_email}")
|
||||||
|
|
||||||
|
user = {
|
||||||
|
"email": email,
|
||||||
|
"role": users_data[email].get("role", "user"),
|
||||||
|
"sites": users_data[email].get("sites", []),
|
||||||
|
"must_change_password": users_data[email].get("must_change_password", False)
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"user_edit.html",
|
||||||
|
user=user,
|
||||||
|
all_sites=all_sites,
|
||||||
|
error=error,
|
||||||
|
site_list=get_accessible_sites(cfg, current_user),
|
||||||
|
current_site=all_sites[0] if all_sites else "",
|
||||||
|
version=APP_VERSION,
|
||||||
|
year=datetime.now().year,
|
||||||
|
hostname=os.uname().nodename,
|
||||||
|
server_url=cfg.get("server_url", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users/delete/<path:email>", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def admin_users_delete(email):
|
||||||
|
"""Löscht einen User. Der eigene Account kann nicht gelöscht werden.
|
||||||
|
Der letzte verbleibende Admin kann nicht gelöscht werden."""
|
||||||
|
if email == current_user.email:
|
||||||
|
return "Du kannst dich nicht selbst löschen", 400
|
||||||
|
|
||||||
|
users = load_users()
|
||||||
|
|
||||||
|
# Prüfen, ob es der letzte Admin wäre
|
||||||
|
if users.get(email, {}).get("role") == "admin":
|
||||||
|
admin_count = sum(1 for u in users.values() if u.get("role") == "admin")
|
||||||
|
if admin_count <= 1:
|
||||||
|
return "Letzter Admin kann nicht gelöscht werden", 400
|
||||||
|
|
||||||
|
users.pop(email, None)
|
||||||
|
save_users(users)
|
||||||
|
return redirect("/admin/users")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users/reset-password/<path:email>", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def admin_users_reset_password(email):
|
||||||
|
"""Setzt ein temporäres Passwort und zwingt User zur Änderung beim nächsten Login."""
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
users = load_users()
|
||||||
|
if email not in users:
|
||||||
|
return "User nicht gefunden", 404
|
||||||
|
|
||||||
|
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
|
||||||
|
save_users(users)
|
||||||
|
|
||||||
|
return redirect(f"/admin/users?reset={email}&temp={temp_password}")
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# Main
|
# Main
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
init_user_db()
|
||||||
app.run(debug=True, host="0.0.0.0", port=5005)
|
app.run(debug=True, host="0.0.0.0", port=5005)
|
||||||
|
|||||||
15
config.json
15
config.json
@@ -15,9 +15,9 @@
|
|||||||
"stuttgart": {
|
"stuttgart": {
|
||||||
"screens": {
|
"screens": {
|
||||||
"lobby": {
|
"lobby": {
|
||||||
"interval": 10,
|
"interval": 15,
|
||||||
"show_images": false,
|
"show_images": true,
|
||||||
"show_videos": false,
|
"show_videos": true,
|
||||||
"playlist": [
|
"playlist": [
|
||||||
"welcome.html",
|
"welcome.html",
|
||||||
{
|
{
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
"enabled": false
|
"enabled": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"newsticker_text": "",
|
"newsticker_text": "HErzlich Willkommen in Stuttgart - wir w\u00fcnschen ihnen einen erfolgreichen Aufenthalt",
|
||||||
"newsticker_enabled": false,
|
"newsticker_enabled": true,
|
||||||
"custom_url": "https://dva-stage.cancom-ds.de/voice-agent",
|
"custom_url": "https://dva-stage.cancom-ds.de/voice-agent",
|
||||||
"custom_url_label": "Welcome Agent",
|
"custom_url_label": "Welcome Agent",
|
||||||
"custom_url_enabled": false,
|
"custom_url_enabled": false,
|
||||||
@@ -44,9 +44,10 @@
|
|||||||
"voice_agent_enabled": true,
|
"voice_agent_enabled": true,
|
||||||
"voice_agent_label": "Digitaler Assistent",
|
"voice_agent_label": "Digitaler Assistent",
|
||||||
"voice_agent_target": "overlay",
|
"voice_agent_target": "overlay",
|
||||||
"voice_agent_position": "top-left",
|
"voice_agent_position": "bottom-left",
|
||||||
"custom_url_position": "top-left",
|
"custom_url_position": "top-left",
|
||||||
"voice_agent_show_image": false
|
"voice_agent_show_image": false,
|
||||||
|
"stay_on_first": false
|
||||||
},
|
},
|
||||||
"casino": {
|
"casino": {
|
||||||
"interval": 15,
|
"interval": 15,
|
||||||
|
|||||||
@@ -179,6 +179,17 @@ def generate_welcome_html(customer_names, logo_urls, site="stuttgart", backgroun
|
|||||||
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));
|
||||||
}}
|
}}
|
||||||
|
@media (orientation: portrait) {{
|
||||||
|
.customer-logos {{
|
||||||
|
flex-direction: column; gap: 24px;
|
||||||
|
}}
|
||||||
|
.customer-logo-item {{
|
||||||
|
flex: 0 0 200px;
|
||||||
|
}}
|
||||||
|
.customer-logo {{
|
||||||
|
max-height: 120px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
.content {{
|
.content {{
|
||||||
text-align: right; z-index: 10;
|
text-align: right; z-index: 10;
|
||||||
}}
|
}}
|
||||||
|
|||||||
4799
session-ses_1e96.md
4799
session-ses_1e96.md
File diff suppressed because it is too large
Load Diff
BIN
static/dva.png
Normal file
BIN
static/dva.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 395 KiB |
BIN
static/wallpaper.png
Normal file
BIN
static/wallpaper.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -42,10 +42,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logout -->
|
<!-- User-Dropdown (Passwort ändern, Userverwaltung, Abmelden) -->
|
||||||
<div class="ms-2">
|
{% if current_user.is_authenticated %}
|
||||||
<a href="/logout" class="btn btn-outline-secondary"><i class="ti ti-logout me-1"></i>Abmelden</a>
|
<div class="dropdown ms-2">
|
||||||
|
<a class="btn btn-outline-secondary text-nowrap" href="#" data-bs-toggle="dropdown">
|
||||||
|
<i class="ti ti-user me-1"></i>{{ current_user.email }}{% if current_user.is_admin %}<span class="badge bg-red text-red-fg ms-2" style="font-size:0.6rem;">Admin</span>{% elif current_user.is_superuser %}<span class="badge bg-purple text-purple-fg ms-2" style="font-size:0.6rem;">Superuser</span>{% else %}<span class="badge bg-blue text-blue-fg ms-2" style="font-size:0.6rem;">User</span>{% endif %} <i class="ti ti-chevron-down"></i>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end">
|
||||||
|
<a class="dropdown-item" href="/change-password"><i class="ti ti-key me-1"></i>Passwort ändern</a>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a class="dropdown-item" href="/admin/users"><i class="ti ti-users me-1"></i>Userverwaltung</a>
|
||||||
|
{% endif %}
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<a class="dropdown-item" href="/logout"><i class="ti ti-logout me-1"></i>Abmelden</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
75
templates/change_password.html
Normal file
75
templates/change_password.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CANCOM Simple Signage – Passwort ändern</title>
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const stored = window.localStorage.getItem("signage-theme");
|
||||||
|
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||||
|
<script defer
|
||||||
|
src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
|
||||||
|
{% include "_styles.html" %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
{% set brand_subtitle = "Passwort ändern" %}
|
||||||
|
{% set site_dropdown_url_prefix = "/admin/" %}
|
||||||
|
{% include "_header.html" %}
|
||||||
|
|
||||||
|
<div class="navbar-expand-md">
|
||||||
|
<div class="collapse navbar-collapse show">
|
||||||
|
<div class="navbar navbar-dark nav-surface">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="d-flex flex-wrap gap-2 py-2">
|
||||||
|
<a class="btn btn-white" href="/admin"><i class="ti ti-arrow-left me-1"></i>Zurück</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="container-xl mt-4" style="max-width:480px;">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-3">Passwort ändern</h2>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success" role="alert">{{ success }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Neues Passwort</label>
|
||||||
|
<input type="password" class="form-control" name="new_password" minlength="6" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Passwort wiederholen</label>
|
||||||
|
<input type="password" class="form-control" name="confirm_password" minlength="6" required>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100">Passwort speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "_footer.html" %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Benutzername</label>
|
<label class="form-label">E-Mail / Benutzername</label>
|
||||||
<input class="form-control" name="username" required>
|
<input class="form-control" name="username" placeholder="admin" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Passwort</label>
|
<label class="form-label">Passwort</label>
|
||||||
|
|||||||
124
templates/user_create.html
Normal file
124
templates/user_create.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CANCOM Simple Signage – User anlegen</title>
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const stored = window.localStorage.getItem("signage-theme");
|
||||||
|
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||||
|
<script defer
|
||||||
|
src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
|
||||||
|
{% include "_styles.html" %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
{% set brand_subtitle = "User anlegen" %}
|
||||||
|
{% set site_dropdown_url_prefix = "/admin/" %}
|
||||||
|
{% include "_header.html" %}
|
||||||
|
|
||||||
|
<div class="navbar-expand-md">
|
||||||
|
<div class="collapse navbar-collapse show">
|
||||||
|
<div class="navbar navbar-dark nav-surface">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="d-flex flex-wrap gap-2 py-2">
|
||||||
|
<a class="btn btn-white" href="/admin/{{ current_site }}"><i class="ti ti-arrow-left me-1"></i>Zurück</a>
|
||||||
|
<a class="btn btn-white" href="/admin/users"><i class="ti ti-users me-1"></i>Userliste</a>
|
||||||
|
<a class="btn btn-white active" href="/admin/users/create"><i class="ti ti-user-plus me-1"></i>Neu anlegen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="container-xl mt-4" style="max-width:600px;">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title"><i class="ti ti-user-plus me-1"></i>Neuen User anlegen</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="/admin/users/create" method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">E-Mail (Username)</label>
|
||||||
|
<input type="email" name="email" class="form-control" placeholder="user@example.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Passwort</label>
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="Passwort" minlength="6" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Rolle</label>
|
||||||
|
<select name="role" class="form-select" id="role-select">
|
||||||
|
<option value="user" selected>User</option>
|
||||||
|
<option value="superuser">Superuser</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="sites-select-wrapper">
|
||||||
|
<label class="form-label">Standort-Berechtigungen</label>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-outline-secondary w-100 text-start" type="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<span id="sites-btn-text">Standorte auswählen</span>
|
||||||
|
<i class="ti ti-chevron-down float-end"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu p-2" style="min-width:220px;" id="sites-dropdown-menu">
|
||||||
|
{% for s in all_sites %}
|
||||||
|
<label class="dropdown-item d-flex align-items-center gap-2 px-2 py-1">
|
||||||
|
<input class="form-check-input m-0" type="checkbox" name="sites" value="{{ s }}">
|
||||||
|
<span>{{ s | capitalize }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">User anlegen</button>
|
||||||
|
<a href="/admin/users" class="btn btn-outline-secondary ms-2">Abbrechen</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "_footer.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const roleSelect = document.getElementById("role-select");
|
||||||
|
const sitesWrapper = document.getElementById("sites-select-wrapper");
|
||||||
|
roleSelect.addEventListener("change", function () {
|
||||||
|
sitesWrapper.style.display = (this.value === "admin" || this.value === "superuser") ? "none" : "block";
|
||||||
|
});
|
||||||
|
if (roleSelect.value === "admin" || roleSelect.value === "superuser") sitesWrapper.style.display = "none";
|
||||||
|
|
||||||
|
const dropdownMenu = document.getElementById("sites-dropdown-menu");
|
||||||
|
const btnText = document.getElementById("sites-btn-text");
|
||||||
|
if (dropdownMenu) {
|
||||||
|
dropdownMenu.addEventListener("change", function () {
|
||||||
|
const checked = dropdownMenu.querySelectorAll("input:checked");
|
||||||
|
const total = dropdownMenu.querySelectorAll("input").length;
|
||||||
|
btnText.textContent = checked.length === 0
|
||||||
|
? "Standorte auswählen"
|
||||||
|
: checked.length === total
|
||||||
|
? "Alle Standorte"
|
||||||
|
: checked.length + " von " + total + " ausgewählt";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
131
templates/user_edit.html
Normal file
131
templates/user_edit.html
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CANCOM Simple Signage – User bearbeiten</title>
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const stored = window.localStorage.getItem("signage-theme");
|
||||||
|
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||||
|
<script defer
|
||||||
|
src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
|
||||||
|
{% include "_styles.html" %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
{% set brand_subtitle = "User bearbeiten" %}
|
||||||
|
{% set site_dropdown_url_prefix = "/admin/" %}
|
||||||
|
{% include "_header.html" %}
|
||||||
|
|
||||||
|
<div class="navbar-expand-md">
|
||||||
|
<div class="collapse navbar-collapse show">
|
||||||
|
<div class="navbar navbar-dark nav-surface">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="d-flex flex-wrap gap-2 py-2">
|
||||||
|
<a class="btn btn-white" href="/admin"><i class="ti ti-arrow-left me-1"></i>Zurück</a>
|
||||||
|
<a class="btn btn-white" href="/admin/users"><i class="ti ti-users me-1"></i>Userliste</a>
|
||||||
|
<a class="btn btn-white active" href="#"><i class="ti ti-edit me-1"></i>Bearbeiten</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="container-xl mt-4" style="max-width:600px;">
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title"><i class="ti ti-edit me-1"></i>User bearbeiten</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="/admin/users/edit/{{ user.email }}" method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">E-Mail (Username)</label>
|
||||||
|
<input type="email" name="email" class="form-control" value="{{ user.email }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Rolle</label>
|
||||||
|
<select name="role" class="form-select" id="role-select">
|
||||||
|
<option value="user" {% if user.role == "user" %}selected{% endif %}>User</option>
|
||||||
|
<option value="superuser" {% if user.role == "superuser" %}selected{% endif %}>Superuser</option>
|
||||||
|
<option value="admin" {% if user.role == "admin" %}selected{% endif %}>Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="sites-select-wrapper" {% if user.role == "admin" or user.role == "superuser" %}style="display:none"{% endif %}>
|
||||||
|
<label class="form-label">Standort-Berechtigungen</label>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-outline-secondary w-100 text-start" type="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<span id="sites-btn-text">
|
||||||
|
{%- set checked_sites = user.sites -%}
|
||||||
|
{%- if checked_sites -%}
|
||||||
|
{{ checked_sites|length }} von {{ all_sites|length }} ausgewählt
|
||||||
|
{%- else -%}
|
||||||
|
Standorte auswählen
|
||||||
|
{%- endif -%}
|
||||||
|
</span>
|
||||||
|
<i class="ti ti-chevron-down float-end"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu p-2" style="min-width:220px;" id="sites-dropdown-menu">
|
||||||
|
{% for s in all_sites %}
|
||||||
|
<label class="dropdown-item d-flex align-items-center gap-2 px-2 py-1">
|
||||||
|
<input class="form-check-input m-0" type="checkbox" name="sites" value="{{ s }}"
|
||||||
|
{% if s in user.sites %}checked{% endif %}>
|
||||||
|
<span>{{ s | capitalize }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
<a href="/admin/users" class="btn btn-outline-secondary ms-2">Abbrechen</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "_footer.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const roleSelect = document.getElementById("role-select");
|
||||||
|
const sitesWrapper = document.getElementById("sites-select-wrapper");
|
||||||
|
roleSelect.addEventListener("change", function () {
|
||||||
|
sitesWrapper.style.display = (this.value === "admin" || this.value === "superuser") ? "none" : "block";
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropdownMenu = document.getElementById("sites-dropdown-menu");
|
||||||
|
const btnText = document.getElementById("sites-btn-text");
|
||||||
|
if (dropdownMenu) {
|
||||||
|
dropdownMenu.addEventListener("change", function () {
|
||||||
|
const checked = dropdownMenu.querySelectorAll("input:checked");
|
||||||
|
const total = dropdownMenu.querySelectorAll("input").length;
|
||||||
|
btnText.textContent = checked.length === 0
|
||||||
|
? "Standorte auswählen"
|
||||||
|
: checked.length === total
|
||||||
|
? "Alle Standorte"
|
||||||
|
: checked.length + " von " + total + " ausgewählt";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
141
templates/user_list.html
Normal file
141
templates/user_list.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CANCOM Simple Signage – Userverwaltung</title>
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const stored = window.localStorage.getItem("signage-theme");
|
||||||
|
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||||
|
<script defer
|
||||||
|
src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
|
||||||
|
{% include "_styles.html" %}
|
||||||
|
<style>
|
||||||
|
.badge-site { background: #e9ecef; color: #212121; margin: 0 0.15rem; }
|
||||||
|
[data-bs-theme="dark"] .badge-site { background: #3a3f45; color: #e9ecef; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
{% set brand_subtitle = "Userverwaltung" %}
|
||||||
|
{% set site_dropdown_url_prefix = "/admin/" %}
|
||||||
|
{% include "_header.html" %}
|
||||||
|
|
||||||
|
<div class="navbar-expand-md">
|
||||||
|
<div class="collapse navbar-collapse show">
|
||||||
|
<div class="navbar navbar-dark nav-surface">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="d-flex flex-wrap gap-2 py-2">
|
||||||
|
<a class="btn btn-white" href="/admin/{{ current_site }}"><i class="ti ti-arrow-left me-1"></i>Zurück</a>
|
||||||
|
<a class="btn btn-white active" href="/admin/users"><i class="ti ti-users me-1"></i>Userliste</a>
|
||||||
|
<a class="btn btn-white" href="/admin/users/create"><i class="ti ti-user-plus me-1"></i>Neu anlegen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="container-xl mt-4">
|
||||||
|
|
||||||
|
{% if request.args.get('reset') and request.args.get('temp') %}
|
||||||
|
<div class="alert alert-success" role="alert">
|
||||||
|
<strong>Passwort zurückgesetzt für {{ request.args.get('reset') }}</strong><br>
|
||||||
|
Temporäres Passwort: <code>{{ request.args.get('temp') }}</code><br>
|
||||||
|
<small class="text-muted">Der User muss sich beim nächsten Login mit diesem Passwort anmelden und wird dann zur Passwort-Änderung aufgefordert.</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if request.args.get('updated') %}
|
||||||
|
<div class="alert alert-success" role="alert">
|
||||||
|
<strong>User {{ request.args.get('updated') }} erfolgreich aktualisiert.</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title"><i class="ti ti-users me-1"></i>User</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>E-Mail</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
<th>Standorte</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap">{{ u.email }}</td>
|
||||||
|
<td>
|
||||||
|
{% if u.role == "admin" %}
|
||||||
|
<span class="badge bg-red text-red-fg">Admin</span>
|
||||||
|
{% elif u.role == "superuser" %}
|
||||||
|
<span class="badge bg-purple text-purple-fg">Superuser</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-blue text-blue-fg">User</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if u.role == "admin" or u.role == "superuser" %}
|
||||||
|
<span class="text-muted">Alle</span>
|
||||||
|
{% else %}
|
||||||
|
{% for s in u.sites %}
|
||||||
|
<span class="badge badge-site">{{ s }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if u.must_change_password %}
|
||||||
|
<span class="badge bg-warning text-white">Passwort-Änderung erforderlich</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-green text-green-fg">Aktiv</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
{% if u.email != current_user.email %}
|
||||||
|
<form action="/admin/users/delete/{{ u.email }}" method="post"
|
||||||
|
onsubmit="return confirm('User »{{ u.email }}« wirklich löschen?')">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">Löschen</button>
|
||||||
|
</form>
|
||||||
|
<a href="/admin/users/edit/{{ u.email }}" class="btn btn-outline-secondary btn-sm">Bearbeiten</a>
|
||||||
|
<form action="/admin/users/reset-password/{{ u.email }}" method="post" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-outline-warning btn-sm">Reset</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">(aktueller User)</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "_footer.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
users.json
Normal file
14
users.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"admin": {
|
||||||
|
"password_hash": "scrypt:32768:8:1$2IMM3RQ2eqdrtYj1$960cf075435d7c7584ecf240a3b1858551727e875c9ff61874d4269f412ec6eb8cdef732e509408968460fe62534b193f2a691cf3ef329a45a61c20ec3d12c8a",
|
||||||
|
"role": "admin",
|
||||||
|
"sites": [],
|
||||||
|
"must_change_password": false
|
||||||
|
},
|
||||||
|
"erik.thiele@cancom.de": {
|
||||||
|
"password_hash": "scrypt:32768:8:1$Cy3cP6oIQuIsYSad$f5feb9e476e79cd917f12ca62a6813104679cd96cb76647cb177e640946054ab8a3f8748020f0782a07e4d47d970ea670b5910b05a685939c2161115eadbc440",
|
||||||
|
"role": "admin",
|
||||||
|
"sites": [],
|
||||||
|
"must_change_password": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user