Compare commits
44 Commits
35e22718e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7870c889f8 | ||
|
|
6f82bda70c | ||
|
|
bf95b3173b | ||
|
|
22104b54cd | ||
|
|
6efce5b2ee | ||
|
|
5ee9e0b373 | ||
|
|
fca129b367 | ||
|
|
1480960801 | ||
|
|
b8093521a0 | ||
|
|
be00342109 | ||
|
|
a8cbeb089f | ||
|
|
f1c36f3c99 | ||
|
|
f8b7a94845 | ||
|
|
ce4cd4486b | ||
|
|
106ec2d897 | ||
|
|
b9e7b2e074 | ||
|
|
bfbd970f3b | ||
|
|
23a33955aa | ||
|
|
317f2a6eb2 | ||
|
|
a0753ea4c3 | ||
|
|
c1a043fc75 | ||
|
|
b1f525c714 | ||
|
|
f8c0d7d72c | ||
|
|
cdcf2f50c6 | ||
|
|
3a07df31cf | ||
|
|
297b52e50c | ||
|
|
1243910d4b | ||
|
|
22ed03c8fa | ||
|
|
ab83c19997 | ||
|
|
259626afbc | ||
|
|
6eed62d990 | ||
|
|
3de4d5e1dc | ||
|
|
589b5a58ed | ||
|
|
a5653b2d99 | ||
|
|
e83d24a3c3 | ||
|
|
dd6bcd9d54 | ||
|
|
928ea92aed | ||
|
|
9ccbd1d896 | ||
|
|
4a428c0d6d | ||
|
|
3eb5cf9d8d | ||
|
|
e18414ab79 | ||
|
|
ab1a36dee1 | ||
|
|
39aed78b91 | ||
|
|
2a29025386 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
media/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
README.md
|
||||||
|
AGENTS.md
|
||||||
|
session-ses_*.md
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,5 +8,7 @@ test*
|
|||||||
media/
|
media/
|
||||||
*.mp4
|
*.mp4
|
||||||
*.mov
|
*.mov
|
||||||
*.png
|
agents.md
|
||||||
*.jpg
|
opencode.md
|
||||||
|
session-ses_*.md
|
||||||
|
session-ses_1e96.md
|
||||||
|
|||||||
158
AGENTS.md
158
AGENTS.md
@@ -14,46 +14,108 @@ docker compose up -d
|
|||||||
|
|
||||||
- Single Flask app in `app.py`; there is no database.
|
- Single Flask app in `app.py`; there is no database.
|
||||||
- Persistent state is `config.json` plus files under `media/<site>/<screen>/`.
|
- Persistent state is `config.json` plus files under `media/<site>/<screen>/`.
|
||||||
|
- User data in `users.json` (hashed passwords via werkzeug.security scrypt).
|
||||||
- Port 5005; `README.md` hat den korrekten Port.
|
- Port 5005; `README.md` hat den korrekten Port.
|
||||||
- Existing repo instructions in this file are the main local guidance; there is no `opencode.json` or workflow config in this repo.
|
- Existing repo instructions in this file are the main local guidance; there is no `opencode.json` or workflow config in this repo.
|
||||||
|
|
||||||
## Multi-Standort-URL-Struktur (seit v4.2.0)
|
## Multi-Standort-URL-Struktur
|
||||||
|
|
||||||
Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
||||||
|
|
||||||
| Route | Beschreibung |
|
| Route | Beschreibung |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `GET /player/<site>/<screen>` | Player für Screen an einem Standort |
|
| `GET /player/<site>/<screen>` | Player für Screen an einem Standort |
|
||||||
| `GET /admin` | Redirect zum ersten Standort |
|
| `GET /admin` | Redirect zum ersten zugänglichen Standort |
|
||||||
| `GET /admin/<site>` | Admin-Dashboard für einen Standort |
|
| `GET /admin/<site>` | Admin-Dashboard für einen Standort (`@site_access_required`) |
|
||||||
| `GET /admin/<site>/priority` | Priority-Playlist als separate Seite |
|
| `GET /admin/<site>/priority` | Priority-Playlist als separate Seite |
|
||||||
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
||||||
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
||||||
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
||||||
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload |
|
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) |
|
||||||
| `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren |
|
| `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren |
|
||||||
| `POST /admin/<site>/upload-background` | Hintergrundbild für Willkommensseite hochladen |
|
| `POST /admin/<site>/upload-background` | Hintergrundbild für Willkommensseite hochladen |
|
||||||
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen auf Standard |
|
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen auf Standard |
|
||||||
| `POST /api/customer` | API-Endpunkt (JSON mit "site"-Feld) |
|
| `POST /api/customer` | API-Endpunkt (JSON mit "site"-Feld) |
|
||||||
| `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen |
|
| `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen |
|
||||||
| `POST /admin/<site>/delete-screen/<screen>` | Screen + Medien löschen |
|
| `POST /admin/<site>/delete-screen/<screen>` | Screen + Medien löschen |
|
||||||
| `GET /add-site?name=<name>` | Neuen Standort anlegen |
|
| `GET /add-site?name=<name>` | Neuen Standort anlegen (nur Admin) |
|
||||||
| `POST /admin/<site>/update-actions/<screen>` | Aktionen-Einstellungen speichern (Custom-URL + Position) |
|
| `POST /admin/<site>/update-actions/<screen>` | Aktionen-Einstellungen speichern (Custom-URL + Position) |
|
||||||
| `POST /admin/<site>/update-voice/<screen>` | Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position) |
|
| `POST /admin/<site>/update-voice/<screen>` | Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position) |
|
||||||
| `POST /admin/<site>/delete-site` | Standort + alle Screens/Medien löschen |
|
| `POST /admin/<site>/delete-site` | Standort + alle Screens/Medien löschen (nur Admin) |
|
||||||
|
| `GET /change-password` | Passwort ändern-Seite |
|
||||||
|
| `GET /admin/users` | User-Liste (nur Admin) |
|
||||||
|
| `GET /admin/users/create` | User anlegen (nur Admin) |
|
||||||
|
| `GET /admin/users/edit/<email>` | User bearbeiten (nur Admin) |
|
||||||
|
|
||||||
|
## User-Verwaltung (seit v5.6.0)
|
||||||
|
|
||||||
|
- Login via E-Mail + Passwort (scrypt-gehasht in `users.json`)
|
||||||
|
- Drei Rollen:
|
||||||
|
- **Admin**: Zugriff auf alle Standorte + Userverwaltung (anlegen/bearbeiten/löschen/Reset)
|
||||||
|
- **Superuser**: Zugriff auf alle Standorte, keine Userverwaltung
|
||||||
|
- **User**: Zugriff nur auf zugewiesene Standorte
|
||||||
|
- Erster Login erfordert Passwort-Änderung (`must_change_password=True`)
|
||||||
|
- Admin-Reset generiert temporäres Passwort (wird im UI angezeigt)
|
||||||
|
- Fehlender/leerer `password_hash` → Login ohne Prüfung, direkt zu `/change-password`
|
||||||
|
- Letzter Admin kann nicht gelöscht werden
|
||||||
|
- `users.json` wird beim ersten Start aus `config.json.admin` befüllt
|
||||||
|
- Notfall: `users.json` löschen → Server-Neustart erzeugt neuen Admin
|
||||||
|
|
||||||
|
### User-Routen
|
||||||
|
|
||||||
|
| Route | Beschreibung |
|
||||||
|
|-------|-------------|
|
||||||
|
| `GET /admin/users` | User-Liste (Admin, mit MFA-Spalte) |
|
||||||
|
| `GET /admin/users/create` | User anlegen (Formular) |
|
||||||
|
| `POST /admin/users/create` | User anlegen |
|
||||||
|
| `GET /admin/users/edit/<email>` | User bearbeiten (Formular) |
|
||||||
|
| `POST /admin/users/edit/<email>` | User bearbeiten speichern |
|
||||||
|
| `POST /admin/users/delete/<email>` | User löschen (letzter Admin geschützt) |
|
||||||
|
| `POST /admin/users/reset-password/<email>` | Passwort-Reset (temporäres Passwort) |
|
||||||
|
| `GET /mfa/setup` | MFA einrichten (QR-Code + Verifikation) |
|
||||||
|
| `POST /mfa/setup` | TOTP-Code verifizieren → MFA aktivieren |
|
||||||
|
| `GET /mfa/verify` | TOTP-Code nach Login eingeben |
|
||||||
|
| `POST /mfa/verify` | Code prüfen oder Recovery-Code verwenden |
|
||||||
|
| `POST /mfa/disable` | MFA deaktivieren (mit Passwort) |
|
||||||
|
|
||||||
|
### MFA (Multi-Faktor-Authentifizierung)
|
||||||
|
|
||||||
|
- TOTP via `pyotp`, QR-Code via `qrcode[pil]` (nur in `.venv` installiert)
|
||||||
|
- Der TOTP-Secret wird sofort beim ersten Aufruf von `/mfa/setup` (GET) in `users.json` persistiert, sodass die nachfolgende POST-Verifikation denselben Secret verwendet
|
||||||
|
- Nach erfolgreicher Aktivierung: 8 Recovery-Codes (hex, 7 Stellen), einmalig im UI angezeigt, jeder nur einmal verwendbar
|
||||||
|
- Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung
|
||||||
|
- Deaktivierung via `/mfa/disable` mit Passwort-Bestätigung
|
||||||
|
- MFA-Status in der Userliste (`/admin/users`) als grünes "Aktiv"-Badge oder "–"
|
||||||
|
- **Trusted Device**: Nach erfolgreichem TOTP-Code kann das Gerät für 1–30 Tage gespeichert werden. Bei erneuter Anmeldung wird der MFA-Code dann übersprungen (Cookie `mfa_trust` mit Token, gespeichert in `users.json[email].trusted_devices`). Beim Logout oder Deaktivieren von MFA wird der Cookie gelöscht.
|
||||||
|
|
||||||
|
### MFA-Login-Flow
|
||||||
|
|
||||||
|
1. POST `/login` mit Passwort → bei aktivem MFA: Session `mfa_pending` setzen → redirect `/mfa/verify`
|
||||||
|
2. GET/POST `/mfa/verify` → TOTP-Code prüfen → `login_user()` aufrufen → redirect `/admin/<site>`
|
||||||
|
3. Recovery-Code: POST `/mfa/verify` mit `recovery=true` → Code prüfen → `mfa_enabled=False` → Login erlauben, aber User muss MFA neu einrichten
|
||||||
|
4. **Trusted Device**: POST `/mfa/verify` mit `remember` (Tage) → Token generieren → in `users.json[email].trusted_devices` speichern → Cookie `mfa_trust` setzen. Nächstes POST `/login`: Cookie erkannt → MFA überspringen → direkt einloggen
|
||||||
|
|
||||||
|
### Zugriffs-Dekoratoren
|
||||||
|
|
||||||
|
| Dekorator | Wirkung |
|
||||||
|
|-----------|---------|
|
||||||
|
| `@login_required` | User muss eingeloggt sein |
|
||||||
|
| `@admin_required` | Nur Admins |
|
||||||
|
| `@site_access_required` | Admin/Superuser → alle Sites, User → nur freigegebene |
|
||||||
|
|
||||||
## Behavior To Preserve
|
## Behavior To Preserve
|
||||||
|
|
||||||
- `GET /player/<site>/<screen>` renders the playlist and auto-reloads from `/playlist/<site>/<screen>/hash`.
|
- `GET /player/<site>/<screen>` renders the playlist and auto-reloads from `/playlist/<site>/<screen>/hash`.
|
||||||
- `GET /admin` requires login; `config.json.admin` holds the credentials.
|
- `GET /admin` requires login; redirects to first accessible site for the user.
|
||||||
- URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows.
|
- URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows.
|
||||||
- `.html` items in `media/` are rendered inline as content, not in an iframe.
|
- `.html` items in `media/` are rendered inline as content, not in an iframe.
|
||||||
- `config.priority.enabled` makes the priority playlist show on every screen.
|
- `config.priority.enabled` makes the priority playlist show on every screen.
|
||||||
- `POST /api/customer` generates `welcome.html` and inserts it at the front of the lobby playlist for the specified site.
|
- `POST /api/customer` generates `welcome.html` and inserts it at the front of the lobby playlist for the specified site.
|
||||||
- New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`.
|
- New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`.
|
||||||
- Priority playlist (`config.priority`) is global and affects all sites/screens.
|
- Priority playlist (`config.priority`) is global and affects all sites/screens.
|
||||||
- Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row.
|
- Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).
|
||||||
- `generate_welcome_html(customer_names, logo_urls)` takes lists for up to 3 customers; logos have equal width (280px) with `max-height: 180px`.
|
- `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.
|
||||||
|
- Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`).
|
||||||
- `customer_names` are preserved in form fields after POST (via `value`-Attribute).
|
- `customer_names` are preserved in form fields after POST (via `value`-Attribute).
|
||||||
- Admin-UI nutzt keyadmin-Design: `brand-surface` (#2b2f36), `nav-surface` (rot #DA002D), Dark Mode per `localStorage("signage-theme")`.
|
- Admin-UI nutzt keyadmin-Design: `brand-surface` (#2b2f36), `nav-surface` (rot #DA002D), Dark Mode per `localStorage("signage-theme")`.
|
||||||
- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem).
|
- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem).
|
||||||
@@ -67,25 +129,91 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
|||||||
- `voice_agent_show_image` (per Screen): optionales Bild (`static/dva.png`) über dem Button, per Admin-Toggle schaltbar (Beta).
|
- `voice_agent_show_image` (per Screen): optionales Bild (`static/dva.png`) über dem Button, per Admin-Toggle schaltbar (Beta).
|
||||||
- Action-Button (custom_url) und Voice-Agent-Button haben einheitliches Styling: weißer Hintergrund, schwarzer Text, 20px Border-Radius, fette Schrift.
|
- Action-Button (custom_url) und Voice-Agent-Button haben einheitliches Styling: weißer Hintergrund, schwarzer Text, 20px Border-Radius, fette Schrift.
|
||||||
|
|
||||||
- Neue Standorte können über den `+`-Button im Header oder über `GET /add-site?name=<name>` angelegt werden.
|
- Neue Standorte können über den `+`-Button im Header oder über `GET /add-site?name=<name>` angelegt werden (nur Admins).
|
||||||
- `delete_site` entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv.
|
- `delete_site` entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv (nur Admins).
|
||||||
- `config.json["server_url"]` (z. B. `http://signage.ccmake.de`) wird in der Admin-Ansicht für die Player-URLs verwendet.
|
- `config.json["server_url"]` (z. B. `http://signage.ccmake.de`) wird in der Admin-Ansicht für die Player-URLs verwendet.
|
||||||
- Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Digital Voice Agent** (4), **Medien** (5); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**.
|
- **Query-Parameter-Passthrough**: Query-Parameter an der Player-URL (`/player/<site>/<screen>`) können pro Playlist-URL im Edit-Modal (Checkbox) gesteuert werden. Standardmäßig **aus** – nur URLs mit aktivierter Checkbox erhalten die Parameter. Beispiel: `/player/stuttgart/lobby?customer=acme` hängt `?customer=acme` nur an URLs mit Passthrough=an. In der Playlist-Zeile zeigt ein Link-Icon (`ti-link`, grau) den Status an: sichtbar = an, durchgestrichen (`ti-link-off`, hellgrau) = aus. Funktioniert über `appendParams()` in `player.html` + `player_params` im Flask-Route.
|
||||||
|
- Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Digital Voice Agent** (4), **Medien** (5), **Info** (6); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**.
|
||||||
- `stay_on_first`: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf).
|
- `stay_on_first`: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf).
|
||||||
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien.
|
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info.
|
||||||
- Player-URL im Screen-Header ist ein klickbarer Link in grauer Farbe.
|
- Player-URL im Screen-Header ist ein klickbarer Link in grauer Farbe.
|
||||||
|
- `add_customer` und `/customer` erfordern jetzt Login (`@login_required`) mit Site-Zugriffsprüfung.
|
||||||
|
- `/admin` redirectet zum ersten für den User zugänglichen Standort (nicht mehr global ersten).
|
||||||
|
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.
|
||||||
|
- Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen
|
||||||
|
|
||||||
## Repo Quirks
|
## Repo Quirks
|
||||||
|
|
||||||
- `media/` and media file extensions are gitignored.
|
- `media/` and media file extensions are gitignored.
|
||||||
|
- `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
|
||||||
- The app has no configured tests, lint, typecheck, formatter, or CI.
|
- The app has no configured tests, lint, typecheck, formatter, or CI.
|
||||||
- Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
|
- Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
|
||||||
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `playlist_item_enabled()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`.
|
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `playlist_item_enabled()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`.
|
||||||
|
- Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`.
|
||||||
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
|
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
|
||||||
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
|
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
|
||||||
- `generate_welcome_html(customer_names, logo_urls, background_url=None)` akzeptiert optionalen `background_url`-Parameter.
|
- `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` akzeptiert optionale `contacts`-, `site`- und `background_url`-Parameter.
|
||||||
- `welcome.html` wird in `media/<site>/lobby/` gespeichert.
|
- `welcome.html` wird in `media/<site>/lobby/` gespeichert.
|
||||||
- `search_customer_logo` in `generate_welcome_page.py` nutzt OpenAI GPT-4 + Brandfetch CDN.
|
- `search_customer_logo` in `generate_welcome_page.py` nutzt OpenAI GPT-4 + Brandfetch CDN.
|
||||||
- `admin_priority` rendert `priority.html` mit `site_list`, `current_site`, `priority_files`, `server_url`.
|
- `admin_priority` rendert `priority.html` mit `site_list`, `current_site`, `priority_files`, `server_url`.
|
||||||
- `static/dva.png`: Bild für den Voice-Agent-Button (optional, per Admin-Toggle ein-/ausblendbar).
|
- `static/dva.png`: Bild für den Voice-Agent-Button (optional, per Admin-Toggle ein-/ausblendbar).
|
||||||
|
|
||||||
|
## Heartbeat / Online-Status (seit v6.1.0)
|
||||||
|
|
||||||
|
Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermittelt.
|
||||||
|
|
||||||
|
### Funktionsweise
|
||||||
|
|
||||||
|
| Schritt | Beschreibung |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Player-pollt** alle 5s `/playlist/<site>/<screen>/hash` (`checkForUpdates`) | Bestehender Mechanismus, kein Extra-Request |
|
||||||
|
| **Server zeichnet Heartbeat auf** | `record_heartbeat(site, screen)` erstellt/updated `/tmp/signage-heartbeat/<site>/<screen>` via `path.touch()` |
|
||||||
|
| **Admin-UI fragt Status ab** | `screen_is_active(site, screen)` prüft ob `mtime < 60s` alt |
|
||||||
|
| **Anzeige** | Grüner "Online"-Button (filled success) oder roter "Offline"-Button (filled danger) im Card-Header neben "Löschen" |
|
||||||
|
| **Client-Info** | Beim Hovern über den Online-Button: Tooltip mit Browser, Auflösung, IP, letztem Seen-Zeitpunkt |
|
||||||
|
|
||||||
|
### Helper-Funktionen in `app.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
|
||||||
|
HEARTBEAT_TIMEOUT = 60 # Sekunden
|
||||||
|
|
||||||
|
def record_heartbeat(site, screen, ip="", ua="", resolution=""):
|
||||||
|
"""Schreibt JSON mit Client-Info (ip, ua, resolution, last_seen)."""
|
||||||
|
path = HEARTBEAT_DIR / site / f"{screen}.json"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {"ip": ip, "ua": ua[:200], "resolution": resolution,
|
||||||
|
"last_seen": datetime.now().strftime("%d.%m.%Y %H:%M:%S")}
|
||||||
|
path.write_text(json.dumps(data))
|
||||||
|
|
||||||
|
def screen_is_active(site, screen):
|
||||||
|
"""Prüft, ob Heartbeat < 60s alt ist (anhand .json-mtime)."""
|
||||||
|
path = HEARTBEAT_DIR / site / f"{screen}.json"
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
return (time.time() - path.stat().st_mtime) < 60
|
||||||
|
|
||||||
|
def get_client_info(site, screen):
|
||||||
|
"""Gibt das gespeicherte Client-Info-JSON zurück."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
- `playlist_hash()`-Route liest Query-Parameter `w` (Breite) und `h` (Höhe) + `remote_addr` + `User-Agent` und übergibt sie an `record_heartbeat()`
|
||||||
|
- `admin()`-Route sammelt `client_info[screen] = get_client_info(...)` für jeden Screen
|
||||||
|
- Admin-Template (`admin.html`) zeigt den Online-Button mit `title`-Tooltip (Browser, Auflösung, IP, letztes Update) und den Info-Tab mit Client-Info-Tabelle
|
||||||
|
- Player (`player.html`) sendet `screen.width` und `screen.height` als `?w=...&h=...` im `checkForUpdates`-Fetch
|
||||||
|
- Die Lösch-Buttons (`btn-outline-danger btn-sm`) haben gleiche Größe durch gleiche btn-sm-Klasse, Form-Wrapper hat `m-0 p-0`
|
||||||
|
|
||||||
|
### Docker-Hinweis
|
||||||
|
|
||||||
|
Im Docker-Container liegt der Heartbeat-Pfad unter `/tmp/signage-heartbeat/` im Container. Da die Docker-Umgebung keine Volumes mounted, sind Heartbeats container-lokal. Nach `docker compose restart` werden alle Screens kurz rot, bis die Player innerhalb von max. 60s wieder pollen.
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle Heartbeat-Dateien anzeigen
|
||||||
|
ls -la /tmp/signage-heartbeat/*/
|
||||||
|
find /tmp/signage-heartbeat -type f -exec ls -la {} \;
|
||||||
|
```
|
||||||
|
|||||||
333
DESIGN.md
Normal file
333
DESIGN.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
---
|
||||||
|
version: alpha
|
||||||
|
name: CANCOM Simple Signage
|
||||||
|
description: "Digital-Signage-Plattform für CANCOM: Admin-UI zur Verwaltung von Screens, Playlists, Medien und Standorten sowie ein kioskartiger Player-Vollbildmodus."
|
||||||
|
|
||||||
|
colors:
|
||||||
|
primary: "#DA002D"
|
||||||
|
primary-hover: "#B00024"
|
||||||
|
header: "#2B2F36"
|
||||||
|
header-dark: "#0F1720"
|
||||||
|
surface: "#FFFFFF"
|
||||||
|
surface-dark: "#15181D"
|
||||||
|
bg: "#F4F6F8"
|
||||||
|
bg-dark: "#15181D"
|
||||||
|
text: "#212121"
|
||||||
|
text-dark: "#E5E7EB"
|
||||||
|
border: "#D9DEE3"
|
||||||
|
border-dark: "#2B3440"
|
||||||
|
muted: "#6B7280"
|
||||||
|
muted-dark: "#9AA4B2"
|
||||||
|
online: "#2FB344"
|
||||||
|
offline: "#D63939"
|
||||||
|
badge-admin: "#DA002D"
|
||||||
|
badge-superuser: "#8B5CF6"
|
||||||
|
badge-user: "#3B82F6"
|
||||||
|
newsticker-bg: "#DA002D"
|
||||||
|
newsticker-text: "#FFFFFF"
|
||||||
|
card-header-light: "#EEEEEE"
|
||||||
|
card-header-dark: "#1A1E24"
|
||||||
|
input-bg-dark: "#11161D"
|
||||||
|
|
||||||
|
typography:
|
||||||
|
body:
|
||||||
|
fontFamily: "Segoe UI, Arial, sans-serif"
|
||||||
|
fontSize: 16px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.5
|
||||||
|
heading:
|
||||||
|
fontFamily: "Segoe UI, Arial, sans-serif"
|
||||||
|
fontWeight: 600
|
||||||
|
heading-lg:
|
||||||
|
fontFamily: "Segoe UI, Arial, sans-serif"
|
||||||
|
fontSize: 1.75rem
|
||||||
|
fontWeight: 600
|
||||||
|
heading-md:
|
||||||
|
fontFamily: "Segoe UI, Arial, sans-serif"
|
||||||
|
fontSize: 1.25rem
|
||||||
|
fontWeight: 600
|
||||||
|
player-clock:
|
||||||
|
fontFamily: monospace
|
||||||
|
fontSize: 1.1em
|
||||||
|
newsticker-track:
|
||||||
|
fontFamily: "Segoe UI, Arial, sans-serif"
|
||||||
|
fontSize: 16px
|
||||||
|
fontWeight: 400
|
||||||
|
mute:
|
||||||
|
fontFamily: "Segoe UI, Arial, sans-serif"
|
||||||
|
fontSize: 0.82rem
|
||||||
|
|
||||||
|
rounded:
|
||||||
|
card: 16px
|
||||||
|
button: 20px
|
||||||
|
small: 4px
|
||||||
|
logo-box: 12px
|
||||||
|
full: 9999px
|
||||||
|
|
||||||
|
spacing:
|
||||||
|
xs: 4px
|
||||||
|
sm: 8px
|
||||||
|
md: 16px
|
||||||
|
lg: 24px
|
||||||
|
xl: 32px
|
||||||
|
xxl: 64px
|
||||||
|
gutter: 24px
|
||||||
|
page-top: 24px
|
||||||
|
footer-padding-y: 0.65rem
|
||||||
|
footer-padding-x: 1.5rem
|
||||||
|
card-header-padding: 16px
|
||||||
|
card-margin-bottom: 64px
|
||||||
|
|
||||||
|
components:
|
||||||
|
card:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
borderColor: "{colors.border}"
|
||||||
|
borderLeft: "6px solid {colors.primary}"
|
||||||
|
rounded: "{rounded.card}"
|
||||||
|
card-dark:
|
||||||
|
backgroundColor: "{colors.bg-dark}"
|
||||||
|
borderColor: "{colors.border-dark}"
|
||||||
|
card-header:
|
||||||
|
backgroundColor: "{colors.card-header-light}"
|
||||||
|
card-header-dark:
|
||||||
|
backgroundColor: "{colors.card-header-dark}"
|
||||||
|
btn-primary:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "#FFFFFF"
|
||||||
|
rounded: "{rounded.button}"
|
||||||
|
btn-primary-hover:
|
||||||
|
backgroundColor: "{colors.primary-hover}"
|
||||||
|
action-button:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
textColor: "{colors.text}"
|
||||||
|
rounded: "{rounded.button}"
|
||||||
|
fontWeight: 600
|
||||||
|
online-badge:
|
||||||
|
backgroundColor: "{colors.online}"
|
||||||
|
textColor: "#FFFFFF"
|
||||||
|
offline-badge:
|
||||||
|
backgroundColor: "{colors.offline}"
|
||||||
|
textColor: "#FFFFFF"
|
||||||
|
newsticker-bar:
|
||||||
|
backgroundColor: "{colors.newsticker-bg}"
|
||||||
|
textColor: "{colors.newsticker-text}"
|
||||||
|
height: 40px
|
||||||
|
overlay-button:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
textColor: "{colors.text}"
|
||||||
|
rounded: "{rounded.button}"
|
||||||
|
fontWeight: 600
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.08)"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CANCOM Simple Signage is a corporate digital-signage platform. The UI follows a **keyadmin dashboard** aesthetic: clean, card-based layouts with a dark header bar, a red navigation bar (CANCOM brand red #DA002D), and generous white space. The tone is professional, trustworthy, and functional — suitable for a B2B enterprise environment.
|
||||||
|
|
||||||
|
The admin UI manages screens, playlists, media uploads, and multi-site locations. The player UI is a fullscreen, kiosk-style view with no browser chrome, designed for unattended TV/monitor displays.
|
||||||
|
|
||||||
|
Two themes are supported: **light** (default, `data-bs-theme="light"`) and **dark** (`data-bs-theme="dark"`), persisted via `localStorage("signage-theme")`.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
The palette is rooted in CANCOM's corporate red and high-contrast neutrals.
|
||||||
|
|
||||||
|
- **Primary (#DA002D):** CANCOM signature red. Used for navigation bar, button backgrounds, card left-border accent, form-check focus rings, newsticker bar, and all primary interactive elements. The single most important action per screen uses this color.
|
||||||
|
- **Primary Hover (#B00024):** Darkened red for button hover/focus states.
|
||||||
|
- **Header (#2B2F36 / #0F1720 Dark):** Dark surface for top header bar. White text and icons on this surface.
|
||||||
|
- **Surface (#FFFFFF / #15181D Dark):** Card backgrounds and content areas.
|
||||||
|
- **Background (#F4F6F8 / #15181D Dark):** Page-level background.
|
||||||
|
- **Border (#D9DEE3 / #2B3440 Dark):** Card borders, table borders, form borders, list-group dividers.
|
||||||
|
- **Muted (#6B7280 / #9AA4B2 Dark):** Secondary text, captions, metadata, placeholders.
|
||||||
|
- **Online/Offline:** Green (#2FB344) for online status badge, Red (#D63939) for offline.
|
||||||
|
- **Role Badges:** Red for Admin, Purple (#8B5CF6) for Superuser, Blue (#3B82F6) for User.
|
||||||
|
- **Newsticker:** Red bar (#DA002D) with white text.
|
||||||
|
|
||||||
|
### States
|
||||||
|
|
||||||
|
Interactive elements use the following hover/active color shifts:
|
||||||
|
- **Primary button hover:** `primary-hover` (#B00024)
|
||||||
|
- **Secondary/outline buttons:** inherit background, use border with slight darkening
|
||||||
|
- **Dark-mode form controls:** input background #11161D
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
The typography strategy uses the system font stack **Segoe UI** for all text, ensuring consistent rendering across Windows-based signage players.
|
||||||
|
|
||||||
|
- **Body:** Segoe UI Regular at 16px base. Line-height 1.5 for readability. This is the default for all body text, form labels, table cells.
|
||||||
|
- **Headings:** Segoe UI Semi-Bold (600). Three levels: page-title (1.75rem), card-title (1.25rem), and section headings.
|
||||||
|
- **Player Clock:** Monospace font at 1.1em for the live clock in the newsticker bar.
|
||||||
|
- **Newsticker Track:** Same as body (16px, Segoe UI), scrolling horizontally in the red bar.
|
||||||
|
- **Footer/Muted Text:** 0.82rem for copyright and secondary information.
|
||||||
|
- **Button Labels:** 1rem (action buttons), 1.05rem (voice agent button), both weight 600.
|
||||||
|
- **Code/Monospace:** Used for IP addresses and file listings in info tabs.
|
||||||
|
|
||||||
|
## Layout & Spacing
|
||||||
|
|
||||||
|
The layout follows a **single-column fluid** model with a fixed max-width container (1320px) on the admin pages.
|
||||||
|
|
||||||
|
### Layout Structure
|
||||||
|
|
||||||
|
**Admin pages** follow a two-tier horizontal bar layout:
|
||||||
|
1. **Upper bar (brand-surface):** Logo, site dropdown, theme toggle, user menu
|
||||||
|
2. **Lower bar (nav-surface):** Red (#DA002D) navigation with action buttons
|
||||||
|
|
||||||
|
**Player pages** have no layout bars — the entire viewport is used for media display.
|
||||||
|
|
||||||
|
### Spacing Scale
|
||||||
|
|
||||||
|
A strict spacing scale is used with 4px as the base unit:
|
||||||
|
- `xs` (4px) — tight gaps, micro-adjustments
|
||||||
|
- `sm` (8px) — tight gaps between related elements
|
||||||
|
- `md` (16px) — standard padding inside cards, gaps between buttons
|
||||||
|
- `lg` (24px) — generous card internal padding, gap between sections
|
||||||
|
- `xl` (32px) — major section spacing
|
||||||
|
- `xxl` (64px) — spacing between screen cards
|
||||||
|
|
||||||
|
### Card Layout
|
||||||
|
|
||||||
|
Screen management cards have:
|
||||||
|
- Internal padding: 1rem (16px) on body
|
||||||
|
- Card-header padding: 1rem (16px) left, with font-weight 600
|
||||||
|
- Gap below each card: 64px (`mb-6`)
|
||||||
|
- Tab content offset: 1rem (16px) top padding
|
||||||
|
|
||||||
|
### Grid Areas
|
||||||
|
|
||||||
|
The playlist row uses a fixed grid: `28px 1fr 90px 80px 90px 32px` for checkbox, name, type badge, size, delete button, drag handle.
|
||||||
|
|
||||||
|
## Elevation & Depth
|
||||||
|
|
||||||
|
Elevation is conveyed primarily through **border distinction** rather than shadows. Cards use a 1px solid border plus a distinctive 6px left-border accent in primary red (#DA002D). This provides clear visual separation without heavy drop shadows.
|
||||||
|
|
||||||
|
- **Cards:** 1px border + 6px left red accent + 1rem border-radius
|
||||||
|
- **Logo preview boxes:** 2px dashed border with 0.75rem border-radius
|
||||||
|
- **Player overlay buttons:** Light box-shadow (0 2px 8px rgba(0,0,0,0.08)), elevated to 0 4px 12px on hover
|
||||||
|
- **Willkommensseite card:** Elevated with 0 20px 60px rgba(0,0,0,0.08) shadow for premium feel
|
||||||
|
|
||||||
|
In dark mode, depth is achieved through tonal layering: card backgrounds match the page background, but card headers use a slightly lighter/different shade (#1A1E24) to create subtle hierarchy.
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
The shape language is defined by **consistent generous rounding**.
|
||||||
|
|
||||||
|
- **Cards:** 1rem (16px) border-radius with 1rem inner border-radius
|
||||||
|
- **Buttons (admin):** Tabler default rounded (approximately 4px)
|
||||||
|
- **Player action buttons (custom URL + voice agent):** 20px border-radius for a pill-like appearance
|
||||||
|
- **Logo preview boxes:** 0.75rem (12px) border-radius
|
||||||
|
- **Interactive overlays:** 20px border-radius on the back button
|
||||||
|
- **Small avatars/badges:** Fully rounded (9999px)
|
||||||
|
- **Form controls:** Tabler default (approximately 4px)
|
||||||
|
|
||||||
|
### Consistency Rule
|
||||||
|
|
||||||
|
All interactive player buttons (custom URL, voice agent, overlay back button) share identical shape: 20px border-radius, white background, black text, font-weight 600. This ensures visual consistency across all floating buttons on the player screen.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Header (brand-surface)
|
||||||
|
|
||||||
|
Dark background (`--ccm-header` / `#0F1720` in dark mode). Contains:
|
||||||
|
- **Logo:** CANCOM SVG (height: 1.85rem, width: auto)
|
||||||
|
- **Wordmark:** Two-line (brand name + subtitle), 0.92rem, white
|
||||||
|
- **Site Dropdown:** `btn-outline-secondary` style
|
||||||
|
- **Theme Toggle:** Square button (2.5rem), moon/sun icon
|
||||||
|
- **User Dropdown:** Email + role badge (Admin=red, Superuser=purple, User=blue)
|
||||||
|
|
||||||
|
### Navigation Bar (nav-surface)
|
||||||
|
|
||||||
|
Red background (`--ccm-primary`). Contains white outline buttons (`btn-white`):
|
||||||
|
- Active state: white background, red text, inset shadow
|
||||||
|
- Icons: 1rem, vertically centered at -2px
|
||||||
|
- Gap: 8px between buttons, 8px vertical padding
|
||||||
|
- Used on admin, customer, dashboard, priority pages
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
- `rounded: 1rem`, `border: 1px solid var(--ccm-border)`
|
||||||
|
- `border-left: 6px solid var(--ccm-primary)`
|
||||||
|
- Header: `background: #eee` (light) / `#1a1e24` (dark), font-weight 600
|
||||||
|
- Body contains Tabler tabs (6 tabs for screen cards)
|
||||||
|
- Tab background matches card surface; tab link text uses muted color; active tab uses surface background
|
||||||
|
|
||||||
|
### Player Media Elements
|
||||||
|
|
||||||
|
- img, video, iframe: fullscreen (`100vw × 100vh`), fixed position, centered via `top:50%; left:50%; transform:translate(-50%,-50%)`
|
||||||
|
- Only one element visible at a time (`display:none` default)
|
||||||
|
- `object-fit: contain` for images and video
|
||||||
|
- Background: pure black (`#000`)
|
||||||
|
|
||||||
|
### Action Button (Custom URL / Voice Agent)
|
||||||
|
|
||||||
|
The player supports floating action buttons with identical styling:
|
||||||
|
- `background: #fff; color: #000; border: none; border-radius: 20px; font-size: 1rem; font-weight: 600; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.08)`
|
||||||
|
- 9 fixed positions: top/middle/bottom × left/center/right
|
||||||
|
- Bottom positions offset 56px from bottom when newsticker is active, 16px otherwise
|
||||||
|
- Two target behaviors: overlay (iframe with back button) or redirect (direct navigation)
|
||||||
|
|
||||||
|
### Voice Agent Button
|
||||||
|
|
||||||
|
Same base as action button plus:
|
||||||
|
- Integrated animated bars (5 bars, staggered delay 0–0.4s, 0.6s wave animation)
|
||||||
|
- Typewriter tagline: 11 multilingual phrases, character-by-character (50ms + 30ms random), 2.5s pause, 0.8s fade-out
|
||||||
|
- Optional DVA image (200px, aspect-ratio 648/596) above button
|
||||||
|
- Min-width: 280px, height: 56px, gap: 12px between icon and text
|
||||||
|
|
||||||
|
### Overlay (iframe Modal)
|
||||||
|
|
||||||
|
- Fullscreen (`position:fixed; inset:0; z-index:99999; background:#000`)
|
||||||
|
- iframe fills entire viewport
|
||||||
|
- Back button positioned at the same location as the clicked trigger button (9 positions)
|
||||||
|
- Closing the overlay resumes player (`playNext()`)
|
||||||
|
|
||||||
|
### Newsticker
|
||||||
|
|
||||||
|
- Fixed bottom bar: `height:40px; background:#DA002D; color:#fff; z-index:10000`
|
||||||
|
- Scrolling text: `animation: ticker-scroll 40s linear infinite`
|
||||||
|
- Live clock: monospace, right-aligned, hh:mm:ss, updated every second
|
||||||
|
|
||||||
|
### Online/Offline Status
|
||||||
|
|
||||||
|
- Online: green filled button (`btn-success`), not interactive
|
||||||
|
- Offline: red filled button (`btn-danger`), not interactive
|
||||||
|
- Tooltip on hover: browser, resolution, IP, last seen timestamp
|
||||||
|
- Determined by heartbeat file mtime (< 60s)
|
||||||
|
|
||||||
|
### Charts (Admin Dashboard)
|
||||||
|
|
||||||
|
- Sparkline area charts using ApexCharts
|
||||||
|
- Height: 32px, width: 100%, stroke width: 1.5, curve: straight
|
||||||
|
- Color: primary red (#DA002D), fill opacity: 0.25
|
||||||
|
- No tooltip, no animation
|
||||||
|
|
||||||
|
### Form Controls
|
||||||
|
|
||||||
|
- Switches use primary red for checked state
|
||||||
|
- Focus ring: `0 0 0 0.2rem rgba(218, 0, 45, 0.25)`
|
||||||
|
- Dark mode: background `#11161D`, text `#E5E7EB`, border `#2B3440`
|
||||||
|
|
||||||
|
### Dropdown Menus
|
||||||
|
|
||||||
|
- Hover/focus: `background: #e9ecef` (light) / `#3a3f45` (dark)
|
||||||
|
- Active item: primary red background with white text
|
||||||
|
|
||||||
|
### Alerts (Dark Mode)
|
||||||
|
|
||||||
|
- Danger: background `#2d0d0d`, text `#f0a0a0`, border `#4a1a1a`
|
||||||
|
- Success: background `#0d2b1d`, text `#a3d9b1`, border `#1a4730`
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
- Do use primary red (#DA002D) only for the most important interactive elements and the nav bar
|
||||||
|
- Do maintain the two-tier header pattern (dark brand bar + red nav bar) on all admin pages
|
||||||
|
- Do keep player pages completely chromeless — no headers, navs, or footers
|
||||||
|
- Don't add shadows to cards; use border + left accent instead
|
||||||
|
- Don't use more than one red accent per card (the 6px left border is sufficient)
|
||||||
|
- Don't mix corner radii: cards use 1rem, player buttons use 20px — keep each category consistent
|
||||||
|
- Do set `data-bs-theme` synchronously before rendering to prevent dark-mode flash
|
||||||
|
- Do persist theme preference in `localStorage("signage-theme")`
|
||||||
|
- Don't use emojis in the UI; use Tabler Icons (`ti-*`) consistently
|
||||||
|
- Do respect the 9-position system for all floating player buttons
|
||||||
|
- Do offset bottom-positioned buttons 56px above the newsticker bar
|
||||||
|
- Do hide all media elements by default; show only the active one
|
||||||
|
- Do ensure WCAG AA contrast ratios for all text-on-background combinations
|
||||||
16
Dockerfile
16
Dockerfile
@@ -1,12 +1,20 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1000 app && \
|
||||||
|
adduser --system --uid 1000 --ingroup app --disabled-password --disabled-login --no-create-home app && \
|
||||||
|
chown -R app:app /app && chmod -R u+w /app && \
|
||||||
|
find / -perm /4000 -o -perm /2000 2>/dev/null | xargs -r chmod ug-s
|
||||||
|
|
||||||
|
USER app
|
||||||
|
ENV HOME=/tmp
|
||||||
|
|
||||||
EXPOSE 5005
|
EXPOSE 5005
|
||||||
|
|
||||||
CMD ["python", "app.py"]
|
CMD ["gunicorn", "-b", "0.0.0.0:5005", "-w", "4", "app:app"]
|
||||||
|
|||||||
194
README.md
194
README.md
@@ -17,7 +17,15 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
|
|||||||
- **Custom-URL-Button**: pro Screen konfigurierbarer Aktions-Button mit frei wählbarer Position (9 Positionen) im Player (öffnet URL in iframe-Overlay mit Zurück-Button oder per Direkt-Weiterleitung)
|
- **Custom-URL-Button**: pro Screen konfigurierbarer Aktions-Button mit frei wählbarer Position (9 Positionen) im Player (öffnet URL in iframe-Overlay mit Zurück-Button oder per Direkt-Weiterleitung)
|
||||||
- **Digital Voice Agent**: global konfigurierbarer Voice-Agent-Button pro Screen (9 Positionen, iframe-Overlay mit positionsgetreuem Zurück-Button, Typewriter-Tagline mit mehrsprachigen Wechseltexten, optionales Bild über dem Button)
|
- **Digital Voice Agent**: global konfigurierbarer Voice-Agent-Button pro Screen (9 Positionen, iframe-Overlay mit positionsgetreuem Zurück-Button, Typewriter-Tagline mit mehrsprachigen Wechseltexten, optionales Bild über dem Button)
|
||||||
- **Stay-on-First**: Screen kann auf erstem Playlist-Element stehen bleiben (kein automatischer Refresh)
|
- **Stay-on-First**: Screen kann auf erstem Playlist-Element stehen bleiben (kein automatischer Refresh)
|
||||||
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien (Tabler Tabs)
|
- **Online/Offline-Status**: Player-Status wird via dateibasiertem Heartbeat ermittelt – grün (online) bei aktivem Player, rot (offline) nach 60s ohne Poll
|
||||||
|
- **Client-Info**: Tooltip beim Hovern über den Online-Button zeigt Browser, Bildschirmauflösung, IP-Adresse und letzten Heartbeat-Zeitpunkt; Info-Tab pro Screen zeigt detaillierte Client-Info-Tabelle
|
||||||
|
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
|
||||||
|
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
|
||||||
|
- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung, **Trusted Device** (Gerät für 1–30 Tage speichern, überspringt MFA beim nächsten Login)
|
||||||
|
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf
|
||||||
|
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt
|
||||||
|
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
|
||||||
|
- **Query-Parameter-Passthrough**: Per Player-URL übergebene Query-Parameter (z. B. `/player/stuttgart/lobby?customer=acme`) können pro Playlist-URL im Edit-Modal aktiviert werden – standardmäßig aus. Link-Icon in der Playlist-Zeile zeigt Status (an/aus) an.
|
||||||
- Priority-Seite ebenfalls mit Tabs: Playlist und Medien
|
- Priority-Seite ebenfalls mit Tabs: Playlist und Medien
|
||||||
- Dark Mode (localStorage-persistiert)
|
- Dark Mode (localStorage-persistiert)
|
||||||
- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot)
|
- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot)
|
||||||
@@ -35,6 +43,9 @@ Flask App (Server)
|
|||||||
├── Player UI /player/<site>/<screen>
|
├── Player UI /player/<site>/<screen>
|
||||||
├── Priority-Seite /admin/<site>/priority
|
├── Priority-Seite /admin/<site>/priority
|
||||||
├── config.json
|
├── config.json
|
||||||
|
├── users.json
|
||||||
|
├── history.json
|
||||||
|
├── /tmp/signage-heartbeat/ (Heartbeat-JSONs mit Client-Info für Online-Status)
|
||||||
├── media/
|
├── media/
|
||||||
│ ├── <site>/
|
│ ├── <site>/
|
||||||
│ │ ├── <screen>/
|
│ │ ├── <screen>/
|
||||||
@@ -59,6 +70,8 @@ signage/
|
|||||||
├── app.py # Flask-App (alle Routen)
|
├── app.py # Flask-App (alle Routen)
|
||||||
├── generate_welcome_page.py # Logo-Suche + Willkommensseite-Generierung
|
├── generate_welcome_page.py # Logo-Suche + Willkommensseite-Generierung
|
||||||
├── config.json # Persistente Konfiguration
|
├── config.json # Persistente Konfiguration
|
||||||
|
├── users.json # User-Datenbank (gehashte Passwörter)
|
||||||
|
├── history.json # Aktivitätsverlauf (Dashboard)
|
||||||
├── media/
|
├── media/
|
||||||
│ ├── <site>/
|
│ ├── <site>/
|
||||||
│ │ ├── lobby/
|
│ │ ├── lobby/
|
||||||
@@ -67,11 +80,19 @@ signage/
|
|||||||
│ └── priority/
|
│ └── priority/
|
||||||
├── templates/
|
├── templates/
|
||||||
│ ├── admin.html # Admin-Dashboard (Übersicht)
|
│ ├── admin.html # Admin-Dashboard (Übersicht)
|
||||||
|
│ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf)
|
||||||
│ ├── priority.html # Priority-Playlist (eigene Seite)
|
│ ├── priority.html # Priority-Playlist (eigene Seite)
|
||||||
│ ├── customer.html # Willkommensseite-Formular
|
│ ├── customer.html # Willkommensseite-Formular
|
||||||
│ ├── player.html # Player-Ansicht
|
│ ├── player.html # Player-Ansicht
|
||||||
│ ├── login.html
|
│ ├── login.html
|
||||||
│ ├── _header.html # Gemeinsamer Header
|
│ ├── change_password.html # Passwort ändern (First-Login / Reset)
|
||||||
|
│ ├── user_list.html # User-Liste (Admin)
|
||||||
|
│ ├── user_create.html # User anlegen (Admin)
|
||||||
|
│ ├── user_edit.html # User bearbeiten (Admin)
|
||||||
|
│ ├── mfa_setup.html # MFA einrichten (QR-Code + Deaktivierung)
|
||||||
|
│ ├── mfa_verify.html # TOTP-Code nach Login eingeben
|
||||||
|
│ ├── mfa_recovery.html # Einmalige Recovery-Codes-Anzeige
|
||||||
|
│ ├── _header.html # Gemeinsamer Header (MFA-Link + Aktiv-Badge)
|
||||||
│ ├── _footer.html # Gemeinsamer Footer
|
│ ├── _footer.html # Gemeinsamer Footer
|
||||||
│ └── _styles.html # Zentrale CSS (Variablen, Dark Mode)
|
│ └── _styles.html # Zentrale CSS (Variablen, Dark Mode)
|
||||||
├── static/
|
├── static/
|
||||||
@@ -107,10 +128,52 @@ Server läuft auf `http://localhost:5005`.
|
|||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
|
Empfohlenes `docker-compose.yml` mit Bind-Mounts für persistente Daten:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
signage:
|
||||||
|
container_name: signage
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
image: your-registry/signage:latest
|
||||||
|
ports:
|
||||||
|
- "5005:5005"
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
volumes:
|
||||||
|
- ./media:/app/media
|
||||||
|
- ./config.json:/app/config.json
|
||||||
|
- ./users.json:/app/users.json
|
||||||
|
- ./history.json:/app/history.json
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Berlin
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Container läuft als User mit **UID 1000** ohne Shell, auf **read-only Root-FS** ohne Capabilities. Daher vor dem ersten Start die Berechtigungen setzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chown 1000:1000 config.json users.json history.json
|
||||||
|
chown -R 1000:1000 media/
|
||||||
|
```
|
||||||
|
|
||||||
|
Start:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Routen
|
## Routen
|
||||||
@@ -121,6 +184,7 @@ docker compose up -d
|
|||||||
| `GET /login` | Admin-Login |
|
| `GET /login` | Admin-Login |
|
||||||
| `GET /logout` | Ausloggen |
|
| `GET /logout` | Ausloggen |
|
||||||
| `GET /admin` | Redirect zum ersten konfigurierten Standort |
|
| `GET /admin` | Redirect zum ersten konfigurierten Standort |
|
||||||
|
| `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf) |
|
||||||
| `GET /admin/<site>` | Admin-Dashboard für einen Standort |
|
| `GET /admin/<site>` | Admin-Dashboard für einen Standort |
|
||||||
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
|
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
|
||||||
| `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
|
| `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
|
||||||
@@ -137,20 +201,46 @@ docker compose up -d
|
|||||||
| `POST /admin/<site>/upload-background` | Hintergrundbild für Willkommensseite hochladen |
|
| `POST /admin/<site>/upload-background` | Hintergrundbild für Willkommensseite hochladen |
|
||||||
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
|
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
|
||||||
| `GET /player/<site>/<screen>` | Player-Ansicht |
|
| `GET /player/<site>/<screen>` | Player-Ansicht |
|
||||||
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) |
|
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) |
|
||||||
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) |
|
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) |
|
||||||
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
|
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
|
||||||
|
| `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) |
|
||||||
|
| `GET /admin/users` | User-Liste (Admin) |
|
||||||
|
| `GET /admin/users/create` | User anlegen (Admin) |
|
||||||
|
| `GET /admin/users/edit/<email>` | User bearbeiten (Admin) |
|
||||||
|
| `GET /mfa/setup` | MFA einrichten / deaktivieren (QR-Code + Verifikation) |
|
||||||
|
| `POST /mfa/setup` | TOTP-Code verifizieren und MFA aktivieren |
|
||||||
|
| `GET /mfa/verify` | TOTP-Code nach Login eingeben |
|
||||||
|
| `POST /mfa/verify` | TOTP-Code prüfen oder Recovery-Code verwenden |
|
||||||
|
| `POST /mfa/disable` | MFA deaktivieren (mit Passwort-Bestätigung) |
|
||||||
|
| `POST /admin/users/create` | User anlegen (Admin) |
|
||||||
|
| `POST /admin/users/delete/<email>` | User löschen (Admin, letzter Admin geschützt) |
|
||||||
|
| `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) |
|
||||||
|
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
|
||||||
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
||||||
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
||||||
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
||||||
|
|
||||||
### Admin-Portal
|
### Heartbeat / Online-Status
|
||||||
|
|
||||||
|
Der Player ruft alle 5s `/playlist/<site>/<screen>/hash?w=<breite>&h=<höhe>` auf. Der Server speichert dabei ein JSON mit Client-Info (IP, User-Agent, Auflösung) nach `/tmp/signage-heartbeat/<site>/<screen>.json`. Im Admin-Dashboard wird geprüft, ob der Timestamp < 60s alt ist → grüner "Online"-Button mit Tooltip (Browser, Auflösung, IP, letztes Update) oder roter "Offline"-Button pro Screen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Heartbeat-Dateien anzeigen
|
||||||
|
ls -la /tmp/signage-heartbeat/*/
|
||||||
|
cat /tmp/signage-heartbeat/stuttgart/lobby.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach einem Server-Neustart werden alle Screens kurz rot, bis die Player wieder pollen (max. 60s).
|
||||||
|
|
||||||
|
### Admin Portal
|
||||||
|
|
||||||
```
|
```
|
||||||
http://localhost:5005/admin/<standort>
|
http://localhost:5005/admin/<standort>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Screens konfigurieren (Tab-basiert: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien)
|
- Screens konfigurieren (Tab-basiert: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info)
|
||||||
|
- Info-Tab pro Screen zeigt Client-Info-Tabelle: IP-Adresse, Browser (User-Agent), Bildschirmauflösung und letzter Heartbeat-Zeitpunkt
|
||||||
- Medien hochladen / löschen
|
- Medien hochladen / löschen
|
||||||
- Playlist per Drag & Drop sortieren
|
- Playlist per Drag & Drop sortieren
|
||||||
- Priority-Playlist verwalten (ebenfalls mit Tabs)
|
- Priority-Playlist verwalten (ebenfalls mit Tabs)
|
||||||
@@ -159,6 +249,17 @@ http://localhost:5005/admin/<standort>
|
|||||||
- Digital Voice Agent pro Screen konfigurieren (Position, Bild ein/aus, Typewriter-Tagline)
|
- Digital Voice Agent pro Screen konfigurieren (Position, Bild ein/aus, Typewriter-Tagline)
|
||||||
- Stay-on-First-Modus pro Screen (kein automatischer Refresh)
|
- Stay-on-First-Modus pro Screen (kein automatischer Refresh)
|
||||||
- Standorte anlegen & löschen
|
- Standorte anlegen & löschen
|
||||||
|
- **Userverwaltung**: User anlegen, bearbeiten, löschen, Passwort-Reset (temporäres Passwort wird angezeigt), MFA-Status pro User in der Liste (grünes "Aktiv"-Badge oder "–")
|
||||||
|
|
||||||
|
### Admin Dashboard
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:5005/admin/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
- 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige
|
||||||
|
- Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User
|
||||||
|
- Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert
|
||||||
|
|
||||||
### Player-URL
|
### Player-URL
|
||||||
|
|
||||||
@@ -171,6 +272,17 @@ Beispiel:
|
|||||||
http://localhost:5005/player/stuttgart/lobby
|
http://localhost:5005/player/stuttgart/lobby
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Query-Parameter-Passthrough
|
||||||
|
|
||||||
|
Die Player-URL akzeptiert beliebige Query-Parameter, z. B. `?customer=acme&token=xyz`. Ob diese Parameter an eine Playlist-URL weitergegeben werden, wird pro URL im Admin-Playlist-Edit-Modal gesteuert (Checkbox "Query-Parameter an diese URL übergeben", standardmäßig aus). In der Playlist-Zeile zeigt ein Link-Icon (`ti-link`, grau) den Status an: sichtbar = an, durchgestrichen (`ti-link-off`, hellgrau) = aus.
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
```
|
||||||
|
/player/stuttgart/lobby?customer=acme&token=xyz
|
||||||
|
```
|
||||||
|
- URL in Playlist mit **aktiviertem** Passthrough: `https://dashboard.acme.com/status?customer=acme&token=xyz`
|
||||||
|
- URL in Playlist mit **deaktiviertem** Passthrough: `https://dashboard.acme.com/status`
|
||||||
|
|
||||||
### Willkommensseite
|
### Willkommensseite
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -179,16 +291,69 @@ http://localhost:5005/willkommen?site=stuttgart
|
|||||||
|
|
||||||
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in der Lobby-Playlist vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
|
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in der Lobby-Playlist vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
|
||||||
|
|
||||||
|
**Ansprechpartner:** Pro Kunde können Kontaktdaten eingegeben werden (Markdown-Format).
|
||||||
|
- **Fett**: `**Text**`
|
||||||
|
- *Kursiv*: `*Text*`
|
||||||
|
- `Code`: `` `Code` ``
|
||||||
|
- Aufzählungen: `- Punkt` oder `* Punkt`
|
||||||
|
- Zeilenumbrüche werden als `<br>` übernommen
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- Landscape: Logos nebeneinander (280px breit, 120px hoch), Texte linksbündig darunter
|
||||||
|
- Portrait: Texte links vom Logo, per `order`-CSS gesteuert
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Globale Konfiguration
|
### OpenAI API-Key
|
||||||
|
|
||||||
|
Die Logo-Suche via OpenAI benötigt einen API-Key. Diesen als Umgebungsvariable setzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENAI_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder via `.env`-Datei (wird von Docker Compose automatisch geladen):
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Globale Konfiguration (config.json)
|
||||||
|
|
||||||
| Feld | Typ | Beschreibung |
|
| Feld | Typ | Beschreibung |
|
||||||
|------|-----|-------------|
|
|------|-----|-------------|
|
||||||
| `server_url` | string | Öffentliche Server-URL für Player-Links |
|
| `server_url` | string | Öffentliche Server-URL für Player-Links |
|
||||||
| `voice_agent_url` | string | URL des Digital Voice Agents (global, read-only im Admin) |
|
| `voice_agent_url` | string | URL des Digital Voice Agents (global, read-only im Admin) |
|
||||||
|
|
||||||
## Konfiguration (`config.json`)
|
### User-Rollen (users.json)
|
||||||
|
|
||||||
|
| Rolle | Zugriff | Userverwaltung |
|
||||||
|
|-------|---------|----------------|
|
||||||
|
| **Admin** | Alle Standorte | Ja (anlegen/bearbeiten/löschen/Reset) |
|
||||||
|
| **Superuser** | Alle Standorte | Nein |
|
||||||
|
| **User** | Nur freigegebene Standorte | Nein |
|
||||||
|
|
||||||
|
- Usernamen sind E-Mail-Adressen
|
||||||
|
- Passwörter werden als scrypt-Hash gespeichert
|
||||||
|
- `users.json` wird beim ersten Start aus `config.json.admin` befüllt
|
||||||
|
- **Notfall**: `users.json` löschen → Server-Neustart erzeugt neuen Admin aus `config.json`
|
||||||
|
|
||||||
|
### User-Felder (`users.json`)
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|------|-----|-------------|
|
||||||
|
| `password_hash` | string | scrypt-Hash des Passworts |
|
||||||
|
| `role` | string | `admin`, `superuser` oder `user` |
|
||||||
|
| `sites` | array | Freigegebene Standorte (nur für User-Rolle) |
|
||||||
|
| `must_change_password` | bool | Erzwingt Passwort-Änderung beim nächsten Login |
|
||||||
|
| `first_name` / `last_name` | string | Vor- und Nachname |
|
||||||
|
| `department` | string | Abteilung |
|
||||||
|
| `notes` | string | Interne Notizen |
|
||||||
|
| `mfa_enabled` | bool | MFA aktiv (TOTP) |
|
||||||
|
| `mfa_secret` | string | TOTP-Secret (sofort beim ersten GET `/mfa/setup` persistiert) |
|
||||||
|
| `mfa_recovery_codes` | array | 8 Recovery-Codes (hex, 7 Stellen, einmalig verwendbar) |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -269,6 +434,13 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
|
|||||||
- `load_priority_files()` – Priority-Playlist + Dateien laden
|
- `load_priority_files()` – Priority-Playlist + Dateien laden
|
||||||
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
|
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
|
||||||
- `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None`
|
- `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None`
|
||||||
|
- `load_users()` / `save_users()` / `get_user()` – User aus `users.json`
|
||||||
|
- `init_user_db()` – initialen Admin aus `config.json` anlegen
|
||||||
|
- `get_accessible_sites(cfg, user)` – Standorte nach User-Berechtigung filtern
|
||||||
|
- `record_heartbeat(site, screen, ip, ua, resolution)` – Heartbeat-JSON mit Client-Info schreiben
|
||||||
|
- `screen_is_active(site, screen)` – Prüft ob Heartbeat < 60s alt ist
|
||||||
|
- `get_client_info(site, screen)` – Gibt gespeicherte Client-Info zurück (Dict mit ip, ua, resolution, last_seen)
|
||||||
|
- `admin_required` / `site_access_required` – Zugriffs-Dekoratoren
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -280,7 +452,13 @@ Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Heade
|
|||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- Admin-Bereich per Flask-Login geschützt
|
- Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort)
|
||||||
|
- Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy)
|
||||||
|
- 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt
|
||||||
|
- Passwörter gehasht (scrypt) in `users.json`
|
||||||
|
- Drei Rollen: Admin (alle Standorte + Userverwaltung), Superuser (alle Standorte), User (nur zugewiesene Standorte)
|
||||||
|
- Zugriffskontrolle per Decorator (`@admin_required`, `@site_access_required`)
|
||||||
|
- Letzter Admin kann nicht gelöscht werden
|
||||||
- Player-Seiten Read-Only
|
- Player-Seiten Read-Only
|
||||||
- Externe Nutzung via Reverse Proxy + TLS empfohlen
|
- Externe Nutzung via Reverse Proxy + TLS empfohlen
|
||||||
|
|
||||||
|
|||||||
55
config.json
55
config.json
@@ -15,11 +15,14 @@
|
|||||||
"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",
|
{
|
||||||
|
"name": "welcome.html",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
|
"url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
|
||||||
"zoom": 1.0,
|
"zoom": 1.0,
|
||||||
@@ -35,8 +38,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 +47,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,
|
||||||
@@ -95,11 +99,9 @@
|
|||||||
"screens": {
|
"screens": {
|
||||||
"lobby": {
|
"lobby": {
|
||||||
"playlist": [
|
"playlist": [
|
||||||
"welcome.html",
|
|
||||||
{
|
{
|
||||||
"url": "https://dva-stage.cancom-ds.de/",
|
"name": "welcome.html",
|
||||||
"zoom": 1.0,
|
"enabled": true
|
||||||
"enabled": false
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"interval": 10,
|
"interval": 10,
|
||||||
@@ -123,35 +125,6 @@
|
|||||||
"https://cdn.brandfetch.io/weisenburger.de/logo?c=1idyd4Tpb2nKaXIIc8T"
|
"https://cdn.brandfetch.io/weisenburger.de/logo?c=1idyd4Tpb2nKaXIIc8T"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"dva": {
|
|
||||||
"screens": {
|
|
||||||
"dva": {
|
|
||||||
"playlist": [
|
|
||||||
{
|
|
||||||
"url": "https://dva-stage.cancom-ds.de/",
|
|
||||||
"zoom": 1.0,
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"interval": 10,
|
|
||||||
"show_images": true,
|
|
||||||
"show_videos": true,
|
|
||||||
"newsticker_text": "Wichtige Meldung",
|
|
||||||
"newsticker_enabled": false,
|
|
||||||
"stay_on_first": true,
|
|
||||||
"custom_url": "https://npm.teamthiele.de/nginx/proxy",
|
|
||||||
"custom_url_label": "Reverse Proxy",
|
|
||||||
"custom_url_enabled": false,
|
|
||||||
"custom_url_target": "overlay",
|
|
||||||
"custom_url_position": "top-center",
|
|
||||||
"voice_agent_enabled": true,
|
|
||||||
"voice_agent_label": "",
|
|
||||||
"voice_agent_target": "overlay",
|
|
||||||
"voice_agent_position": "top-left",
|
|
||||||
"voice_agent_show_image": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
@@ -11,4 +11,12 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5005:5005"
|
- "5005:5005"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
environment:
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ WELCOME_FILENAME = "welcome.html"
|
|||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
|
|
||||||
def get_openai_client():
|
def get_openai_client():
|
||||||
"""Initialisiert den OpenAI-Client mit dem API-Key.
|
api_key = os.environ.get("OPENAI_API_KEY")
|
||||||
Der Key ist aktuell direkt im Code hinterlegt – sollte in env var ausgelagert werden."""
|
|
||||||
api_key = "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"
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise ValueError("OPENAI_API_KEY environment variable not set")
|
raise ValueError("OPENAI_API_KEY environment variable not set")
|
||||||
return OpenAI(api_key=api_key)
|
return OpenAI(api_key=api_key)
|
||||||
@@ -97,14 +95,47 @@ def search_customer_logo(customer_name):
|
|||||||
# HTML-Generierung
|
# HTML-Generierung
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
|
|
||||||
def generate_welcome_html(customer_names, logo_urls, site="stuttgart", background_url=None):
|
def md_to_html(text):
|
||||||
|
"""Wandelt einfaches Markdown in HTML um (fett, kursiv, code, zeilenumbrüche, aufzählungen)."""
|
||||||
|
import re
|
||||||
|
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
|
||||||
|
text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
|
||||||
|
text = re.sub(r'`(.+?)`', r'<code>\1</code>', text)
|
||||||
|
lines = text.split('\n')
|
||||||
|
parts = []
|
||||||
|
in_list = False
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
is_li = stripped.startswith('- ') or stripped.startswith('* ')
|
||||||
|
if is_li:
|
||||||
|
if not in_list:
|
||||||
|
parts.append('<ul>')
|
||||||
|
in_list = True
|
||||||
|
parts.append(f'<li>{stripped[2:]}</li>')
|
||||||
|
else:
|
||||||
|
if in_list:
|
||||||
|
parts.append('</ul>')
|
||||||
|
in_list = False
|
||||||
|
if stripped:
|
||||||
|
parts.append(stripped)
|
||||||
|
parts.append('<br>')
|
||||||
|
if in_list:
|
||||||
|
parts.append('</ul>')
|
||||||
|
if parts and parts[-1] == '<br>':
|
||||||
|
parts.pop()
|
||||||
|
return ''.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None):
|
||||||
"""
|
"""
|
||||||
Generiert den HTML-Code für die Willkommensseite.
|
Generiert den HTML-Code für die Willkommensseite.
|
||||||
- Links oben: CANCOM-Logo + "meets" + Kundenlogos (280px Breite)
|
- Links oben: CANCOM-Logo + "meets" + Kundenlogos
|
||||||
- Rechts unten: "Herzlich Willkommen in unserer Niederlassung {Standort}"
|
- Unter jedem Logo: Ansprechpartner-Kontaktdaten (Landscape)
|
||||||
|
- Portrait: Kontaktdaten links neben den Logos
|
||||||
|
|
||||||
customer_names: Liste der Kundennamen (max. 3)
|
customer_names: Liste der Kundennamen (max. 3)
|
||||||
logo_urls: Liste der Logo-URLs (gleiche Reihenfolge)
|
logo_urls: Liste der Logo-URLs (gleiche Reihenfolge)
|
||||||
|
contacts: Liste der Ansprechpartner-Texte (Markdown, optional)
|
||||||
site: Standort-Name für die Anzeige
|
site: Standort-Name für die Anzeige
|
||||||
background_url: URLs zum Hintergrundbild (None → Standard-Wallpaper)
|
background_url: URLs zum Hintergrundbild (None → Standard-Wallpaper)
|
||||||
"""
|
"""
|
||||||
@@ -113,15 +144,24 @@ def generate_welcome_html(customer_names, logo_urls, site="stuttgart", backgroun
|
|||||||
print(f"✅ Generate - Willkommensseite wird generiert für: {names_str} (site: {site})")
|
print(f"✅ Generate - Willkommensseite wird generiert für: {names_str} (site: {site})")
|
||||||
site_display = site.capitalize()
|
site_display = site.capitalize()
|
||||||
|
|
||||||
|
if contacts is None:
|
||||||
|
contacts = [""] * len(customer_names)
|
||||||
|
|
||||||
bg_image = background_url if background_url else "/static/wallpaper.png"
|
bg_image = background_url if background_url else "/static/wallpaper.png"
|
||||||
cancom_svg = '''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xcs="https://www.xtool.com/pages/software" version="1.1" preserveAspectRatio="xMinYMin meet" width="61.682394106284505mm" height="9.909010347638684mm" viewBox="136.55880294685778 209.2954948261807 61.682394106284505 9.909010347638684" xcs:version="2.7.22"><style></style><path transform="matrix(0.158038,0,0,0.158038,133.2558,163.322122)" stroke="none" fill="currentColor" data-view-type="laser" d="M125.9,352.1h18.9L119,291.7h-16.2l-25,60.4h18.2l14.6-38.9L125.9,352.1z M323.6,322 c0,8.8-6.1,16.4-15.4,16.4c-9.1,0-15.4-7.6-15.4-16.4s6.1-16.4,15.2-16.4C317,305.6,323.6,313.4,323.6,322 M340.5,322.3 c0-17.4-13.4-31.3-32.3-31.3c-18.7,0-32.3,14.1-32.3,31.3c0,17.4,13.4,31.3,32.3,31.3C326.9,353.6,340.5,339.4,340.5,322.3 M230.4,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.2,0-31.6,14.1-31.6,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C236.5,338.4,230.4,331.4,230.4,322.5 M38,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.4,0-31.8,14.1-31.8,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C44,338.4,38,331.4,38,322.5 M191.8,352.1h14.4v-59.8H190v33.1l-25-33.1h-15.4v60.1h16.2v-34.1L191.8,352.1z M411.2,352.1v-59.8h-17.7l-14.4,23.2l-14.4-23.2 h-17.7v60.1h16.4V318l15.4,23.2h0.3l15.4-23.5v34.6L411.2,352.1L411.2,352.1z" fill-rule="nonzero"></path></svg>'''
|
cancom_svg = '''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xcs="https://www.xtool.com/pages/software" version="1.1" preserveAspectRatio="xMinYMin meet" width="61.682394106284505mm" height="9.909010347638684mm" viewBox="136.55880294685778 209.2954948261807 61.682394106284505 9.909010347638684" xcs:version="2.7.22"><style></style><path transform="matrix(0.158038,0,0,0.158038,133.2558,163.322122)" stroke="none" fill="currentColor" data-view-type="laser" d="M125.9,352.1h18.9L119,291.7h-16.2l-25,60.4h18.2l14.6-38.9L125.9,352.1z M323.6,322 c0,8.8-6.1,16.4-15.4,16.4c-9.1,0-15.4-7.6-15.4-16.4s6.1-16.4,15.2-16.4C317,305.6,323.6,313.4,323.6,322 M340.5,322.3 c0-17.4-13.4-31.3-32.3-31.3c-18.7,0-32.3,14.1-32.3,31.3c0,17.4,13.4,31.3,32.3,31.3C326.9,353.6,340.5,339.4,340.5,322.3 M230.4,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.2,0-31.6,14.1-31.6,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C236.5,338.4,230.4,331.4,230.4,322.5 M38,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.4,0-31.8,14.1-31.8,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C44,338.4,38,331.4,38,322.5 M191.8,352.1h14.4v-59.8H190v33.1l-25-33.1h-15.4v60.1h16.2v-34.1L191.8,352.1z M411.2,352.1v-59.8h-17.7l-14.4,23.2l-14.4-23.2 h-17.7v60.1h16.4V318l15.4,23.2h0.3l15.4-23.5v34.6L411.2,352.1L411.2,352.1z" fill-rule="nonzero"></path></svg>'''
|
||||||
|
|
||||||
logo_html = ""
|
logo_html = ""
|
||||||
for i, (name, url) in enumerate(zip(customer_names, logo_urls)):
|
for i, (name, url) in enumerate(zip(customer_names, logo_urls)):
|
||||||
if url:
|
if url:
|
||||||
|
contact_html = ""
|
||||||
|
if i < len(contacts) and contacts[i]:
|
||||||
|
contact_html = f'''<div class="contact-info">{md_to_html(contacts[i])}</div>'''
|
||||||
logo_html += f'''
|
logo_html += f'''
|
||||||
<div class="customer-logo-item">
|
<div class="customer-logo-item">
|
||||||
<img src="{url}" alt="{name}" class="customer-logo" crossorigin="anonymous">
|
<img src="{url}" alt="{name}" class="customer-logo" crossorigin="anonymous">
|
||||||
|
<div class="customer-meta">
|
||||||
|
{contact_html}
|
||||||
|
</div>
|
||||||
</div>'''
|
</div>'''
|
||||||
|
|
||||||
html_content = f"""<!DOCTYPE html>
|
html_content = f"""<!DOCTYPE html>
|
||||||
@@ -165,19 +205,60 @@ def generate_welcome_html(customer_names, logo_urls, site="stuttgart", backgroun
|
|||||||
}}
|
}}
|
||||||
.customer-logos {{
|
.customer-logos {{
|
||||||
display: flex; flex-direction: row;
|
display: flex; flex-direction: row;
|
||||||
align-items: center; justify-content: flex-end;
|
align-items: flex-start; justify-content: flex-end;
|
||||||
gap: 48px; max-width: 100%;
|
gap: 48px; max-width: 100%;
|
||||||
}}
|
}}
|
||||||
.customer-logo-item {{
|
.customer-logo-item {{
|
||||||
flex: 0 0 280px;
|
flex: 0 0 280px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; flex-direction: column;
|
||||||
min-height: 100px;
|
align-items: stretch; justify-content: flex-start;
|
||||||
|
}}
|
||||||
|
.customer-meta {{
|
||||||
|
width: 100%; text-align: left;
|
||||||
}}
|
}}
|
||||||
.customer-logo {{
|
.customer-logo {{
|
||||||
width: 100%; height: auto;
|
width: 100%; height: 120px;
|
||||||
max-height: 180px;
|
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.15));
|
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.15));
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.contact-info {{
|
||||||
|
font-size: 1.3rem; color: #333; line-height: 1.3;
|
||||||
|
margin-top: 24px; text-align: left;
|
||||||
|
}}
|
||||||
|
.contact-info strong {{ color: #000; }}
|
||||||
|
.contact-info code {{
|
||||||
|
font-size: 0.8rem; padding: 1px 4px;
|
||||||
|
background: rgba(0,0,0,0.06); border-radius: 3px;
|
||||||
|
}}
|
||||||
|
.contact-info ul {{
|
||||||
|
margin: 0; padding-left: 0;
|
||||||
|
list-style-position: inside;
|
||||||
|
}}
|
||||||
|
.contact-info li {{
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
}}
|
||||||
|
@media (orientation: portrait) {{
|
||||||
|
.customer-logos {{
|
||||||
|
flex-direction: column; gap: 20px;
|
||||||
|
}}
|
||||||
|
.customer-logo-item {{
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: row; gap: 40px;
|
||||||
|
width: 100%; max-width: 600px;
|
||||||
|
}}
|
||||||
|
.customer-logo {{
|
||||||
|
max-height: 100px; width: 180px; flex-shrink: 0;
|
||||||
|
order: 2;
|
||||||
|
}}
|
||||||
|
.contact-info {{
|
||||||
|
text-align: left;
|
||||||
|
}}
|
||||||
|
.customer-meta {{
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: flex-start; flex: 1;
|
||||||
|
order: 1;
|
||||||
|
}}
|
||||||
}}
|
}}
|
||||||
.content {{
|
.content {{
|
||||||
text-align: right; z-index: 10;
|
text-align: right; z-index: 10;
|
||||||
@@ -215,15 +296,16 @@ def generate_welcome_html(customer_names, logo_urls, site="stuttgart", backgroun
|
|||||||
# Speichern + Playlist-Einfügung
|
# Speichern + Playlist-Einfügung
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
|
|
||||||
def save_welcome_page(customer_names, logo_urls, site="stuttgart", background_url=None):
|
def save_welcome_page(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None):
|
||||||
"""Speichert die generierte Willkommensseite als welcome.html im Lobby-Verzeichnis.
|
"""Speichert die generierte Willkommensseite als welcome.html im Lobby-Verzeichnis.
|
||||||
|
contacts: Liste der Ansprechpartner-Texte (Markdown, optional)
|
||||||
background_url: Benutzerdefiniertes Hintergrundbild oder None (Standard).
|
background_url: Benutzerdefiniertes Hintergrundbild oder None (Standard).
|
||||||
Gibt den Dateinamen zurück oder None bei Fehler."""
|
Gibt den Dateinamen zurück oder None bei Fehler."""
|
||||||
try:
|
try:
|
||||||
welcome_dir = os.path.join(MEDIA_DIR, site, "lobby")
|
welcome_dir = os.path.join(MEDIA_DIR, site, "lobby")
|
||||||
os.makedirs(welcome_dir, exist_ok=True)
|
os.makedirs(welcome_dir, exist_ok=True)
|
||||||
filepath = os.path.join(os.path.abspath(welcome_dir), WELCOME_FILENAME)
|
filepath = os.path.join(os.path.abspath(welcome_dir), WELCOME_FILENAME)
|
||||||
html_content = generate_welcome_html(customer_names, logo_urls, site=site, background_url=background_url)
|
html_content = generate_welcome_html(customer_names, logo_urls, contacts=contacts, site=site, background_url=background_url)
|
||||||
|
|
||||||
with open(filepath, "w", encoding="utf-8") as f:
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
f.write(html_content)
|
f.write(html_content)
|
||||||
|
|||||||
662
history.json
Normal file
662
history.json
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:15:59",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (Trusted Device)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:15:56",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:15:51",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:13:50",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:13:46",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:11:43",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:11:39",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:10:18",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:10:14",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:07:35",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 16:07:27",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:45:29",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:45:20",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:44:47",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:41:11",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:40:59",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:40:56",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:40:37",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:40:28",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 14 Tage gespeichert)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:40:03",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:39:57",
|
||||||
|
"action": "mfa_enabled",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' hat MFA aktiviert",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:39:32",
|
||||||
|
"action": "password_changed",
|
||||||
|
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' ge\u00e4ndert",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:39:22",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:39:15",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'admin' abgemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:38:55",
|
||||||
|
"action": "user_password_reset",
|
||||||
|
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' zur\u00fcckgesetzt (MFA deaktiviert)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:38:49",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:31:55",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'admin' abgemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:31:42",
|
||||||
|
"action": "user_password_reset",
|
||||||
|
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' zur\u00fcckgesetzt",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10 15:31:14",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-02 12:25:24",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 16:04:39",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 16:04:11",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 15:12:39",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 15:08:14",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 15:04:00",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 15:00:36",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 15:00:15",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'admin' abgemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:59:59",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:59:34",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:57:59",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:55:28",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:55:09",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:53:29",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:52:57",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:52:52",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:52:45",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:49:44",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:49:08",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:48:51",
|
||||||
|
"action": "mfa_enabled",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' hat MFA aktiviert",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:46:23",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:46:19",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:45:59",
|
||||||
|
"action": "mfa_disabled",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' hat MFA deaktiviert",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:42:00",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:35:45",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:33:52",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:33:35",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:32:41",
|
||||||
|
"action": "mfa_enabled",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' hat MFA aktiviert",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:31:07",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet (mit MFA)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:30:25",
|
||||||
|
"action": "mfa_enabled",
|
||||||
|
"detail": "User 'admin' hat MFA aktiviert",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:30:25",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:29:49",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:17:41",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:17:35",
|
||||||
|
"action": "user_logout",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:12:52",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:11:41",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:10:12",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 14:00:10",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet (mit MFA)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 13:59:43",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet (mit MFA)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 13:59:16",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet (mit MFA)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 13:58:30",
|
||||||
|
"action": "mfa_enabled",
|
||||||
|
"detail": "User 'admin' hat MFA aktiviert",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 13:58:30",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 13:58:23",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 13:57:59",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 13:28:44",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 13:25:50",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-28 13:25:28",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'admin' angemeldet",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-26 14:49:09",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:38:54",
|
||||||
|
"action": "superuser_deleted",
|
||||||
|
"detail": "Superuser-Rechte von 'test1@test.de' entzogen",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:38:54",
|
||||||
|
"action": "user_updated",
|
||||||
|
"detail": "User 'test1@test.de' bearbeitet (Rolle: superuser \u2192 user)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:38:19",
|
||||||
|
"action": "site_deleted",
|
||||||
|
"detail": "Standort 'wdqwsqws' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:30:45",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen '11wssss' an Standort 'wdqwsqws' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:30:41",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'dqwqqq' an Standort 'wdqwsqws' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:30:38",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'asdsad' an Standort 'wdqwsqws' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:29:59",
|
||||||
|
"action": "site_deleted",
|
||||||
|
"detail": "Standort 'qwsqwsqs' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:29:53",
|
||||||
|
"action": "site_created",
|
||||||
|
"detail": "Standort 'qwsqwsqs' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:29:49",
|
||||||
|
"action": "site_created",
|
||||||
|
"detail": "Standort 'wdqwsqws' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:29:33",
|
||||||
|
"action": "site_deleted",
|
||||||
|
"detail": "Standort 'wsqwdsqws' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:29:28",
|
||||||
|
"action": "site_created",
|
||||||
|
"detail": "Standort 'wsqwdsqws' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:27:58",
|
||||||
|
"action": "site_deleted",
|
||||||
|
"detail": "Standort 'sdwqdwddd' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:27:45",
|
||||||
|
"action": "site_created",
|
||||||
|
"detail": "Standort 'sdwqdwddd' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:26:50",
|
||||||
|
"action": "site_deleted",
|
||||||
|
"detail": "Standort 'test1' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:26:43",
|
||||||
|
"action": "site_deleted",
|
||||||
|
"detail": "Standort 'test2' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:26:40",
|
||||||
|
"action": "screen_deleted",
|
||||||
|
"detail": "Screen 'dasdsfs' an Standort 'test2' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:26:36",
|
||||||
|
"action": "screen_deleted",
|
||||||
|
"detail": "Screen 'asdwedwed' an Standort 'test2' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:22:12",
|
||||||
|
"action": "site_deleted",
|
||||||
|
"detail": "Standort 'dwedwed' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:22:05",
|
||||||
|
"action": "site_created",
|
||||||
|
"detail": "Standort 'dwedwed' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:21:38",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'asdwedwed' an Standort 'test2' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:21:34",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'dasdsfs' an Standort 'test2' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:21:31",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'asdasd' an Standort 'test2' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:19:27",
|
||||||
|
"action": "screen_deleted",
|
||||||
|
"detail": "Screen 'test2' an Standort 'test2' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:18:49",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'test2' an Standort 'test2' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:18:45",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'test1' an Standort 'test2' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:18:40",
|
||||||
|
"action": "site_created",
|
||||||
|
"detail": "Standort 'test2' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:16:32",
|
||||||
|
"action": "password_changed",
|
||||||
|
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' ge\u00e4ndert",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:14:03",
|
||||||
|
"action": "superuser_created",
|
||||||
|
"detail": "'test1@test.de' zum Superuser ernannt",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:14:03",
|
||||||
|
"action": "user_updated",
|
||||||
|
"detail": "User 'test1@test.de' bearbeitet (Rolle: user \u2192 superuser)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 16:13:29",
|
||||||
|
"action": "user_created",
|
||||||
|
"detail": "User 'test1@test.de' angelegt (Rolle: user)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 15:51:36",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'test2' an Standort 'test1' angelegt",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 15:51:31",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'test1' an Standort 'test1' angelegt",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-20 15:51:20",
|
||||||
|
"action": "site_created",
|
||||||
|
"detail": "Standort 'test1' angelegt",
|
||||||
|
"user": "admin"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -4,3 +4,5 @@ werkzeug
|
|||||||
openai
|
openai
|
||||||
requests
|
requests
|
||||||
urllib3
|
urllib3
|
||||||
|
pyotp
|
||||||
|
qrcode[pil]
|
||||||
|
|||||||
2836
session-ses_1e96.md
2836
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 |
@@ -17,6 +17,7 @@
|
|||||||
<!-- Aktionen: Standort-Dropdown, +Button, Theme, Logout -->
|
<!-- Aktionen: Standort-Dropdown, +Button, Theme, Logout -->
|
||||||
<div class="navbar-nav flex-row order-md-last top-actions">
|
<div class="navbar-nav flex-row order-md-last top-actions">
|
||||||
<!-- Standort-Auswahl -->
|
<!-- Standort-Auswahl -->
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
<div class="dropdown me-2">
|
<div class="dropdown me-2">
|
||||||
<a class="btn btn-outline-secondary" href="#" data-bs-toggle="dropdown">
|
<a class="btn btn-outline-secondary" href="#" data-bs-toggle="dropdown">
|
||||||
<i class="ti ti-building me-1"></i>{{ current_site | capitalize }} <i class="ti ti-chevron-down"></i>
|
<i class="ti ti-building me-1"></i>{{ current_site | capitalize }} <i class="ti ti-chevron-down"></i>
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
<a class="btn btn-outline-secondary me-2" href="javascript:void(0)"
|
<a class="btn btn-outline-secondary me-2" href="javascript:void(0)"
|
||||||
onclick="var n=prompt('Name des neuen Standorts:'); if(n&&n.trim()) location.href='/add-site?name='+encodeURIComponent(n.trim().toLowerCase());"
|
onclick="var n=prompt('Name des neuen Standorts:'); if(n&&n.trim()) location.href='/add-site?name='+encodeURIComponent(n.trim().toLowerCase());"
|
||||||
title="Neuen Standort anlegen"
|
title="Neuen Standort anlegen"
|
||||||
style="display:inline-flex;align-items:center;justify-content:center;min-width:2.5rem;min-height:2.5rem;"><i class="ti ti-plus"></i></a>
|
style="display:inline-flex;align-items:center;justify-content:center;min-width:2.5rem;min-height:2.5rem;padding:0;"><i class="ti ti-plus"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Dark-Mode-Toggle (persistiert in localStorage("signage-theme")) -->
|
<!-- Dark-Mode-Toggle (persistiert in localStorage("signage-theme")) -->
|
||||||
<div>
|
<div>
|
||||||
@@ -42,10 +44,48 @@
|
|||||||
</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>
|
||||||
|
<a class="dropdown-item" href="/mfa/setup"><i class="ti ti-shield me-1"></i>MFA einrichten{% if current_user.mfa_enabled %} <span class="badge bg-green text-green-fg ms-1" style="font-size:0.6rem;">Aktiv</span>{% endif %}</a>
|
||||||
|
<a class="dropdown-item" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</a>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a class="dropdown-item" href="/admin/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</a>
|
||||||
|
<a class="dropdown-item" href="/admin/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>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
const themeToggle = document.getElementById("theme-toggle");
|
||||||
|
const root = document.documentElement;
|
||||||
|
const syncThemeToggle = () => {
|
||||||
|
const theme = root.getAttribute("data-bs-theme") || "light";
|
||||||
|
if (!themeToggle) return;
|
||||||
|
themeToggle.innerHTML = theme === "dark"
|
||||||
|
? '<i class="ti ti-sun"></i>'
|
||||||
|
: '<i class="ti ti-moon"></i>';
|
||||||
|
};
|
||||||
|
if (themeToggle) {
|
||||||
|
syncThemeToggle();
|
||||||
|
themeToggle.addEventListener("click", () => {
|
||||||
|
const nextTheme = (root.getAttribute("data-bs-theme") || "light") === "dark" ? "light" : "dark";
|
||||||
|
root.setAttribute("data-bs-theme", nextTheme);
|
||||||
|
window.localStorage.setItem("signage-theme", nextTheme);
|
||||||
|
syncThemeToggle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -166,6 +166,23 @@
|
|||||||
box-shadow: 0 0 0 0.2rem rgba(218, 0, 45, 0.25);
|
box-shadow: 0 0 0 0.2rem rgba(218, 0, 45, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover,
|
||||||
|
.dropdown-item:focus {
|
||||||
|
background-color: #e9ecef !important;
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
.dropdown-item.active,
|
||||||
|
.dropdown-item:active {
|
||||||
|
background-color: #e9ecef !important;
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="dark"] .dropdown-item:hover,
|
||||||
|
[data-bs-theme="dark"] .dropdown-item:focus,
|
||||||
|
[data-bs-theme="dark"] .dropdown-item.active,
|
||||||
|
[data-bs-theme="dark"] .dropdown-item:active {
|
||||||
|
background-color: #3a3f45 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item.active,
|
.dropdown-item.active,
|
||||||
.dropdown-item:active {
|
.dropdown-item:active {
|
||||||
background-color: var(--ccm-primary);
|
background-color: var(--ccm-primary);
|
||||||
|
|||||||
@@ -43,11 +43,11 @@
|
|||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Playlist-Zeile als Grid (Checkbox | Name | Typ | Größe | Löschen | Drag) ─── */
|
/* ─── Playlist-Zeile als Grid (Checkbox | Name | Typ | Größe | Löschen | Edit | Drag) ─── */
|
||||||
.playlist-row {
|
.playlist-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
28px 1fr 90px 80px 90px 32px;
|
28px 1fr 90px 80px 50px 40px 32px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -73,24 +73,6 @@
|
|||||||
color: var(--ccm-primary);
|
color: var(--ccm-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Dropdown-Hover (grauer Hintergrund) ─── */
|
|
||||||
.dropdown-item:hover,
|
|
||||||
.dropdown-item:focus {
|
|
||||||
background-color: #e9ecef !important;
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
|
||||||
.dropdown-item.active,
|
|
||||||
.dropdown-item:active {
|
|
||||||
background-color: #e9ecef !important;
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
|
||||||
[data-bs-theme="dark"] .dropdown-item:hover,
|
|
||||||
[data-bs-theme="dark"] .dropdown-item:focus,
|
|
||||||
[data-bs-theme="dark"] .dropdown-item.active,
|
|
||||||
[data-bs-theme="dark"] .dropdown-item:active {
|
|
||||||
background-color: #3a3f45 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Drag-Handle für SortableJS ─── */
|
/* ─── Drag-Handle für SortableJS ─── */
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
@@ -169,13 +151,15 @@
|
|||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<div class="container-xl mt-4">
|
<div class="container-xl mt-4">
|
||||||
|
|
||||||
<!-- ─── Standort löschen (Button oben rechts) ─── -->
|
<!-- ─── Standort löschen (Button oben rechts, nur für Admins) ─── -->
|
||||||
|
{% if current_user.is_admin %}
|
||||||
<div class="d-flex justify-content-end mb-3">
|
<div class="d-flex justify-content-end mb-3">
|
||||||
<form action="/admin/{{ current_site }}/delete-site" method="post"
|
<form action="/admin/{{ current_site }}/delete-site" method="post"
|
||||||
onsubmit="return confirm('Standort »{{ current_site }}« wirklich löschen? Alle Screens und Medien werden gelöscht.')">
|
onsubmit="return confirm('Standort »{{ current_site }}« wirklich löschen? Alle Screens und Medien werden gelöscht.')">
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="ti ti-building-off me-1"></i>Standort löschen</button>
|
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="ti ti-building-off me-1"></i>Standort löschen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- ========================= -->
|
<!-- ========================= -->
|
||||||
<!-- SCREEN-CARDS -->
|
<!-- SCREEN-CARDS -->
|
||||||
@@ -191,20 +175,21 @@
|
|||||||
<div class="page-subtitle"><a href="{{ server_url }}/player/{{ current_site }}/{{ screen }}">{{ server_url }}/player/{{ current_site }}/{{ screen }}</a></div>
|
<div class="page-subtitle"><a href="{{ server_url }}/player/{{ current_site }}/{{ screen }}">{{ server_url }}/player/{{ current_site }}/{{ screen }}</a></div>
|
||||||
</span>
|
</span>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
{% if screen_status[screen] == "active" %}
|
{% if screen_status[screen] == "online" %}
|
||||||
<span class="badge bg-green text-green-fg">Aktiv</span>
|
<button type="button" class="btn btn-sm btn-success" onclick="return false;"
|
||||||
|
title="Browser: {{ client_info[screen].get('ua', '?')[:60] }} Auflösung: {{ client_info[screen].get('resolution', '?') }} IP: {{ client_info[screen].get('ip', '?') }} Zuletzt: {{ client_info[screen].get('last_seen', '?') }}">Online</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-warning text-dark">Leer</span>
|
<button type="button" class="btn btn-sm btn-danger" onclick="return false;">Offline</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form action="/admin/{{ current_site }}/delete-screen/{{ screen }}"
|
<form action="/admin/{{ current_site }}/delete-screen/{{ screen }}"
|
||||||
method="post" class="d-inline"
|
method="post" class="d-inline m-0 p-0"
|
||||||
onsubmit="return confirm('Screen »{{ screen }}« wirklich löschen? Alle Medien werden gelöscht.')">
|
onsubmit="return confirm('Screen »{{ screen }}« wirklich löschen? Alle Medien werden gelöscht.')">
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm">Löschen</button>
|
<button type="submit" class="btn btn-outline-danger btn-sm">Löschen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card-Body: Tabs (Playlist | Einstellungen | Aktionen | Digital Voice Agent | Medien) -->
|
<!-- Card-Body: Tabs (Playlist | Einstellungen | Aktionen | Digital Voice Agent | Medien | Info) -->
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
<ul class="nav nav-tabs" data-bs-toggle="tabs" role="tablist">
|
<ul class="nav nav-tabs" data-bs-toggle="tabs" role="tablist">
|
||||||
@@ -233,6 +218,11 @@
|
|||||||
<i class="ti ti-upload me-1"></i>Medien
|
<i class="ti ti-upload me-1"></i>Medien
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<a href="#info-{{ screen }}" class="nav-link" data-bs-toggle="tab" role="tab">
|
||||||
|
<i class="ti ti-info-circle me-1"></i>Info
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content mt-3">
|
<div class="tab-content mt-3">
|
||||||
@@ -260,7 +250,7 @@
|
|||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
|
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
|
||||||
{% endif %}>
|
{% endif %}>
|
||||||
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}
|
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Typ-Badge -->
|
<!-- Typ-Badge -->
|
||||||
@@ -291,6 +281,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Bearbeiten (nur URLs) -->
|
||||||
|
{% if file.type == "url" %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-secondary btn-sm edit-url-btn"
|
||||||
|
title="URL bearbeiten"
|
||||||
|
data-name="{{ file.name | e }}"
|
||||||
|
data-zoom="{{ file.zoom | default(1.0) }}"
|
||||||
|
data-site="{{ current_site }}"
|
||||||
|
data-screen="{{ screen }}"
|
||||||
|
data-oldname="{{ file.name | e }}"
|
||||||
|
data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Drag-Handle (SortableJS) -->
|
<!-- Drag-Handle (SortableJS) -->
|
||||||
<span class="drag-handle" title="Reihenfolge ändern">⇅</span>
|
<span class="drag-handle" title="Reihenfolge ändern">⇅</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -489,6 +496,35 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Tab: Info (Client-Informationen) ═══ -->
|
||||||
|
<div class="tab-pane" id="info-{{ screen }}" role="tabpanel">
|
||||||
|
<h3>Client-Informationen</h3>
|
||||||
|
{% if client_info[screen] %}
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th style="width:140px">IP-Adresse</th>
|
||||||
|
<td><code>{{ client_info[screen].get('ip', '') }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Browser</th>
|
||||||
|
<td style="word-break:break-all"><small>{{ client_info[screen].get('ua', '') }}</small></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Auflösung</th>
|
||||||
|
<td>{{ client_info[screen].get('resolution', '') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Zuletzt gesehen</th>
|
||||||
|
<td>{{ client_info[screen].get('last_seen', '') }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">Noch keine Client-Daten vorhanden. Der Player muss mindestens einmal die Playlist aufgerufen haben.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div> <!-- /tab-content -->
|
</div> <!-- /tab-content -->
|
||||||
|
|
||||||
</div> <!-- /card-body -->
|
</div> <!-- /card-body -->
|
||||||
@@ -504,9 +540,26 @@
|
|||||||
</div> <!-- /page -->
|
</div> <!-- /page -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
/* ─── Playlist-Item bearbeiten (URL + Zoom) ─── */
|
||||||
|
var editState = { site: "", screen: "", oldName: "" };
|
||||||
|
|
||||||
|
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
var btn = e.target.closest(".edit-url-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
editState.site = btn.dataset.site;
|
||||||
|
editState.screen = btn.dataset.screen;
|
||||||
|
editState.oldName = btn.dataset.oldname;
|
||||||
|
document.getElementById("edit-url-input").value = btn.dataset.oldname;
|
||||||
|
document.getElementById("edit-zoom-input").value = btn.dataset.zoom;
|
||||||
|
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true";
|
||||||
|
document.getElementById("edit-url-error").style.display = "none";
|
||||||
|
new bootstrap.Modal(document.getElementById("edit-url-modal")).show();
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|
||||||
/* ─── SortableJS Drag & Drop initialisieren ─── */
|
|
||||||
function initSortable(name) {
|
function initSortable(name) {
|
||||||
const el = document.getElementById("playlist-" + name);
|
const el = document.getElementById("playlist-" + name);
|
||||||
if (!el || typeof Sortable === "undefined") return;
|
if (!el || typeof Sortable === "undefined") return;
|
||||||
@@ -558,30 +611,77 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ─── Dark Mode Toggle (persistiert in localStorage) ─── */
|
/* ─── Edit-URL-Modal: Speichern-Button ─── */
|
||||||
const themeToggle = document.getElementById("theme-toggle");
|
document.getElementById("edit-url-save").addEventListener("click", function () {
|
||||||
const root = document.documentElement;
|
const newUrl = document.getElementById("edit-url-input").value.trim();
|
||||||
|
const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0;
|
||||||
|
const errorEl = document.getElementById("edit-url-error");
|
||||||
|
|
||||||
const syncThemeToggle = () => {
|
if (!newUrl.startsWith("http://") && !newUrl.startsWith("https://")) {
|
||||||
const theme = root.getAttribute("data-bs-theme") || "light";
|
errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)";
|
||||||
if (!themeToggle) return;
|
errorEl.style.display = "block";
|
||||||
themeToggle.innerHTML = theme === "dark"
|
return;
|
||||||
? '<i class="ti ti-sun"></i>'
|
}
|
||||||
: '<i class="ti ti-moon"></i>';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (themeToggle) {
|
errorEl.style.display = "none";
|
||||||
syncThemeToggle();
|
|
||||||
themeToggle.addEventListener("click", () => {
|
fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, {
|
||||||
const nextTheme = (root.getAttribute("data-bs-theme") || "light") === "dark" ? "light" : "dark";
|
method: "POST",
|
||||||
root.setAttribute("data-bs-theme", nextTheme);
|
headers: { "Content-Type": "application/json" },
|
||||||
window.localStorage.setItem("signage-theme", nextTheme);
|
body: JSON.stringify({
|
||||||
syncThemeToggle();
|
old_name: editState.oldName,
|
||||||
|
new_url: newUrl,
|
||||||
|
zoom: zoom,
|
||||||
|
passthrough: document.getElementById("edit-passthrough-input").checked
|
||||||
|
})
|
||||||
|
}).then(function (r) {
|
||||||
|
if (r.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
return r.json().then(function (d) {
|
||||||
|
errorEl.textContent = d.error || "Fehler beim Speichern";
|
||||||
|
errorEl.style.display = "block";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
errorEl.textContent = "Netzwerkfehler";
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Modal: URL bearbeiten -->
|
||||||
|
<div class="modal fade" id="edit-url-modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">URL bearbeiten</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">URL</label>
|
||||||
|
<input type="url" class="form-control" id="edit-url-input">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
|
||||||
|
<input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="edit-passthrough-input">
|
||||||
|
<label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="edit-url-save">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
206
templates/admin_dashboard.html
Normal file
206
templates/admin_dashboard.html
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CANCOM Simple Signage – Admin Dashboard</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>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||||||
|
{% include "_styles.html" %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
{% set brand_subtitle = "Admin Dashboard" %}
|
||||||
|
{% 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/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</a>
|
||||||
|
<a class="btn btn-white" href="/admin/users"><i class="ti ti-users me-1"></i>Userverwaltung</a>
|
||||||
|
<a class="btn btn-white" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="container-xl mt-4">
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6 col-lg">
|
||||||
|
<div class="card card-sm">
|
||||||
|
<div class="card-body p-3 pb-0">
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="text-secondary mt-1"><i class="ti ti-building-community" style="font-size:2.2rem;"></i></div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fs-1 fw-bold d-flex align-items-center gap-2">{{ site_count }}{% if site_trend > 0 %}<span class="text-green d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-up"></i>+{{ site_trend }}</span>{% elif site_trend < 0 %}<span class="text-red d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-down"></i>{{ site_trend }}</span>{% endif %}</div>
|
||||||
|
<div class="text-secondary small">Standorte</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="spark-sites" class="mx-n3" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg">
|
||||||
|
<div class="card card-sm">
|
||||||
|
<div class="card-body p-3 pb-0">
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="text-secondary mt-1"><i class="ti ti-devices" style="font-size:2.2rem;"></i></div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fs-1 fw-bold d-flex align-items-center gap-2">{{ screen_count }}{% if screen_trend > 0 %}<span class="text-green d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-up"></i>+{{ screen_trend }}</span>{% elif screen_trend < 0 %}<span class="text-red d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-down"></i>{{ screen_trend }}</span>{% endif %}</div>
|
||||||
|
<div class="text-secondary small">Screens</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="spark-screens" class="mx-n3" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg">
|
||||||
|
<div class="card card-sm">
|
||||||
|
<div class="card-body p-3 pb-0">
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="text-secondary mt-1"><i class="ti ti-users" style="font-size:2.2rem;"></i></div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fs-1 fw-bold d-flex align-items-center gap-2">{{ user_count }}{% if user_trend > 0 %}<span class="text-green d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-up"></i>+{{ user_trend }}</span>{% elif user_trend < 0 %}<span class="text-red d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-down"></i>{{ user_trend }}</span>{% endif %}</div>
|
||||||
|
<div class="text-secondary small">User</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="spark-users" class="mx-n3" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg">
|
||||||
|
<div class="card card-sm">
|
||||||
|
<div class="card-body p-3 pb-0">
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="text-secondary mt-1"><i class="ti ti-shield" style="font-size:2.2rem;"></i></div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fs-1 fw-bold d-flex align-items-center gap-2">{{ admin_count }}{% if admin_trend > 0 %}<span class="text-green d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-up"></i>+{{ admin_trend }}</span>{% elif admin_trend < 0 %}<span class="text-red d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-down"></i>{{ admin_trend }}</span>{% endif %}</div>
|
||||||
|
<div class="text-secondary small">Admins</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="spark-admins" class="mx-n3" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg">
|
||||||
|
<div class="card card-sm">
|
||||||
|
<div class="card-body p-3 pb-0">
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="text-secondary mt-1"><i class="ti ti-star" style="font-size:2.2rem;"></i></div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fs-1 fw-bold d-flex align-items-center gap-2">{{ superuser_count }}{% if superuser_trend > 0 %}<span class="text-green d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-up"></i>+{{ superuser_trend }}</span>{% elif superuser_trend < 0 %}<span class="text-red d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-down"></i>{{ superuser_trend }}</span>{% endif %}</div>
|
||||||
|
<div class="text-secondary small">Superuser</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="spark-superusers" class="mx-n3" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title"><i class="ti ti-history me-1"></i>Verlauf</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for entry in history %}
|
||||||
|
<div class="list-group-item d-flex align-items-center gap-3 py-2">
|
||||||
|
<span class="d-flex align-items-center">
|
||||||
|
{% if entry.action == "site_created" %}
|
||||||
|
<span class="avatar avatar-xs bg-green" data-bs-toggle="tooltip" title="Standort angelegt"><i class="ti ti-building-community text-white"></i></span>
|
||||||
|
{% elif entry.action == "site_deleted" %}
|
||||||
|
<span class="avatar avatar-xs bg-red" data-bs-toggle="tooltip" title="Standort gelöscht"><i class="ti ti-building-community text-white"></i></span>
|
||||||
|
{% elif entry.action == "screen_created" %}
|
||||||
|
<span class="avatar avatar-xs bg-green" data-bs-toggle="tooltip" title="Screen angelegt"><i class="ti ti-devices text-white"></i></span>
|
||||||
|
{% elif entry.action == "screen_deleted" %}
|
||||||
|
<span class="avatar avatar-xs bg-red" data-bs-toggle="tooltip" title="Screen gelöscht"><i class="ti ti-devices text-white"></i></span>
|
||||||
|
{% elif entry.action == "user_created" %}
|
||||||
|
<span class="avatar avatar-xs bg-green" data-bs-toggle="tooltip" title="User angelegt"><i class="ti ti-user-plus text-white"></i></span>
|
||||||
|
{% elif entry.action == "user_deleted" %}
|
||||||
|
<span class="avatar avatar-xs bg-red" data-bs-toggle="tooltip" title="User gelöscht"><i class="ti ti-user-minus text-white"></i></span>
|
||||||
|
{% elif entry.action == "user_updated" %}
|
||||||
|
<span class="avatar avatar-xs bg-blue" data-bs-toggle="tooltip" title="User bearbeitet"><i class="ti ti-user-edit text-white"></i></span>
|
||||||
|
{% elif entry.action == "user_password_reset" %}
|
||||||
|
<span class="avatar avatar-xs bg-warning" data-bs-toggle="tooltip" title="Passwort zurückgesetzt"><i class="ti ti-key text-white"></i></span>
|
||||||
|
{% elif entry.action == "password_changed" %}
|
||||||
|
<span class="avatar avatar-xs bg-purple" data-bs-toggle="tooltip" title="Passwort geändert"><i class="ti ti-key text-white"></i></span>
|
||||||
|
{% elif entry.action == "background_uploaded" %}
|
||||||
|
<span class="avatar avatar-xs bg-blue" data-bs-toggle="tooltip" title="Hintergrund hochgeladen"><i class="ti ti-photo text-white"></i></span>
|
||||||
|
{% elif entry.action == "background_deleted" %}
|
||||||
|
<span class="avatar avatar-xs bg-yellow" data-bs-toggle="tooltip" title="Hintergrund zurückgesetzt"><i class="ti ti-photo-off text-white"></i></span>
|
||||||
|
{% elif entry.action == "customer_added" %}
|
||||||
|
<span class="avatar avatar-xs bg-teal" data-bs-toggle="tooltip" title="Kunde hinzugefügt"><i class="ti ti-users text-white"></i></span>
|
||||||
|
{% elif entry.action == "user_login" %}
|
||||||
|
<span class="avatar avatar-xs bg-lime" data-bs-toggle="tooltip" title="Anmeldung"><i class="ti ti-login text-white"></i></span>
|
||||||
|
{% elif entry.action == "user_logout" %}
|
||||||
|
<span class="avatar avatar-xs bg-secondary" data-bs-toggle="tooltip" title="Abmeldung"><i class="ti ti-logout text-white"></i></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="avatar avatar-xs bg-secondary" data-bs-toggle="tooltip" title="{{ entry.action }}"><i class="ti ti-info-circle text-white"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="text-secondary small text-nowrap" style="min-width:8.5rem;">{{ entry.timestamp }}</span>
|
||||||
|
<span class="flex-grow-1">{{ entry.detail }}</span>
|
||||||
|
<span class="text-secondary small text-nowrap">{{ entry.user }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group-item text-center text-muted py-4">
|
||||||
|
<i class="ti ti-inbox ti-lg d-block mb-1"></i>
|
||||||
|
Noch keine Aktivitäten aufgezeichnet
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "_footer.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
if (!window.ApexCharts) return;
|
||||||
|
|
||||||
|
function spark(id, data, color) {
|
||||||
|
new ApexCharts(document.getElementById(id), {
|
||||||
|
chart: { type: "area", height: 32, width: "100%", sparkline: { enabled: true }, animations: { enabled: false } },
|
||||||
|
series: [{ data: data }],
|
||||||
|
stroke: { width: 1.5, curve: "straight" },
|
||||||
|
fill: { opacity: 0.25 },
|
||||||
|
colors: [color],
|
||||||
|
tooltip: { enabled: false }
|
||||||
|
}).render();
|
||||||
|
}
|
||||||
|
|
||||||
|
spark("spark-sites", {{ site_spark | tojson }}, "var(--ccm-primary, #da002d)");
|
||||||
|
spark("spark-screens", {{ screen_spark | tojson }}, "var(--ccm-primary, #da002d)");
|
||||||
|
spark("spark-users", {{ user_spark | tojson }}, "var(--ccm-primary, #da002d)");
|
||||||
|
spark("spark-admins", {{ admin_spark | tojson }}, "var(--ccm-primary, #da002d)");
|
||||||
|
spark("spark-superusers", {{ superuser_spark | tojson }}, "var(--ccm-primary, #da002d)");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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>
|
||||||
@@ -129,29 +129,42 @@
|
|||||||
<input type="text" class="form-control" id="customer_name_1"
|
<input type="text" class="form-control" id="customer_name_1"
|
||||||
name="customer_name_1" placeholder="z. B. Beispiel GmbH"
|
name="customer_name_1" placeholder="z. B. Beispiel GmbH"
|
||||||
value="{{ customer_names[0] if customer_names else '' }}">
|
value="{{ customer_names[0] if customer_names else '' }}">
|
||||||
|
<label class="form-label mt-2" for="contact_1">Ansprechpartner (optional)</label>
|
||||||
|
<textarea class="form-control" id="contact_1" name="contact_1" rows="4"
|
||||||
|
placeholder="**Max Mustermann** +49 123 4567890 *E-Mail*: max@beispiel.de">{{ contacts[0] if contacts else '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label" for="customer_name_2">Kunde 2 (optional)</label>
|
<label class="form-label" for="customer_name_2">Kunde 2 (optional)</label>
|
||||||
<input type="text" class="form-control" id="customer_name_2"
|
<input type="text" class="form-control" id="customer_name_2"
|
||||||
name="customer_name_2" placeholder="z. B. Muster AG"
|
name="customer_name_2" placeholder="z. B. Muster AG"
|
||||||
value="{{ customer_names[1] if customer_names else '' }}">
|
value="{{ customer_names[1] if customer_names else '' }}">
|
||||||
|
<label class="form-label mt-2" for="contact_2">Ansprechpartner (optional)</label>
|
||||||
|
<textarea class="form-control" id="contact_2" name="contact_2" rows="4"
|
||||||
|
placeholder="**Maria Schmidt** +49 456 7890123 *E-Mail*: maria@muster.de">{{ contacts[1] if contacts else '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label" for="customer_name_3">Kunde 3 (optional)</label>
|
<label class="form-label" for="customer_name_3">Kunde 3 (optional)</label>
|
||||||
<input type="text" class="form-control" id="customer_name_3"
|
<input type="text" class="form-control" id="customer_name_3"
|
||||||
name="customer_name_3" placeholder="z. B. Firma GmbH"
|
name="customer_name_3" placeholder="z. B. Firma GmbH"
|
||||||
value="{{ customer_names[2] if customer_names else '' }}">
|
value="{{ customer_names[2] if customer_names else '' }}">
|
||||||
|
<label class="form-label mt-2" for="contact_3">Ansprechpartner (optional)</label>
|
||||||
|
<textarea class="form-control" id="contact_3" name="contact_3" rows="4"
|
||||||
|
placeholder="**Sabine Weber** +49 789 0123456 *E-Mail*: sabine@firma.de">{{ contacts[2] if contacts else '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 text-muted">
|
<div class="mb-3 text-muted">
|
||||||
<p class="mb-1">Das System sucht automatisch nach den Kundenlogos</p>
|
<p class="mb-1">Das System sucht automatisch nach den Kundenlogos</p>
|
||||||
<p class="mb-0">und erstellt eine angepasste Willkommensseite für die Lobby-Playlist.</p>
|
<p class="mb-0">und erstellt eine angepasste Willkommensseite für die Lobby-Playlist.</p>
|
||||||
|
<p class="mb-0 small"><i class="ti ti-markdown me-1"></i>Ansprechpartner unterstützen <strong>fett</strong>, <em>kursiv</em>, <code>Code</code>, Aufzählungen (<code>- </code>) sowie Zeilenumbrüche.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="ti ti-file-plus me-1"></i>Logos & Willkommensseite generieren
|
<i class="ti ti-file-plus me-1"></i>Logos & Willkommensseite generieren
|
||||||
</button>
|
</button>
|
||||||
|
<a href="?reset=1&site={{ site }}" class="btn btn-outline-secondary ms-2">
|
||||||
|
<i class="ti ti-trash me-1"></i>Zurücksetzen
|
||||||
|
</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,29 +220,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.addEventListener("load", () => {
|
|
||||||
/* ─── Dark Mode Toggle ─── */
|
|
||||||
const themeToggle = document.getElementById("theme-toggle");
|
|
||||||
const root = document.documentElement;
|
|
||||||
|
|
||||||
const syncThemeToggle = () => {
|
|
||||||
const theme = root.getAttribute("data-bs-theme") || "light";
|
|
||||||
if (!themeToggle) return;
|
|
||||||
themeToggle.innerHTML = theme === "dark"
|
|
||||||
? '<i class="ti ti-sun"></i>'
|
|
||||||
: '<i class="ti ti-moon"></i>';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (themeToggle) {
|
|
||||||
syncThemeToggle();
|
|
||||||
themeToggle.addEventListener("click", () => {
|
|
||||||
const nextTheme = (root.getAttribute("data-bs-theme") || "light") === "dark" ? "light" : "dark";
|
|
||||||
root.setAttribute("data-bs-theme", nextTheme);
|
|
||||||
window.localStorage.setItem("signage-theme", nextTheme);
|
|
||||||
syncThemeToggle();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
289
templates/help.html
Normal file
289
templates/help.html
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CANCOM Simple Signage – Hilfe</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 = "Hilfe" %}
|
||||||
|
{% 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/help"><i class="ti ti-help me-1"></i>Hilfe</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="container-xl mt-4">
|
||||||
|
<div class="row g-4">
|
||||||
|
<!-- Sidebar Navigation -->
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card" style="position:sticky;top:1rem;">
|
||||||
|
<div class="card-header"><h4 class="card-title" style="font-size:0.9rem;"><i class="ti ti-menu-2 me-1"></i>Themen</h4></div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<nav class="nav flex-column">
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-standort"><i class="ti ti-building-community me-1"></i>Standort</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-screen"><i class="ti ti-devices me-1"></i>Screen</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-playlist"><i class="ti ti-list me-1"></i>Playlist</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-priority"><i class="ti ti-player-play me-1"></i>Priority</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-actions"><i class="ti ti-external-link me-1"></i>Action Button</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-voice"><i class="ti ti-robot me-1"></i>Voice Agent</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-einstellungen"><i class="ti ti-settings me-1"></i>Einstellungen</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-info"><i class="ti ti-info-circle me-1"></i>Info-Tab</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-willkommen"><i class="ti ti-users me-1"></i>Willkommen</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-userverwaltung"><i class="ti ti-shield me-1"></i>User</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-dashboard"><i class="ti ti-dashboard me-1"></i>Dashboard</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-darkmode"><i class="ti ti-moon me-1"></i>Dark Mode</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-passwort"><i class="ti ti-key me-1"></i>Passwort</a>
|
||||||
|
<a class="nav-link py-1 px-2 small" href="#help-mfa"><i class="ti ti-shield-lock me-1"></i>MFA</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="col-md-9">
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-standort"><i class="ti ti-building-community me-1"></i>Standort anlegen</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Ein Standort (Site) gruppiert mehrere Screens, z. B. <code>stuttgart</code> oder <code>karlsruhe</code>.</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>+ Button</strong> im Header (neben dem Standort-Dropdown) klicken</li>
|
||||||
|
<li>Namen des neuen Standorts eingeben (z. B. <code>berlin</code>)</li>
|
||||||
|
<li>Der Standort wird sofort angelegt und im Dropdown sichtbar</li>
|
||||||
|
</ol>
|
||||||
|
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Nur Admins können Standorte anlegen oder löschen (Löschen über den Button im Standort-Dashboard).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-screen"><i class="ti ti-devices me-1"></i>Screen hinzufügen</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Ein Screen ist ein einzelner Player (z. B. <code>lobby</code>, <code>casino</code>), der eine Playlist abspielt.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Im Admin-Bereich des Standorts (<code>/admin/<standort></code>) das <strong>+</strong>-Symbol oben rechts klicken</li>
|
||||||
|
<li>Namen des Screens eingeben (z. B. <code>lobby</code>)</li>
|
||||||
|
<li>Der Screen erscheint als neue Card mit Tabs: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien</li>
|
||||||
|
</ol>
|
||||||
|
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Der Screen-Name wird automatisch in Kleinbuchstaben umgewandelt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-playlist"><i class="ti ti-list me-1"></i>Playlist verwalten</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Jeder Screen hat eine eigene Playlist mit Medien und URLs.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Medien hochladen:</strong> Im Tab "Medien" eine Datei auswählen und hochladen. Unterstützt werden Bilder (JPG, PNG) und Videos (MP4).</li>
|
||||||
|
<li><strong>URL hinzufügen:</strong> Im Tab "Playlist" eine URL eingeben und optional den Zoom-Faktor anpassen.</li>
|
||||||
|
<li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li>
|
||||||
|
<li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden.</li>
|
||||||
|
<li><strong>Löschen:</strong> Über den roten Papierkorb-Button.</li>
|
||||||
|
<li><strong>Query-Parameter-Passthrough:</strong> Per URL-Edit-Button (Stift-Icon) im Playlist-Eintrag kann pro URL gesteuert werden, ob Query-Parameter von der Player-URL an die Playlist-URL übergeben werden. Standardmäßig <strong>aus</strong> – Checkbox "Query-Parameter an diese URL übergeben" aktivieren wenn gewünscht. Ein Link-Icon in der Playlist-Zeile zeigt den Status: <i class="ti ti-link"></i> = an, <i class="ti ti-link-off"></i> = aus.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Änderungen an der Playlist werden automatisch gespeichert. Der Player lädt die Seite automatisch neu (via Hash-Prüfung).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Die Priority-Playlist ist <strong>global</strong> und wird auf <em>allen</em> Playern eines Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Aufruf über <code>/admin/<standort>/priority</code></li>
|
||||||
|
<li>Globale Medien und URLs verwalten, ebenfalls per Drag & Drop</li>
|
||||||
|
<li>Priority muss in der Konfiguration aktiviert sein (<code>config.json → priority.enabled</code>)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-actions"><i class="ti ti-external-link me-1"></i>Action Button (Custom-URL)</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Der Action Button ist ein konfigurierbarer Button im Player, der eine frei wählbare URL öffnet.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Konfiguration:</strong> Im Tab <strong>Aktionen</strong> der Screen-Card</li>
|
||||||
|
<li><strong>URL:</strong> Beliebige Webadresse (z. B. Intranet-Seite, Dashboard)</li>
|
||||||
|
<li><strong>Label:</strong> Text, der auf dem Button angezeigt wird (z. B. "Infos")</li>
|
||||||
|
<li><strong>Position:</strong> 9 mögliche Positionen (oben/mitte/unten × links/mitte/rechts)</li>
|
||||||
|
<li><strong>Ziel:</strong>
|
||||||
|
<ul>
|
||||||
|
<li><code>overlay</code> – URL wird in einem iframe-Overlay geöffnet, Zurück-Button schließt es</li>
|
||||||
|
<li><code>redirect</code> – direkte Weiterleitung zur URL</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Der Button erscheint nur, wenn "Aktiviert" eingeschaltet ist. Bei Positionen unten weicht er automatisch 16px über dem Newsticker aus.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-voice"><i class="ti ti-robot me-1"></i>Digital Voice Agent</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Der Digital Voice Agent zeigt einen Button im Player, der eine Typewriter-Animation mit wechselnden mehrsprachigen Texten anzeigt.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Globale URL:</strong> Die Ziel-URL wird in <code>config.json</code> (Feld <code>voice_agent_url</code>) gesetzt und ist im Admin sichtbar, aber nicht änderbar</li>
|
||||||
|
<li><strong>Konfiguration pro Screen:</strong> Im Tab <strong>Digital Voice Agent</strong> der Screen-Card</li>
|
||||||
|
<li><strong>Label:</strong> Button-Beschriftung (Default: "Digitaler Assistent")</li>
|
||||||
|
<li><strong>Position:</strong> Wie Action Button – 9 Positionen wählbar</li>
|
||||||
|
<li><strong>Bild:</strong> Optional kann über dem Button ein Bild (dva.png) angezeigt werden (Beta-Feature)</li>
|
||||||
|
<li><strong>Typewriter-Tagline:</strong> 11 Phrasen in 5 Sprachen wechseln alle 2,5 Sekunden mit Buchstaben-für-Buchstaben-Effekt</li>
|
||||||
|
<li><strong>Animierte Balken:</strong> 5 rote Audiobalken neben dem Button signalisieren Aktivität</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-einstellungen"><i class="ti ti-settings me-1"></i>Screen-Einstellungen</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Im Tab <strong>Einstellungen</strong> können pro Screen konfiguriert werden:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Intervall:</strong> Anzeigedauer pro Playlist-Eintrag in Sekunden</li>
|
||||||
|
<li><strong>Bilder / Videos anzeigen:</strong> Jeweils ein-/ausschaltbar</li>
|
||||||
|
<li><strong>Newsticker:</strong> Text (max. 200 Zeichen) und Aktivierung des Laufbands</li>
|
||||||
|
<li><strong>Stay on First:</strong> Wenn aktiviert, bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-info"><i class="ti ti-info-circle me-1"></i>Info-Tab im Screen</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Jeder Screen hat einen <strong>Info-Tab</strong> (Tab 6), der detaillierte Client-Informationen zum aktuell verbundenen Player anzeigt:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>IP-Adresse</strong> des Players</li>
|
||||||
|
<li><strong>Browser</strong> (User-Agent) des Players</li>
|
||||||
|
<li><strong>Bildschirmauflösung</strong> (Breite × Höhe)</li>
|
||||||
|
<li><strong>Zuletzt gesehen</strong> – Zeitstempel des letzten Heartbeats</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Die Daten werden alle 5 Sekunden vom Player gesendet und im Heartbeat-JSON gespeichert. Der Online-Status (grüner/roter Button im Screen-Header) basiert auf dem gleichen Mechanismus.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-willkommen"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Aufruf über <code>/willkommen?site=<standort></code> (oder Menüpunkt)</li>
|
||||||
|
<li>Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Lobby-Playlist gesetzt</li>
|
||||||
|
<li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</li>
|
||||||
|
<li>Pro Kunde können <strong>Ansprechpartner</strong> eingegeben werden (Markdown-Format):
|
||||||
|
<ul>
|
||||||
|
<li><strong>Fett</strong>: <code>**Text**</code></li>
|
||||||
|
<li><em>Kursiv</em>: <code>*Text*</code></li>
|
||||||
|
<li><code>Code</code>: <code>`Code`</code></li>
|
||||||
|
<li>Aufzählungen: <code>- Punkt</code> oder <code>* Punkt</code></li>
|
||||||
|
<li>Zeilenumbrüche werden automatisch übernommen</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Layout:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Landscape: Logos nebeneinander, Texte linksbündig darunter</li>
|
||||||
|
<li>Portrait: Texte <em>links</em> vom Logo, Abstand 40px</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Reset-Button setzt das Formular und die gespeicherten Daten zurück</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-userverwaltung"><i class="ti ti-shield me-1"></i>User-Verwaltung (Admin)</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Admins können über <code>/admin/users</code> die User-Verwaltung aufrufen.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>User anlegen:</strong> E-Mail, Passwort, Rolle (Admin/Superuser/User) und Standort-Zugriff festlegen</li>
|
||||||
|
<li><strong>User bearbeiten:</strong> E-Mail, Rolle, Standorte, Profilfelder (Vorname, Nachname, Abteilung) ändern</li>
|
||||||
|
<li><strong>User löschen:</strong> Der letzte Admin kann nicht gelöscht werden</li>
|
||||||
|
<li><strong>Passwort-Reset:</strong> Setzt ein temporäres Passwort, der User muss es beim nächsten Login ändern</li>
|
||||||
|
<li><strong>Rollen:</strong>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Admin</strong> – Zugriff auf alle Standorte + Userverwaltung</li>
|
||||||
|
<li><strong>Superuser</strong> – Zugriff auf alle Standorte, keine Userverwaltung</li>
|
||||||
|
<li><strong>User</strong> – Zugriff nur auf zugewiesene Standorte</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Das Admin Dashboard (<code>/admin/dashboard</code>) bietet eine Übersicht über das gesamte System:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Statistik-Cards:</strong> Anzahl Standorte, Screens, User, Admins und Superuser</li>
|
||||||
|
<li><strong>Sparkline:</strong> Kleine Liniendiagramme zeigen den Verlauf der letzten Änderungen</li>
|
||||||
|
<li><strong>Trend:</strong> Pfeil nach oben/unten mit Netto-Änderung aus den letzten 30 Aktionen</li>
|
||||||
|
<li><strong>Aktivitätsverlauf:</strong> Alle Erstell-, Lösch-, Änderungsaktionen sowie An-/Abmeldungen werden protokolliert</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-darkmode"><i class="ti ti-moon me-1"></i>Dark Mode</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Der Dark Mode kann über den Mond-Button im Header umgeschaltet werden. Die Einstellung wird im Browser gespeichert (localStorage) und bleibt auch nach Seitenwechsel erhalten.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-passwort"><i class="ti ti-key me-1"></i>Passwort ändern</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Das Passwort kann über das User-Menü (oben rechts) → <strong>Passwort ändern</strong> geändert werden. Nach einem Admin-Reset muss der User beim ersten Login ein neues Passwort vergeben.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title" id="help-mfa"><i class="ti ti-shield-lock me-1"></i>MFA (Multi-Faktor-Authentifizierung)</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>MFA bietet eine optionale zweite Sicherheitsstufe per TOTP (Google Authenticator, Authy).</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Aktivierung:</strong> Über das User-Menü → <strong>MFA einrichten</strong> → QR-Code mit einer Authenticator-App scannen → 6-stelligen Code eingeben → Bestätigen</li>
|
||||||
|
<li><strong>Recovery-Codes:</strong> Nach erfolgreicher Aktivierung werden 8 Recovery-Codes (einmalig) angezeigt. Jeder Code kann nur einmal verwendet werden. Nach Einsatz eines Recovery-Codes muss MFA neu eingerichtet werden.</li>
|
||||||
|
<li><strong>Login mit MFA:</strong> Nach Eingabe von E-Mail + Passwort erscheint ein zweites Formular für den TOTP-Code. Erst nach erfolgreichem Code wird der Zugriff gewährt.</li>
|
||||||
|
<li><strong>Deaktivierung:</strong> Über <code>/mfa/setup</code> → Button "MFA deaktivieren" → aktuelles Passwort eingeben</li>
|
||||||
|
<li><strong>MFA-Status:</strong> In der Userliste (<code>/admin/users</code>) wird der MFA-Status als grünes "Aktiv"-Badge oder "–" angezeigt.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> <!-- /col-md-9 main -->
|
||||||
|
</div> <!-- /row -->
|
||||||
|
|
||||||
|
</div> <!-- /container-xl -->
|
||||||
|
</div> <!-- /page-wrapper -->
|
||||||
|
|
||||||
|
{% include "_footer.html" %}
|
||||||
|
</div> <!-- /page -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,29 +2,32 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>CANCOM Simple Signage Admin</title>
|
<title>CANCOM Simple Signage Admin</title>
|
||||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
<link href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css" rel="stylesheet">
|
<link rel="stylesheet"
|
||||||
|
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||||
<style>
|
<link rel="stylesheet"
|
||||||
body {
|
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||||
background-color: #F4F6F8;
|
{% include "_styles.html" %}
|
||||||
color: #212121;
|
<script>
|
||||||
}
|
(() => {
|
||||||
.btn-primary {
|
const stored = window.localStorage.getItem("signage-theme");
|
||||||
background-color: #DA002D;
|
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||||
border-color: #DA002D;
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
}
|
})();
|
||||||
.btn-primary:hover {
|
</script>
|
||||||
background-color: #B00024;
|
|
||||||
border-color: #B00024;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
<!-- ─── Login-Seite (zentriert) ─── -->
|
{% include "_header.html" %}
|
||||||
<body class="d-flex align-items-center justify-content-center" style="height:100vh;">
|
|
||||||
<div class="card card-md">
|
<div class="page-wrapper d-flex align-items-center justify-content-center">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card card-md">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="text-center mb-4">Signage Admin</h2>
|
<h2 class="text-center mb-4">Signage Admin</h2>
|
||||||
|
|
||||||
@@ -36,8 +39,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>
|
||||||
@@ -46,6 +49,13 @@
|
|||||||
<button class="btn btn-primary w-100">Login</button>
|
<button class="btn btn-primary w-100">Login</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "_footer.html" %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
64
templates/mfa_recovery.html
Normal file
64
templates/mfa_recovery.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>MFA eingerichtet – CANCOM Signage</title>
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const stored = window.localStorage.getItem("signage-theme");
|
||||||
|
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||||
|
<script defer src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
|
||||||
|
|
||||||
|
{% include '_styles.html' %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include '_header.html' %}
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="page-body" style="min-height:calc(100vh - 120px);display:flex;align-items:center;justify-content:center;">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8 col-xl-6">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<h2 class="mb-2">✅ MFA aktiviert</h2>
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Speichere diese Recovery-Codes an einem sicheren Ort.<br>
|
||||||
|
Jeder Code kann <strong>einmal</strong> verwendet werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<strong>⚠️ Wichtig:</strong> Diese Codes werden nur <strong>einmal</strong> angezeigt.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-4 d-flex flex-wrap justify-content-center gap-2">
|
||||||
|
{% for code in codes %}
|
||||||
|
<kbd style="font-size:1.1rem;padding:8px 16px;border-radius:6px;user-select:all;cursor:pointer;" onclick="navigator.clipboard.writeText(this.textContent);this.textContent='✓ Kopiert!';setTimeout(()=>{this.textContent='{{ code }}';},1500);">{{ code }}</kbd>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted small mb-4">Klicke auf einen Code, um ihn zu kopieren.</p>
|
||||||
|
<a href="/admin" class="btn btn-primary">← Zum Admin</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include '_footer.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
101
templates/mfa_setup.html
Normal file
101
templates/mfa_setup.html
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>MFA einrichten – CANCOM Signage</title>
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const stored = window.localStorage.getItem("signage-theme");
|
||||||
|
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||||
|
<script defer src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
|
||||||
|
|
||||||
|
{% include '_styles.html' %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include '_header.html' %}
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="page-body" style="min-height:calc(100vh - 120px);display:flex;align-items:center;justify-content:center;">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8 col-xl-6">
|
||||||
|
|
||||||
|
{% if recovered %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<strong>Hinweis:</strong> Du hast einen Recovery-Code verwendet. Bitte richte MFA neu ein.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if disabled %}
|
||||||
|
<div class="alert alert-info">MFA wurde deaktiviert.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
|
||||||
|
{% if current_user.mfa_enabled %}
|
||||||
|
<h2 class="mb-2">✅ MFA ist aktiv</h2>
|
||||||
|
<p class="text-muted mb-4">Dein Konto ist durch Zwei-Faktor-Authentifizierung geschützt.</p>
|
||||||
|
<p class="text-muted small mb-2">Möchtest du MFA deaktivieren? Gib dein Passwort ein:</p>
|
||||||
|
<form method="post" action="/mfa/disable" class="row justify-content-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="Passwort" required>
|
||||||
|
<button class="btn btn-danger" type="submit">Deaktivieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<h2 class="mb-2">MFA einrichten</h2>
|
||||||
|
<p class="text-muted mb-4">Scanne den QR-Code mit deiner Authenticator-App (z. B. Google Authenticator, Authy).</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if qr_data %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<img src="data:image/png;base64,{{ qr_data }}" alt="QR-Code"
|
||||||
|
style="width:220px;height:220px;border:1px solid #ddd;border-radius:8px;">
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small">Alternativ: Secret manuell eingeben</p>
|
||||||
|
<div class="mb-4">
|
||||||
|
<kbd style="font-size:1.1rem;padding:6px 14px;border-radius:6px;user-select:all;">{{ secret }}</kbd>
|
||||||
|
</div>
|
||||||
|
<hr class="my-4" style="max-width:300px;margin-left:auto;margin-right:auto;">
|
||||||
|
<h4>Code verifizieren</h4>
|
||||||
|
<p class="text-muted small mb-3">Gib den 6-stelligen Code aus der App ein, um MFA zu aktivieren.</p>
|
||||||
|
<form method="post" class="row justify-content-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<input type="text" name="code" class="form-control form-control-lg text-center"
|
||||||
|
placeholder="000000" inputmode="numeric" maxlength="6"
|
||||||
|
pattern="[0-9]{6}" required autofocus style="max-width:160px;">
|
||||||
|
<button class="btn btn-success" type="submit">Aktivieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include '_footer.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
96
templates/mfa_verify.html
Normal file
96
templates/mfa_verify.html
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sicherheitscode eingeben - CANCOM Signage</title>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||||
|
{% include "_styles.html" %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const stored = window.localStorage.getItem("signage-theme");
|
||||||
|
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
{% include "_header.html" %}
|
||||||
|
|
||||||
|
<div class="page-wrapper d-flex align-items-center justify-content-center">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card card-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="text-center mb-2">Sicherheitscode</h2>
|
||||||
|
<p class="text-center text-muted mb-4">Gib den 6-stelligen Code aus deiner Authenticator-App ein.</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger text-center" role="alert">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" name="code" class="form-control form-control-lg text-center"
|
||||||
|
placeholder="000000" inputmode="numeric" autocomplete="one-time-code"
|
||||||
|
maxlength="6" pattern="[0-9]{6}" required autofocus>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100 mb-2" type="submit">Bestätigen</button>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="remember" name="remember" value="7">
|
||||||
|
<label class="form-check-label" for="remember">
|
||||||
|
Gerät für
|
||||||
|
<select class="form-select form-select-sm d-inline-block w-auto" id="remember_days" onchange="document.getElementById('remember').value=this.value">
|
||||||
|
<option value="1">1 Tag</option>
|
||||||
|
<option value="3">3 Tage</option>
|
||||||
|
<option value="7" selected>7 Tage</option>
|
||||||
|
<option value="14">14 Tage</option>
|
||||||
|
<option value="30">30 Tage</option>
|
||||||
|
</select>
|
||||||
|
merken
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var cb = document.getElementById('remember');
|
||||||
|
var sel = document.getElementById('remember_days');
|
||||||
|
cb.addEventListener('change', function() {
|
||||||
|
if (this.checked) this.value = sel.value;
|
||||||
|
});
|
||||||
|
sel.addEventListener('change', function() {
|
||||||
|
if (cb.checked) cb.value = this.value;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center text-muted mt-3 small">
|
||||||
|
<a href="#" onclick="document.getElementById('recovery-form').style.display='block';this.style.display='none';return false;">
|
||||||
|
Code verloren?
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<form id="recovery-form" method="post" style="display:none;">
|
||||||
|
<input type="hidden" name="action" value="recovery">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Recovery-Code eingeben</label>
|
||||||
|
<input type="text" name="code" class="form-control" placeholder="ABCD-123">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-warning w-100" type="submit">Mit Recovery-Code anmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "_footer.html" %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -321,13 +321,10 @@
|
|||||||
// Player-JavaScript
|
// Player-JavaScript
|
||||||
// ═════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════
|
||||||
|
|
||||||
let playerTimer = null;
|
|
||||||
|
|
||||||
// ─── Custom-URL/Voice-Agent-Overlay (open/close mit Zurück-Button-Position-Tracking) ───
|
// ─── Custom-URL/Voice-Agent-Overlay (open/close mit Zurück-Button-Position-Tracking) ───
|
||||||
function openOverlay(url, pos) {
|
function openOverlay(url, pos) {
|
||||||
if (playerTimer) clearTimeout(playerTimer);
|
|
||||||
document.getElementById("overlay").style.display = "block";
|
document.getElementById("overlay").style.display = "block";
|
||||||
document.getElementById("overlay-iframe").src = url;
|
document.getElementById("overlay-iframe").src = appendParams(url, playerParams);
|
||||||
var backBtn = document.getElementById("overlay-back");
|
var backBtn = document.getElementById("overlay-back");
|
||||||
backBtn.className = "overlay-back-" + (pos || "top-left");
|
backBtn.className = "overlay-back-" + (pos || "top-left");
|
||||||
}
|
}
|
||||||
@@ -352,15 +349,24 @@ updateClock();
|
|||||||
const normalFiles = {{ normal_files | tojson }};
|
const normalFiles = {{ normal_files | tojson }};
|
||||||
const prioFiles = {{ prio_files | tojson }};
|
const prioFiles = {{ prio_files | tojson }};
|
||||||
const interval = {{ interval }} * 1000;
|
const interval = {{ interval }} * 1000;
|
||||||
const stayOnFirst = {{ "true" if stay_on_first else "false" }};
|
let stayOnFirst = {{ "true" if stay_on_first else "false" }};
|
||||||
const screen = "{{ screen }}";
|
const screen = "{{ screen }}";
|
||||||
const site = "{{ site }}";
|
const site = "{{ site }}";
|
||||||
// stayOnFirst: Player bleibt auf erstem Element (kein Timer/Advance)
|
const playerParams = {{ player_params | tojson }};
|
||||||
|
|
||||||
|
// Query-Parameter aus der Player-URL an URLs anhängen
|
||||||
|
function appendParams(url, params) {
|
||||||
|
if (!params || Object.keys(params).length === 0) return url;
|
||||||
|
const sep = url.includes('?') ? '&' : '?';
|
||||||
|
return url + sep + new URLSearchParams(params).toString();
|
||||||
|
}
|
||||||
|
// stayOnFirst: Player bleibt auf erstem Element (kein Timer/Advance)
|
||||||
|
|
||||||
// ─── Player-Status ───
|
// ─── Player-Status ───
|
||||||
let normalIndex = 0;
|
let normalIndex = 0;
|
||||||
let prioIndex = 0;
|
let prioIndex = 0;
|
||||||
let mode = "normal"; // "normal" | "prio"
|
let mode = "normal"; // "normal" | "prio"
|
||||||
|
|
||||||
const img = document.getElementById("image");
|
const img = document.getElementById("image");
|
||||||
const vid = document.getElementById("video");
|
const vid = document.getElementById("video");
|
||||||
const iframe = document.getElementById("iframe");
|
const iframe = document.getElementById("iframe");
|
||||||
@@ -450,7 +456,7 @@ function playNext() {
|
|||||||
// URL im iframe anzeigen (mit Zoom-Unterstützung)
|
// URL im iframe anzeigen (mit Zoom-Unterstützung)
|
||||||
if (item.kind === "url") {
|
if (item.kind === "url") {
|
||||||
iframe.style.display = "block";
|
iframe.style.display = "block";
|
||||||
iframe.src = item.url;
|
iframe.src = (item.passthrough === true) ? appendParams(item.url, playerParams) : item.url;
|
||||||
const zoom = item.zoom || 1.0;
|
const zoom = item.zoom || 1.0;
|
||||||
if (zoom !== 1.0) {
|
if (zoom !== 1.0) {
|
||||||
const scale = 1 / zoom;
|
const scale = 1 / zoom;
|
||||||
@@ -462,7 +468,6 @@ function playNext() {
|
|||||||
iframe.style.height = `100vh`;
|
iframe.style.height = `100vh`;
|
||||||
iframe.style.transform = `translate(-50%, -50%)`;
|
iframe.style.transform = `translate(-50%, -50%)`;
|
||||||
}
|
}
|
||||||
if (!stayOnFirst) playerTimer = setTimeout(playNext, interval);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,24 +484,34 @@ function playNext() {
|
|||||||
iframe.style.width = `100vw`;
|
iframe.style.width = `100vw`;
|
||||||
iframe.style.height = `100vh`;
|
iframe.style.height = `100vh`;
|
||||||
iframe.style.transform = `translate(-50%, -50%)`;
|
iframe.style.transform = `translate(-50%, -50%)`;
|
||||||
playerTimer = setTimeout(playNext, interval);
|
|
||||||
} else {
|
} else {
|
||||||
// Bild (jpg/png)
|
// Bild (jpg/png)
|
||||||
img.style.display = "block";
|
img.style.display = "block";
|
||||||
img.src = src;
|
img.src = src;
|
||||||
playerTimer = setTimeout(playNext, interval);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Auto-Reload bei Playlist-Änderungen ───
|
// ─── Auto-Reload bei Playlist-Änderungen + dynamisches stayOnFirst ───
|
||||||
let lastHash = null;
|
let lastHash = null;
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/playlist/${site}/${screen}/hash`, { cache: "no-store" });
|
const w = window.screen ? window.screen.width : '?';
|
||||||
const hash = await res.text();
|
const h = window.screen ? window.screen.height : '?';
|
||||||
if (lastHash && lastHash !== hash) {
|
const res = await fetch(`/playlist/${site}/${screen}/hash?w=${w}&h=${h}`, { cache: "no-store" });
|
||||||
location.reload();
|
const data = await res.json();
|
||||||
|
const hash = data.hash;
|
||||||
|
const serverStayOnFirst = data.stay_on_first;
|
||||||
|
|
||||||
|
const wasStopped = stayOnFirst === true;
|
||||||
|
stayOnFirst = serverStayOnFirst;
|
||||||
|
|
||||||
|
if (wasStopped && !stayOnFirst) {
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastHash && lastHash !== hash && !wasStopped) {
|
||||||
|
location.reload(true);
|
||||||
}
|
}
|
||||||
lastHash = hash;
|
lastHash = hash;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -551,6 +566,16 @@ async function checkForUpdates() {
|
|||||||
|
|
||||||
// ─── Start ───
|
// ─── Start ───
|
||||||
playNext();
|
playNext();
|
||||||
|
|
||||||
|
// Zyklischer Durchlauf: alle `interval` ms vorrücken, wenn stayOnFirst aus ist
|
||||||
|
// und kein Video aktiv läuft (Videos steuern sich via onended)
|
||||||
|
setInterval(function() {
|
||||||
|
if (!stayOnFirst && vid.style.display !== "block") {
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
|
||||||
|
// Config-Änderungen erkennen (stayOnFirst, Playlist)
|
||||||
setInterval(checkForUpdates, 5000);
|
setInterval(checkForUpdates, 5000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
.playlist-row {
|
.playlist-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
28px 1fr 90px 80px 90px 32px;
|
28px 1fr 90px 80px 50px 40px 32px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
|
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
|
||||||
{% endif %}>
|
{% endif %}>
|
||||||
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}
|
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{% if file.type == "image" %}
|
{% if file.type == "image" %}
|
||||||
@@ -183,6 +183,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Bearbeiten (nur URLs) -->
|
||||||
|
{% if file.type == "url" %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-secondary btn-sm edit-url-btn"
|
||||||
|
title="URL bearbeiten"
|
||||||
|
data-name="{{ file.name | e }}"
|
||||||
|
data-zoom="{{ file.zoom | default(1.0) }}"
|
||||||
|
data-site="{{ current_site }}"
|
||||||
|
data-screen="priority"
|
||||||
|
data-oldname="{{ file.name | e }}"
|
||||||
|
data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Drag -->
|
<!-- Drag -->
|
||||||
<span class="drag-handle" title="Reihenfolge ändern">⇅</span>
|
<span class="drag-handle" title="Reihenfolge ändern">⇅</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -236,6 +253,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
/* ─── Global: editState ─── */
|
||||||
|
var editState = { site: "", screen: "", oldName: "" };
|
||||||
|
|
||||||
|
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
var btn = e.target.closest(".edit-url-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
editState.site = btn.dataset.site;
|
||||||
|
editState.screen = btn.dataset.screen;
|
||||||
|
editState.oldName = btn.dataset.oldname;
|
||||||
|
document.getElementById("edit-url-input").value = btn.dataset.oldname;
|
||||||
|
document.getElementById("edit-zoom-input").value = btn.dataset.zoom;
|
||||||
|
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true";
|
||||||
|
document.getElementById("edit-url-error").style.display = "none";
|
||||||
|
new bootstrap.Modal(document.getElementById("edit-url-modal")).show();
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|
||||||
function initSortable(name) {
|
function initSortable(name) {
|
||||||
@@ -278,36 +313,83 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
|
|
||||||
window.savePlaylist = savePlaylist;
|
window.savePlaylist = savePlaylist;
|
||||||
|
|
||||||
// Initialize tooltips
|
/* ─── Bootstrap Tooltips ─── */
|
||||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dark Mode Toggle
|
/* ─── Edit-URL-Modal: Speichern-Button ─── */
|
||||||
const themeToggle = document.getElementById("theme-toggle");
|
document.getElementById("edit-url-save").addEventListener("click", function () {
|
||||||
const root = document.documentElement;
|
const newUrl = document.getElementById("edit-url-input").value.trim();
|
||||||
|
const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0;
|
||||||
|
const errorEl = document.getElementById("edit-url-error");
|
||||||
|
|
||||||
const syncThemeToggle = () => {
|
if (!newUrl.startsWith("http://") && !newUrl.startsWith("https://")) {
|
||||||
const theme = root.getAttribute("data-bs-theme") || "light";
|
errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)";
|
||||||
if (!themeToggle) return;
|
errorEl.style.display = "block";
|
||||||
themeToggle.innerHTML = theme === "dark"
|
return;
|
||||||
? '<i class="ti ti-sun"></i>'
|
}
|
||||||
: '<i class="ti ti-moon"></i>';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (themeToggle) {
|
errorEl.style.display = "none";
|
||||||
syncThemeToggle();
|
|
||||||
themeToggle.addEventListener("click", () => {
|
fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, {
|
||||||
const nextTheme = (root.getAttribute("data-bs-theme") || "light") === "dark" ? "light" : "dark";
|
method: "POST",
|
||||||
root.setAttribute("data-bs-theme", nextTheme);
|
headers: { "Content-Type": "application/json" },
|
||||||
window.localStorage.setItem("signage-theme", nextTheme);
|
body: JSON.stringify({
|
||||||
syncThemeToggle();
|
old_name: editState.oldName,
|
||||||
|
new_url: newUrl,
|
||||||
|
zoom: zoom,
|
||||||
|
passthrough: document.getElementById("edit-passthrough-input").checked
|
||||||
|
})
|
||||||
|
}).then(function (r) {
|
||||||
|
if (r.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
return r.json().then(function (d) {
|
||||||
|
errorEl.textContent = d.error || "Fehler beim Speichern";
|
||||||
|
errorEl.style.display = "block";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
errorEl.textContent = "Netzwerkfehler";
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Modal: URL bearbeiten -->
|
||||||
|
<div class="modal fade" id="edit-url-modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">URL bearbeiten</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">URL</label>
|
||||||
|
<input type="url" class="form-control" id="edit-url-input">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
|
||||||
|
<input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="edit-passthrough-input">
|
||||||
|
<label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="edit-url-save">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
144
templates/user_create.html
Normal file
144
templates/user_create.html
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<!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/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</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>
|
||||||
|
<a class="btn btn-white" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</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="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Vorname</label>
|
||||||
|
<input type="text" name="first_name" class="form-control" placeholder="Vorname">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Nachname</label>
|
||||||
|
<input type="text" name="last_name" class="form-control" placeholder="Nachname">
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Abteilung</label>
|
||||||
|
<input type="text" name="department" class="form-control" placeholder="z. B. Marketing">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Bemerkung</label>
|
||||||
|
<textarea name="notes" class="form-control" rows="2" placeholder="Interne Notiz"></textarea>
|
||||||
|
</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>
|
||||||
151
templates/user_edit.html
Normal file
151
templates/user_edit.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!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/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</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>
|
||||||
|
<a class="btn btn-white" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</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="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Vorname</label>
|
||||||
|
<input type="text" name="first_name" class="form-control" value="{{ user.first_name }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Nachname</label>
|
||||||
|
<input type="text" name="last_name" class="form-control" value="{{ user.last_name }}">
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Abteilung</label>
|
||||||
|
<input type="text" name="department" class="form-control" value="{{ user.department }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Bemerkung</label>
|
||||||
|
<textarea name="notes" class="form-control" rows="2">{{ user.notes }}</textarea>
|
||||||
|
</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>
|
||||||
155
templates/user_list.html
Normal file
155
templates/user_list.html
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<!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" href="/admin/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</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>
|
||||||
|
<a class="btn btn-white" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</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>Name</th>
|
||||||
|
<th>E-Mail</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
<th>Standorte</th>
|
||||||
|
<th>Abteilung</th>
|
||||||
|
<th>MFA</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap">{{ u.last_name }}{% if u.last_name and u.first_name %}, {% endif %}{{ u.first_name }}</td>
|
||||||
|
<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 class="text-muted small">{{ u.department }}</td>
|
||||||
|
<td>
|
||||||
|
{% if u.mfa_enabled %}
|
||||||
|
<span class="badge bg-green text-green-fg">Aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% 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 class="text-nowrap">
|
||||||
|
<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>
|
||||||
64
users.json
Normal file
64
users.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"admin": {
|
||||||
|
"password_hash": "scrypt:32768:8:1$JNEBAJBSSigcWCxK$4809dcc38b2abcaa3f6397d052560f9f59dedf7c95cf99b61bac0df08a65b64287907522841cbb23e06b36760814007d5757542db1c968a8536be7fd6604a382",
|
||||||
|
"role": "admin",
|
||||||
|
"sites": [],
|
||||||
|
"must_change_password": false,
|
||||||
|
"mfa_enabled": false,
|
||||||
|
"mfa_recovery_codes": [
|
||||||
|
"923EFA",
|
||||||
|
"AED258",
|
||||||
|
"E8EAF6",
|
||||||
|
"A659E6",
|
||||||
|
"946F07",
|
||||||
|
"880C07",
|
||||||
|
"FC204A",
|
||||||
|
"1D09D7"
|
||||||
|
],
|
||||||
|
"mfa_secret": "VOLBXT7HYK6BXBD32EGW7CMFLVD53QON",
|
||||||
|
"trusted_devices": []
|
||||||
|
},
|
||||||
|
"erik.thiele@cancom.de": {
|
||||||
|
"password_hash": "scrypt:32768:8:1$D8XYprsEYMO5EPrG$2829b18f77bbd7d9f0cbb945b0bdbc32e16d7889f8711881f82dd35accd26ddb7c31d3fa85e3b89d19d6d1f0b92d8d18765e1c82044188cab8fd0db2d35a0d15",
|
||||||
|
"role": "admin",
|
||||||
|
"sites": [],
|
||||||
|
"must_change_password": false,
|
||||||
|
"first_name": "Erik",
|
||||||
|
"last_name": "Thiele",
|
||||||
|
"department": "CSO",
|
||||||
|
"notes": "Standort Stuttgart",
|
||||||
|
"mfa_secret": "LR5DYI3SQPMW5XWKO33HPETHY5TPOWRG",
|
||||||
|
"mfa_enabled": true,
|
||||||
|
"mfa_recovery_codes": [
|
||||||
|
"7BBD93",
|
||||||
|
"EB1D3B",
|
||||||
|
"BEE291",
|
||||||
|
"D471D5",
|
||||||
|
"D43C00",
|
||||||
|
"75DBAC",
|
||||||
|
"8B00CB",
|
||||||
|
"7AF72D"
|
||||||
|
],
|
||||||
|
"trusted_devices": [
|
||||||
|
{
|
||||||
|
"token": "5kMgj4JPxd2IHuzV5MagAAcYyi_DBImyGimBSvZUNVg",
|
||||||
|
"expires": "2026-07-24T15:40:28.955130"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"test1@test.de": {
|
||||||
|
"password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1",
|
||||||
|
"role": "user",
|
||||||
|
"sites": [
|
||||||
|
"dva",
|
||||||
|
"karlsruhe",
|
||||||
|
"stuttgart"
|
||||||
|
],
|
||||||
|
"must_change_password": true,
|
||||||
|
"first_name": "test",
|
||||||
|
"last_name": "tester",
|
||||||
|
"department": "addff",
|
||||||
|
"notes": "asdasdasd",
|
||||||
|
"trusted_devices": []
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user