Compare commits

...

2 Commits

Author SHA1 Message Date
Erik Thiele
39aed78b91 Userverwaltung hinzugefügt 2026-06-20 12:09:02 +02:00
Erik Thiele
2a29025386 Bilder 2026-06-19 14:17:42 +02:00
15 changed files with 3253 additions and 2500 deletions

BIN
.DS_Store vendored

Binary file not shown.

3
.gitignore vendored
View File

@@ -8,5 +8,4 @@ test*
media/
*.mp4
*.mov
*.png
*.jpg

416
app.py
View File

@@ -11,8 +11,10 @@ zugeschaltet werden, die auf allen Playern abwechselnd eingeblendet wird.
import os
import json
import hashlib
import re
import generate_welcome_page # Logo-Suche und Willkommensseite-Generierung
from functools import wraps
from datetime import datetime
from flask import (
@@ -21,11 +23,12 @@ from flask import (
request, abort
)
from flask_login import (
LoginManager, login_user,
LoginManager, login_user, current_user,
login_required, logout_user,
UserMixin
)
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__))
MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/Screen
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"}
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):
"""Einfacher Admin-User. Es gibt nur einen Admin (keine DB)."""
id = 1
class User(UserMixin):
"""User mit E-Mail (id), Rolle und Standort-Berechtigungen."""
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
def load_user(user_id):
"""Lädt den Admin-User für Flask-Login. user_id wird ignoriert es gibt nur einen User."""
return Admin()
"""Lädt einen User aus users.json anhand der E-Mail (Flask-Login-Callback)."""
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"])
def login():
"""Login-Seite. Bei POST werden die Zugangsdaten aus config.json.admin geprüft."""
config = load_config()
"""Login-Seite. Prüft Zugangsdaten gegen users.json (gehashte Passwörter)."""
error = None
next_url = None
if request.method == "POST":
if (
request.form.get("username") == config["admin"].get("username")
and request.form.get("password") == config["admin"].get("password")
):
login_user(Admin())
email = request.form.get("username", "").strip().lower()
password = request.form.get("password", "")
user = get_user(email)
if user:
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"
return render_template("login.html", error=error)
return render_template("login.html", error=error, next=next_url)
@app.route("/logout")
@@ -270,6 +385,7 @@ def logout():
# -------------------------------------------------
@app.route("/willkommen", methods=["GET", "POST"])
@app.route("/customer", methods=["GET", "POST"])
@login_required
def add_customer():
"""
Formular für die Willkommensseite:
@@ -283,12 +399,16 @@ def add_customer():
customer_names = ["", "", ""]
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:
config = load_config()
sites = get_site_list(config)
sites = get_accessible_sites(config, current_user)
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":
customer_names = [
request.form.get("customer_name_1", "").strip(),
@@ -582,18 +702,18 @@ def root_redirect():
@app.route("/admin", strict_slashes=False)
@login_required
def admin_redirect():
"""Leitet zum ersten konfigurierten Standort weiter."""
"""Leitet zum ersten für den User zugänglichen Standort weiter."""
cfg = load_config()
sites = get_site_list(cfg)
sites = get_accessible_sites(cfg, current_user)
if sites:
return redirect(f"/admin/{sites[0]}")
return "<h1>Kein Standort konfiguriert</h1><p>Bitte lege einen Standort im media-Verzeichnis an.</p>", 200
@app.route("/add-site")
@login_required
@admin_required
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()
if not name:
return redirect("/admin")
@@ -607,9 +727,9 @@ def add_site():
@app.route("/admin/<site>/delete-site", methods=["POST"])
@login_required
@admin_required
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.get("sites", {}).pop(site, None)
save_config(cfg)
@@ -624,7 +744,7 @@ def delete_site(site):
@app.route("/admin/<site>", strict_slashes=False)
@login_required
@site_access_required
def admin(site):
"""
Admin-Dashboard für einen Standort.
@@ -632,10 +752,10 @@ def admin(site):
"""
cfg = load_config()
site_list = get_site_list(cfg)
site_list = get_accessible_sites(cfg, current_user)
# 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)):
cfg.setdefault("sites", {})[site] = {"screens": {}}
save_config(cfg)
@@ -753,11 +873,11 @@ def admin(site):
# Admin: Priority-Playlist (eigene Seite)
# -------------------------------------------------
@app.route("/admin/<site>/priority")
@login_required
@site_access_required
def admin_priority(site):
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
cfg = load_config()
site_list = sorted(cfg.get("sites", {}).keys())
site_list = get_accessible_sites(cfg, current_user)
priority_files = load_priority_files()
return render_template(
@@ -776,7 +896,7 @@ def admin_priority(site):
# Admin: Hintergrundbild für Willkommensseite hochladen
# -------------------------------------------------
@app.route("/admin/<site>/upload-background", methods=["POST"])
@login_required
@site_access_required
def upload_background(site):
"""Lädt ein Hintergrundbild für die Willkommensseite hoch (pro Standort).
Gespeichert als media/<site>/background.<ext>."""
@@ -806,7 +926,7 @@ def upload_background(site):
@app.route("/admin/<site>/delete-background", methods=["POST"])
@login_required
@site_access_required
def delete_background(site):
"""Löscht das Hintergrundbild für die Willkommensseite."""
site_dir = os.path.join(MEDIA_DIR, site)
@@ -825,7 +945,7 @@ def delete_background(site):
# Admin: Screen-Einstellungen speichern
# -------------------------------------------------
@app.route("/admin/<site>/update/<screen>", methods=["POST"])
@login_required
@site_access_required
def update_screen(site, screen):
"""Speichert die allgemeinen Einstellungen für einen Screen (Intervall, Newsticker, Show-Bilder/Videos)."""
config = load_config()
@@ -843,7 +963,7 @@ def update_screen(site, screen):
@app.route("/admin/<site>/update-actions/<screen>", methods=["POST"])
@login_required
@site_access_required
def update_screen_actions(site, screen):
"""Speichert die Aktionen-Einstellungen für einen Screen (Custom-URL + Position)."""
config = load_config()
@@ -863,7 +983,7 @@ def update_screen_actions(site, screen):
# Admin: Voice-Agent-Einstellungen speichern
# -------------------------------------------------
@app.route("/admin/<site>/update-voice/<screen>", methods=["POST"])
@login_required
@site_access_required
def update_screen_voice(site, screen):
"""Speichert die Voice-Agent-Einstellungen für einen Screen (Enabled, Label, Target, Position)."""
config = load_config()
@@ -883,7 +1003,7 @@ def update_screen_voice(site, screen):
# Admin: Screen löschen
# -------------------------------------------------
@app.route("/admin/<site>/delete-screen/<screen>", methods=["POST"])
@login_required
@site_access_required
def delete_screen(site, screen):
"""Löscht einen Screen (Config-Eintrag + Medien-Verzeichnis)."""
config = load_config()
@@ -904,7 +1024,7 @@ def delete_screen(site, screen):
# Admin: Neuen Screen anlegen
# -------------------------------------------------
@app.route("/admin/<site>/add-screen", methods=["GET"])
@login_required
@site_access_required
def add_screen(site):
"""Legt einen neuen Screen an (Verzeichnis + Config-Eintrag)."""
screen_name = request.args.get("name", "").strip().lower()
@@ -927,7 +1047,7 @@ def add_screen(site):
# Admin: Medien-Upload
# -------------------------------------------------
@app.route("/admin/<site>/upload/<screen>", methods=["POST"])
@login_required
@site_access_required
def upload(site, screen):
"""Lädt eine Datei hoch (Bild oder Video). Bei screen="priority" ins globale Verzeichnis."""
file = request.files.get("file")
@@ -952,7 +1072,7 @@ def upload(site, screen):
# Admin: URL zur Playlist hinzufügen
# -------------------------------------------------
@app.route("/admin/<site>/add-url/<screen>", methods=["POST"])
@login_required
@site_access_required
def add_url(site, screen):
"""Fügt eine URL (mit Zoom-Faktor) zur Playlist eines Screens oder der Priority-Playlist hinzu."""
url = request.form.get("url", "").strip()
@@ -985,7 +1105,7 @@ def add_url(site, screen):
# Admin: Datei aus Playlist löschen
# -------------------------------------------------
@app.route("/admin/<site>/delete/<screen>/<path:filename>", methods=["POST"])
@login_required
@site_access_required
def delete_file(site, screen, filename):
"""Löscht eine Datei aus der Playlist (Config + Dateisystem)."""
cfg = load_config()
@@ -1017,7 +1137,7 @@ def delete_file(site, screen, filename):
# Admin: Playlist-Reihenfolge speichern (Drag & Drop)
# -------------------------------------------------
@app.route("/admin/<site>/playlist/<screen>", methods=["POST"])
@login_required
@site_access_required
def save_playlist(site, screen):
"""
Speichert die über SortableJS per Drag & Drop neu sortierte Playlist.
@@ -1058,8 +1178,224 @@ def save_playlist(site, screen):
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
# -------------------------------------------------
if __name__ == "__main__":
init_user_db()
app.run(debug=True, host="0.0.0.0", port=5005)

View File

@@ -15,9 +15,9 @@
"stuttgart": {
"screens": {
"lobby": {
"interval": 10,
"show_images": false,
"show_videos": false,
"interval": 15,
"show_images": true,
"show_videos": true,
"playlist": [
"welcome.html",
{
@@ -35,8 +35,8 @@
"enabled": false
}
],
"newsticker_text": "",
"newsticker_enabled": false,
"newsticker_text": "HErzlich Willkommen in Stuttgart - wir w\u00fcnschen ihnen einen erfolgreichen Aufenthalt",
"newsticker_enabled": true,
"custom_url": "https://dva-stage.cancom-ds.de/voice-agent",
"custom_url_label": "Welcome Agent",
"custom_url_enabled": false,
@@ -44,9 +44,10 @@
"voice_agent_enabled": true,
"voice_agent_label": "Digitaler Assistent",
"voice_agent_target": "overlay",
"voice_agent_position": "top-left",
"voice_agent_position": "bottom-left",
"custom_url_position": "top-left",
"voice_agent_show_image": false
"voice_agent_show_image": false,
"stay_on_first": false
},
"casino": {
"interval": 15,

View File

@@ -179,6 +179,17 @@ def generate_welcome_html(customer_names, logo_urls, site="stuttgart", backgroun
object-fit: contain;
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 {{
text-align: right; z-index: 10;
}}

File diff suppressed because it is too large Load Diff

BIN
static/dva.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

BIN
static/wallpaper.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -42,10 +42,22 @@
</button>
</div>
<!-- Logout -->
<div class="ms-2">
<a href="/logout" class="btn btn-outline-secondary"><i class="ti ti-logout me-1"></i>Abmelden</a>
<!-- User-Dropdown (Passwort ändern, Userverwaltung, Abmelden) -->
{% if current_user.is_authenticated %}
<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>
{% endif %}
</div>
</div>
</header>

View 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>

View File

@@ -36,8 +36,8 @@
<form method="post">
<div class="mb-3">
<label class="form-label">Benutzername</label>
<input class="form-control" name="username" required>
<label class="form-label">E-Mail / Benutzername</label>
<input class="form-control" name="username" placeholder="admin" required>
</div>
<div class="mb-3">
<label class="form-label">Passwort</label>

124
templates/user_create.html Normal file
View 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
View 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
View 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
View 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
}
}