Compare commits

..

8 commits

Author SHA1 Message Date
2fe7e9522d Theme-Auswahl aus dem Seitenkopf nicht mehr überschreiben
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
global.js hat beim Laden erneut ein Theme gesetzt und dabei die
Entscheidung des Inline-Schnipsels im <head> verworfen – sichtbar als
kurzes Umspringen und daran, dass erzwungene Themes ignoriert wurden.
Jetzt gilt: ausdrückliche Auswahl > bereits gesetztes Attribut > System.

Updater-Seite übernimmt ebenfalls die Markenfarben, Screenshots neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:53:57 +00:00
0716311ff6 Tests für Upload und Ui, strengere CI-Prüfungen
- tests/UploadTest.php prüft den SVG-Filter (Skripte, Event-Handler,
  javascript:-Verweise) und Upload::isLocal gegen Pfad-Tricks
- tests/UiTest.php prüft Versionsstempel, Media-Pfade und die
  Branding-Overrides inklusive Abweisung ungültiger Farbwerte
- PHPStan analysiert jetzt auch includes/Ui.php und includes/Upload.php
- CI vergleicht die Sprachdateien (gleiche Schlüsselmenge) und prüft,
  dass jeder im Code verwendete Schlüssel existiert

Dabei aufgefallen und behoben: Upload.php rief __() direkt auf und wäre
außerhalb einer Seite mit geladener I18n mit einem Fatal Error
abgebrochen; jetzt gibt es einen Fallback auf die deutsche Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:52:22 +00:00
36e06ac817 Sicherheits-Header, Werkzeuge und Dokumentation
Sicherheit:
- .htaccess im Projektstamm mit X-Content-Type-Options, X-Frame-Options,
  Referrer-Policy, Permissions-Policy und einer Content-Security-Policy;
  da alle Assets lokal liegen, erlaubt sie nur noch die eigene Herkunft
  (Ausnahme: hCaptcha, falls aktiviert)
- includes/, tools/, tests/, updater/storage und uploads/ schützen sich
  über eigene .htaccess-Dateien – auch bei Installation im Unterordner
- Docker: AllowOverride All, damit diese Regeln überhaupt greifen, und
  ein Volume für uploads/, damit Logos ein Image-Update überstehen

Werkzeuge:
- tools/screenshots.py erzeugt alle Bilder in docs/screenshots aus der
  Demo-Instanz; tools/README.md beschreibt beides
- Einstellungs-Tabs sind per ?tab=… direkt verlinkbar (serverseitig, also
  auch ohne JavaScript)

Dokumentation: Readme um Markenfarben, Bild-Upload, lokale Assets,
Sicherheits-Header (inkl. Nginx-Entsprechung) und einen Abschnitt
"Entwicklung" ergänzt; Screenshots neu erzeugt, Version 2.6.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:49:14 +00:00
e28527ed91 E-Mails und Druckvorlage im neuen Design
E-Mail:
- Nachrichten werden in ein markentreues HTML-Gerüst gelegt (Tabellen +
  Inline-Styles, damit Outlook & Co. es zuverlässig darstellen); die
  Kopfleiste nutzt den eingestellten Markenverlauf
- neuer Platzhalter {VOUCHER_CARD} rendert den Code als hervorgehobene
  Karte; bestehende Vorlagen mit {VOUCHER_CODE} funktionieren unverändert
  weiter, sie werden lediglich eingerahmt
- Zeilenumbrüche der Vorlage werden vor dem Einsetzen der Platzhalter
  umgewandelt, Markup aus dem WYSIWYG-Editor bleibt unangetastet

Druck:
- neue Standardvorlage als Ticket mit QR-Code, Code, Gültigkeit und
  Anleitung; zentral in Ui::defaultPrintTemplate()
- neuer Platzhalter {QR_CODE}; der QR-Code wird lokal im Browser erzeugt
  (qrcodejs aus assets/vendor), auch bei Bulk-Erstellung für jede Karte
- die Druckvorlage ist am Bildschirm ausgeblendet und erscheint nur im
  Ausdruck

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:42:45 +00:00
7f1d93debd Barrierefreiheit und mobile Darstellung
- Kontrast: gedämpfter Text war mit 3,1:1 unter WCAG AA, jetzt 4,9:1
  (hell) bzw. 6,4:1 (dunkel)
- Sprungmarke „Zum Inhalt springen" in Admin-Shell und Voucher-Seite
- aria-label für alle reinen Icon-Schaltflächen (Theme, Menü, Abmelden,
  Zeilenaktionen), aria-current auf dem aktiven Navigationspunkt,
  role="group" für den Sprachumschalter
- 194 dekorative Icons mit aria-hidden versehen, damit Screenreader sie
  nicht vorlesen
- Toast-Container als aria-live-Bereich ausgezeichnet
- prefers-reduced-motion schaltet Animationen und Übergänge ab

Tabellen (Benutzer, Vouchers, Profile, Audit-Log, Dashboard, API-Keys)
werden unter 720 px zu Karten: die Spaltenüberschrift steht per
data-label vor dem Wert, statt horizontal zu scrollen.

Datums- und Zeitformat in der Voucher-Liste folgen jetzt der gewählten
Sprache statt fest de-DE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:38:08 +00:00
850a04d628 Englische Übersetzungen für die restlichen Admin-Seiten
API-Schlüssel, Integration & Wartung, Voucher-Import, Backup & Restore,
Reporting, Sicherheit (2FA) und Audit-Log waren fest auf Deutsch
verdrahtet, obwohl in der Kopfzeile ein DE/EN-Umschalter sitzt. Diese
Seiten laufen jetzt komplett über lang/de.php bzw. lang/en.php.

- rund 200 neue Sprachschlüssel, beide Dateien deckungsgleich
- Aktionsnamen im Audit-Log werden übersetzt statt fest ausgegeben
- Bestätigungsdialoge und Statusmeldungen in JavaScript ebenfalls
- admin/security.php initialisiert jetzt I18n und setzt <html lang>
  passend zur Auswahl

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:34:25 +00:00
30d0ce3a23 Branding: Farben systemweit einstellbar + Bild-Upload statt nur URLs
Design-Tab (Administration → Einstellungen → Design):
- Akzentfarbe für Hell- und Dark-Mode, Markenverlauf und Eckenradius
- abgeleitete Töne (Hover, weiche Flächen, Fokusring) werden per
  color-mix aus der Grundfarbe berechnet – eine Farbe genügt
- Live-Vorschau mit Button, Badge, Chip, Logo-Kachel und Link
- Ausgabe als schlanker :root-Override über Ui::brandingStyle(), greift
  auf allen Seiten inklusive Login und Installer

Uploads (includes/Upload.php):
- Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich jetzt
  hochladen; das URL-Feld bleibt als Alternative bestehen
- Whitelist nach Endung, 3-MB-Grenze, getimagesize-Prüfung für Raster,
  SVGs werden von Skripten, Event-Handlern und externen Verweisen befreit
- Zufällige Dateinamen in uploads/, dort sperrt eine .htaccess die
  Ausführung von PHP; beim Ersetzen wird die alte Datei gelöscht

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:27:30 +00:00
6da46f040a Alle Frontend-Assets lokal ausliefern statt über CDNs
Inter, Font Awesome, Chart.js, qrcodejs und TinyMCE liegen jetzt unter
assets/vendor/ und werden vom eigenen Server ausgeliefert.

Warum:
- Datenschutz: bisher ging bei jedem Seitenaufruf die IP der Nutzer an
  Google Fonts, cdnjs, jsDelivr und Tiny Cloud
- Funktion: UniFi-Installationen stehen oft in abgeschotteten Netzen –
  dort fehlten bisher Schrift, Icons, Diagramme und Editor

Neu: includes/Ui.php
- Ui::head()/Ui::script() binden die Assets ein und hängen einen
  Versionsstempel an (?v=filemtime), damit Browser nach einem Update
  nicht das alte CSS aus dem Cache nehmen
- Ui::themeScript() setzt das Theme aus der gespeicherten Auswahl oder
  – wenn keine vorliegt – aus prefers-color-scheme; global.js folgt
  Systemwechseln live, solange nichts manuell gewählt wurde
- <meta name="color-scheme"> ergänzt, damit Formularelemente passen

Der TinyMCE-API-Key entfällt: der Editor läuft immer lokal (GPL-Variante),
inklusive deutscher Oberfläche, wenn die App auf Deutsch steht.

Neu: tools/demo/build.py – baut aus dem Projekt eine Demo-Instanz ohne
Datenbank (Stubs für Database/Auth, feste Beispieldaten). Damit lassen
sich Screenshots reproduzierbar erzeugen und alle Seiten einmal rendern
(Smoke-Test), ohne eine MySQL-Instanz aufzusetzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:23:44 +00:00
106 changed files with 4073 additions and 1393 deletions

View file

@ -29,9 +29,40 @@ jobs:
php -l "$f" php -l "$f"
done done
- name: Validate JSON language/migration assets - name: Validate language files
run: | run: |
php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";' php -r '
$de = require "lang/de.php"; $en = require "lang/en.php";
if (!is_array($de) || !is_array($en)) { fwrite(STDERR, "Bad lang file\n"); exit(1); }
$missingEn = array_diff(array_keys($de), array_keys($en));
$missingDe = array_diff(array_keys($en), array_keys($de));
if ($missingEn || $missingDe) {
fwrite(STDERR, "Fehlend in en: " . implode(", ", $missingEn) . "\n");
fwrite(STDERR, "Fehlend in de: " . implode(", ", $missingDe) . "\n");
exit(1);
}
echo "lang OK (" . count($de) . " Schluessel)\n";'
- name: Check that every used translation key exists
run: |
php -r '
$de = require "lang/de.php";
$missing = [];
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(".", FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
$path = $file->getPathname();
if (substr($path, -4) !== ".php") continue;
if (strpos($path, "/vendor/") !== false || strpos($path, "/tools/") !== false) continue;
preg_match_all("/__\(\s*\x27([a-z0-9_]+)\x27/", file_get_contents($path), $m);
foreach ($m[1] as $key) {
if (!isset($de[$key]) && substr($key, -1) !== "_") { $missing[$key] = $path; }
}
}
if ($missing) {
foreach ($missing as $key => $path) { fwrite(STDERR, "Unbekannter Schluessel $key in $path\n"); }
exit(1);
}
echo "Alle verwendeten Schluessel vorhanden\n";'
test: test:
name: Unit Tests & Static Analysis name: Unit Tests & Static Analysis

26
.htaccess Normal file
View file

@ -0,0 +1,26 @@
# ---------------------------------------------------------------------------
# Sicherheits-Header und Zugriffsschutz (Apache)
# Nginx-Entsprechung siehe Readme.md, Abschnitt "Sicherheit".
# ---------------------------------------------------------------------------
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
# Alle Frontend-Assets liegen lokal; externe Quellen nur fuer hCaptcha,
# falls es in den Einstellungen aktiviert wurde.
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline' https://*.hcaptcha.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.hcaptcha.com; frame-src https://*.hcaptcha.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
</IfModule>
# Kein Verzeichnislisting
Options -Indexes
# Dateien, die nie direkt ausgeliefert werden sollen
<FilesMatch "^(config\.php|composer\.(json|lock)|phpunit\.xml\.dist|phpstan\.neon|database\.sql)$">
Require all denied
</FilesMatch>
# Interne Ordner schuetzen sich ueber eigene .htaccess-Dateien
# (funktioniert auch bei Installation in einem Unterverzeichnis).

View file

@ -16,11 +16,14 @@ RUN { \
echo 'post_max_size=8M'; \ echo 'post_max_size=8M'; \
} > /usr/local/etc/php/conf.d/zz-voucher.ini } > /usr/local/etc/php/conf.d/zz-voucher.ini
# .htaccess auswerten (Sicherheits-Header, Schutz des uploads-Ordners)
RUN sed -ri 's!<Directory /var/www/>!<Directory /var/www/>\n\tAllowOverride All!g' /etc/apache2/apache2.conf
WORKDIR /var/www/html WORKDIR /var/www/html
COPY . /var/www/html COPY . /var/www/html
# Laufzeit-Verzeichnis des Updaters beschreibbar machen # Laufzeit-Verzeichnisse beschreibbar machen
RUN mkdir -p /var/www/html/updater/storage \ RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \
&& chown -R www-data:www-data /var/www/html && chown -R www-data:www-data /var/www/html
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh

115
Readme.md
View file

@ -8,7 +8,7 @@
![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white)
![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white) ![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white)
![License](https://img.shields.io/badge/Lizenz-MIT-green) ![License](https://img.shields.io/badge/Lizenz-MIT-green)
![Version](https://img.shields.io/badge/Version-2.5.0-blueviolet) ![Version](https://img.shields.io/badge/Version-2.6.0-blueviolet)
![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg) ![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg)
</div> </div>
@ -47,6 +47,11 @@
- 🌍 **Öffentlicher Modus** optional ohne Login nutzbar (mit CSRF-Schutz & Throttle) - 🌍 **Öffentlicher Modus** optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
- 🎨 **Einheitliches Design-System** ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark) - 🎨 **Einheitliches Design-System** ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark)
- 🏷️ **Login-Seite individualisierbar** Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf - 🏷️ **Login-Seite individualisierbar** Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf
- 🖌️ **Eigene Markenfarben** Akzentfarbe, Verlauf und Eckenradius wirken auf die gesamte Oberfläche
- ⬆️ **Bild-Upload** für Logo, Favicon und Login-Hintergrund (kein externes Hosting nötig)
- 🔒 **Keine externen CDNs** Schrift, Icons, Diagramme und Editor werden lokal ausgeliefert (DSGVO, Offline-Netze)
- ♿ **Barrierearm** Kontraste nach WCAG AA, Sprungmarke, aria-Beschriftungen, `prefers-reduced-motion`
- 📱 **Mobil nutzbar** Tabellen werden auf schmalen Geräten zu Karten
- 🌗 **Dark Mode** umschaltbar, Einstellung wird im Browser gespeichert - 🌗 **Dark Mode** umschaltbar, Einstellung wird im Browser gespeichert
- 🌐 **Mehrsprachig** Deutsch / Englisch per Umschalter (`lang/`) - 🌐 **Mehrsprachig** Deutsch / Englisch per Umschalter (`lang/`)
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay - 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
@ -92,6 +97,11 @@
<img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%"> <img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%">
</div> </div>
<div align="center">
<img src="docs/screenshots/settings-branding.png" alt="Markenfarben einstellen" width="48%">
<img src="docs/screenshots/mobile-vouchers.png" alt="Ansicht auf dem Smartphone" width="22%">
</div>
### REST-API, 2FA & Integrationen ### REST-API, 2FA & Integrationen
<div align="center"> <div align="center">
@ -249,21 +259,44 @@ gemeinsames Stylesheet: **`assets/global.css`**.
- **Dark Mode** ausschließlich über Tokens keine `!important`-Overrides mehr - **Dark Mode** ausschließlich über Tokens keine `!important`-Overrides mehr
- **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback - **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback
Eigenes Branding lässt sich meist mit wenigen Zeilen umsetzen z. B. in einer ### Markenfarben ohne Code
eigenen CSS-Datei oder direkt in `assets/global.css`:
Unter **Administration → Einstellungen → Design** lassen sich Akzentfarbe
(hell und dunkel), Markenverlauf und Eckenradius setzen. Abgeleitete Töne
Hover, weiche Flächen, Rahmen, Fokusring berechnet das System per `color-mix`
aus der Grundfarbe; eine Farbe genügt also. Eine Live-Vorschau zeigt Button,
Badge, Chip und Logo-Kachel sofort im neuen Ton.
Die Werte landen als schlanker `:root`-Override im Seitenkopf und gelten überall,
auch auf Login-Seite, Installer und Updater. Wer lieber in CSS arbeitet, kann
dieselben Variablen weiterhin in `assets/global.css` überschreiben:
```css ```css
:root { :root {
--accent: #0f766e; /* Primärfarbe (Buttons, aktive Navigation) */ --accent: #0f766e; /* Primärfarbe (Buttons, aktive Navigation) */
--accent-hover: #0d5f59;
--accent-soft: #e6f4f2; /* Flächen für aktive Zustände */
--brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%); --brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%);
--r-lg: 14px; /* Eckenradius für Cards */ --r-lg: 14px; /* Eckenradius für Cards */
} }
``` ```
Logo und Favicon werden nicht über CSS, sondern unter ### Bilder hochladen
**Administration → Einstellungen → Allgemein** gesetzt.
Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich direkt hochladen
alternativ bleibt das URL-Feld bestehen. Die Dateien landen unter `uploads/`
(Docker: eigenes Volume, siehe unten). Erlaubt sind PNG, JPG, WEBP, GIF und SVG
bis 3 MB; SVGs werden vor dem Speichern von Skripten und externen Verweisen
befreit, und im Upload-Ordner sperrt eine `.htaccess` die PHP-Ausführung.
### Assets ohne Drittanbieter
Schrift (Inter), Icons (Font Awesome), Diagramme (Chart.js), QR-Codes und der
WYSIWYG-Editor (TinyMCE) liegen unter `assets/vendor/` und kommen vom eigenen
Server. Das hält Besucher-IPs bei Ihnen und die Oberfläche funktioniert auch
dort, wo das Netz keinen Weg nach außen hat. Details und Aktualisierungs-Hinweise:
[`assets/vendor/README.md`](assets/vendor/README.md).
Alle Asset-URLs tragen einen Versionsstempel (`?v=…`), damit Browser nach einem
Update nicht die alten Dateien aus dem Cache verwenden.
### Login-Seite individualisieren ### Login-Seite individualisieren
@ -303,13 +336,30 @@ Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
| **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout | | **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout |
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) | | **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
Empfohlene zusätzliche Härtung am Server: Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern
(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal
liegen, erlaubt die CSP nur noch die eigene Herkunft externe Verbindungen
bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie
`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene
`.htaccess`-Dateien.
```apache > **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst
# .htaccess sensible Dateien sperren (wird vom Installer erzeugt) > werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image
<FilesMatch "^(config\.php|database\.sql|install\.php|test\.php|m365_debug\.php|.*\.md)$"> > erledigt das bereits.
Require all denied
</FilesMatch> Für **Nginx** entspricht das:
```nginx
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always;
location ~ ^/(includes|tools|tests)/ { deny all; }
location ~ ^/updater/(storage|migrations)/ { deny all; }
location ~ ^/(config\.php|database\.sql)$ { deny all; }
location ^~ /uploads/ { location ~ \.php$ { deny all; } }
``` ```
```sql ```sql
@ -432,6 +482,40 @@ Das Schema wird beim ersten Start automatisch in MariaDB geladen; danach den
Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV
setzen (`DB_*`, `APP_KEY`). setzen (`DB_*`, `APP_KEY`).
Hochgeladene Logos und Hintergründe liegen im Volume `uploads` und überstehen
damit ein Image-Update. Bei eigener Apache-/Nginx-Installation muss `uploads/`
für den Webserver beschreibbar sein:
```bash
chown -R www-data:www-data uploads updater/storage
```
---
## 🧪 Entwicklung
Für Screenshots und einen schnellen Durchlauf aller Seiten gibt es eine
Demo-Instanz **ohne Datenbank** `Database` und `Auth` werden durch Stubs mit
festen Beispieldaten ersetzt:
```bash
python3 tools/demo/build.py /tmp/uvt-demo
php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
# Bilder in docs/screenshots neu erzeugen (benötigt headless Chromium)
CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py
```
Details und die verfügbaren Demo-Zustände: [`tools/README.md`](tools/README.md).
Tests und statische Analyse:
```bash
composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse
```
## 🗺️ Roadmap ## 🗺️ Roadmap
- [x] Voucher-Templates (vordefinierte Laufzeiten) - [x] Voucher-Templates (vordefinierte Laufzeiten)
@ -447,11 +531,14 @@ setzen (`DB_*`, `APP_KEY`).
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint - [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan) - [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
- [x] Gemeinsames Design-System für Frontend, Login und Backend - [x] Gemeinsames Design-System für Frontend, Login und Backend
- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite)
- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
- [x] Vollständige englische Übersetzung des Admin-Bereichs
--- ---
<div align="center"> <div align="center">
**Version 2.5.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** **Version 2.6.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
</div> </div>

View file

@ -37,7 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) {
); );
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt"); $auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt");
$newKey = $k['plain']; $newKey = $k['plain'];
$success = 'API-Schlüssel erstellt. Bitte JETZT kopieren er wird nur einmal angezeigt!'; $success = __('api_created_once');
} }
} }
} }
@ -46,14 +46,14 @@ if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken(
$row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]); $row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]);
if ($row) { if ($row) {
$db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]); $db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]);
$success = 'Status aktualisiert.'; $success = __('api_status_updated');
} }
} }
if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
$db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]); $db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]);
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht'); $auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht');
$success = 'API-Schlüssel gelöscht.'; $success = __('api_deleted');
} }
$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC"); $keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC");
@ -66,12 +66,12 @@ $adminBase = '';
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API-Schlüssel <?= htmlspecialchars($appTitle) ?></title> <title><?= __('api_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?> <?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">API-Schlüssel</h1> <h1 class="page-title"><?= __('api_title') ?></h1>
<p class="page-subtitle">Zugänge für externe Systeme mit Scope und Rate-Limit.</p> <p class="page-subtitle"><?= __('api_subtitle') ?></p>
</div> </div>
</div> </div>
@ -80,55 +80,55 @@ $adminBase = '';
<?php if ($newKey): ?> <?php if ($newKey): ?>
<div class="card"> <div class="card">
<h2>Neuer Schlüssel</h2> <h2><?= __('api_new_key') ?></h2>
<p class="muted">Kopieren Sie ihn jetzt aus Sicherheitsgründen wird er nicht erneut angezeigt.</p> <p class="muted"><?= __('api_new_key_hint') ?></p>
<div class="keybox"><?= htmlspecialchars($newKey) ?></div> <div class="keybox"><?= htmlspecialchars($newKey) ?></div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="card"> <div class="card">
<h2>Neuen API-Schlüssel erstellen</h2> <h2><?= __('api_create_title') ?></h2>
<form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;"> <form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<div style="flex:2;min-width:200px;"> <div style="flex:2;min-width:200px;">
<label class="muted" style="display:block;margin-bottom:6px;">Bezeichnung</label> <label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_name') ?></label>
<input class="input" type="text" name="name" placeholder="z.B. Buchungssystem, Terminal Foyer" required> <input class="input" type="text" name="name" placeholder="<?= __('api_name_placeholder') ?>" required>
</div> </div>
<div style="flex:1;min-width:130px;"> <div style="flex:1;min-width:130px;">
<label class="muted" style="display:block;margin-bottom:6px;">Berechtigung</label> <label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_scope') ?></label>
<select class="input" name="scope"> <select class="input" name="scope">
<option value="write">Lesen + Erstellen</option> <option value="write"><?= __('api_scope_write') ?></option>
<option value="read">Nur Lesen</option> <option value="read"><?= __('api_scope_read') ?></option>
</select> </select>
</div> </div>
<div style="flex:1;min-width:120px;"> <div style="flex:1;min-width:120px;">
<label class="muted" style="display:block;margin-bottom:6px;">Limit (Anfr./min)</label> <label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_limit') ?></label>
<input class="input" type="number" name="rate_limit" min="0" value="0" title="0 = unbegrenzt"> <input class="input" type="number" name="rate_limit" min="0" value="0" title="<?= __('api_limit_title') ?>">
</div> </div>
<button class="btn btn-primary" type="submit" name="create_key">Erstellen</button> <button class="btn btn-primary" type="submit" name="create_key"><?= __('btn_create') ?></button>
</form> </form>
</div> </div>
<div class="card"> <div class="card">
<h2>Vorhandene Schlüssel</h2> <h2><?= __('api_existing') ?></h2>
<?php if (empty($keys)): ?> <?php if (empty($keys)): ?>
<p class="muted">Noch keine API-Schlüssel angelegt.</p> <p class="muted"><?= __('api_none') ?></p>
<?php else: ?> <?php else: ?>
<div class="table-container"> <div class="table-container">
<table> <table class="table-stack">
<tr><th>Name</th><th>Präfix</th><th>Scope</th><th>Limit</th><th>Status</th><th>Zuletzt genutzt</th><th>Erstellt von</th><th></th></tr> <tr><th><?= __('label_name') ?></th><th><?= __('api_col_prefix') ?></th><th><?= __('api_col_scope') ?></th><th><?= __('api_col_limit') ?></th><th><?= __('label_status') ?></th><th><?= __('api_col_last_used') ?></th><th><?= __('api_col_created_by') ?></th><th></th></tr>
<?php foreach ($keys as $k): ?> <?php foreach ($keys as $k): ?>
<tr> <tr>
<td><?= htmlspecialchars($k['name']) ?></td> <td data-label="<?= __('label_name') ?>"><?= htmlspecialchars($k['name']) ?></td>
<td><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td> <td data-label="<?= __('api_col_prefix') ?>"><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
<td><?= ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?></td> <td data-label="<?= __('api_col_scope') ?>"><?= ($k['scope'] ?? 'write') === 'read' ? __('api_scope_read_short') : __('api_scope_write_short') ?></td>
<td><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td> <td data-label="<?= __('api_col_limit') ?>"><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
<td><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? 'aktiv' : 'gesperrt' ?></span></td> <td data-label="<?= __('label_status') ?>"><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? __('api_state_active') : __('api_state_blocked') ?></span></td>
<td class="muted"><?= $k['last_used_at'] ? date('d.m.Y H:i', strtotime($k['last_used_at'])) : '' ?></td> <td class="muted" data-label="<?= __('api_col_last_used') ?>"><?= $k['last_used_at'] ? date('d.m.Y H:i', strtotime($k['last_used_at'])) : '' ?></td>
<td class="muted"><?= htmlspecialchars($k['creator'] ?? '') ?></td> <td class="muted" data-label="<?= __('api_col_created_by') ?>"><?= htmlspecialchars($k['creator'] ?? '') ?></td>
<td style="text-align:right;white-space:nowrap;"> <td style="text-align:right;white-space:nowrap;">
<a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? 'Sperren' : 'Aktivieren' ?></a> <a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? __('api_action_block') : __('api_action_unblock') ?></a>
<a class="a-link" style="color:var(--danger);" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('Schlüssel löschen?');">Löschen</a> <a class="a-link" style="color:var(--danger);" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('<?= __('api_delete_confirm') ?>');"><?= __('btn_delete') ?></a>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
@ -138,8 +138,8 @@ $adminBase = '';
</div> </div>
<div class="card"> <div class="card">
<h2>Verwendung</h2> <h2><?= __('api_usage') ?></h2>
<p class="muted" style="margin-bottom:10px;">Authentifizierung per Header <code>Authorization: Bearer &lt;key&gt;</code> oder <code>X-API-Key: &lt;key&gt;</code>.</p> <p class="muted" style="margin-bottom:10px;"><?= __('api_usage_hint') ?> <code>Authorization: Bearer &lt;key&gt;</code> oder <code>X-API-Key: &lt;key&gt;</code>.</p>
<pre class="keybox" style="color:#cdd3e0;white-space:pre-wrap;"># Voucher erstellen <pre class="keybox" style="color:#cdd3e0;white-space:pre-wrap;"># Voucher erstellen
curl -X POST https://IHRE-DOMAIN/api/vouchers.php \ curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
-H "Authorization: Bearer uvt_…" \ -H "Authorization: Bearer uvt_…" \
@ -148,7 +148,7 @@ curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
# Sites auflisten # Sites auflisten
curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"</pre> curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"</pre>
<p class="muted" style="margin-top:12px;">OpenAPI-Spezifikation (Import in Postman/Swagger): <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p> <p class="muted" style="margin-top:12px;"><?= __('api_openapi') ?> <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p>
</div> </div>
</main> </main>

View file

@ -45,23 +45,14 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY
$currentPage = 'audit_log'; $currentPage = 'audit_log';
$adminBase = ''; $adminBase = '';
$actionLabels = [ // Aktionsnamen uebersetzt anzeigen; unbekannte Aktionen bleiben technisch.
'voucher_created' => 'Voucher erstellt', $actionLabels = [];
'voucher_bulk' => 'Bulk Voucher', foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created',
'user_login' => 'Login', 'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted',
'user_logout' => 'Logout', 'settings_saved', 'password_reset', 'template_created', 'template_updated',
'user_created' => 'Benutzer erstellt', 'template_deleted'] as $action) {
'user_updated' => 'Benutzer geändert', $actionLabels[$action] = __('audit_action_' . $action);
'user_deleted' => 'Benutzer gelöscht', }
'site_added' => 'Site hinzugefügt',
'site_updated' => 'Site geändert',
'site_deleted' => 'Site gelöscht',
'settings_saved' => 'Einstellungen gespeichert',
'password_reset' => 'Passwort-Reset',
'template_created' => 'Profil erstellt',
'template_updated' => 'Profil geändert',
'template_deleted' => 'Profil gelöscht',
];
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>"> <html lang="<?= I18n::getLanguage() ?>">
@ -80,7 +71,7 @@ $actionLabels = [
<!-- Filter --> <!-- Filter -->
<div class="card" style="margin-bottom: 20px;"> <div class="card" style="margin-bottom: 20px;">
<div class="card-header"><span class="card-title"><i class="fas fa-filter"></i> <?= __('audit_filter') ?></span></div> <div class="card-header"><span class="card-title"><i class="fas fa-filter" aria-hidden="true"></i> <?= __('audit_filter') ?></span></div>
<div style="padding: 20px 25px;"> <div style="padding: 20px 25px;">
<form method="get" class="filter-bar"> <form method="get" class="filter-bar">
<div> <div>
@ -97,7 +88,7 @@ $actionLabels = [
<div> <div>
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:5px;"><?= __('audit_user') ?></label> <label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:5px;"><?= __('audit_user') ?></label>
<select name="user_id"> <select name="user_id">
<option value="">Alle Benutzer</option> <option value=""><?= __('audit_all_users') ?></option>
<?php foreach ($users as $u): ?> <?php foreach ($users as $u): ?>
<option value="<?= $u['id'] ?>" <?= (string)$filterUser === (string)$u['id'] ? 'selected' : '' ?>> <option value="<?= $u['id'] ?>" <?= (string)$filterUser === (string)$u['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($u['name']) ?> <?= htmlspecialchars($u['name']) ?>
@ -105,8 +96,8 @@ $actionLabels = [
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search"></i> Filtern</button> <button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search" aria-hidden="true"></i> Filtern</button>
<a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times"></i> Zurücksetzen</a> <a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times" aria-hidden="true"></i> Zurücksetzen</a>
</form> </form>
</div> </div>
</div> </div>
@ -114,15 +105,15 @@ $actionLabels = [
<!-- Log-Tabelle --> <!-- Log-Tabelle -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<span class="card-title"><i class="fas fa-history"></i> <?= __('audit_title') ?></span> <span class="card-title"><i class="fas fa-history" aria-hidden="true"></i> <?= __('audit_title') ?></span>
<span style="font-size:13px;color:var(--text-muted);"><?= number_format($total) ?> Einträge</span> <span style="font-size:13px;color:var(--text-muted);"><?= number_format($total) ?> Einträge</span>
</div> </div>
<?php if (empty($logs)): ?> <?php if (empty($logs)): ?>
<div class="empty-state"><i class="fas fa-history"></i><p><?= __('audit_none') ?></p></div> <div class="empty-state"><i class="fas fa-history" aria-hidden="true"></i><p><?= __('audit_none') ?></p></div>
<?php else: ?> <?php else: ?>
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<div class="table-container"> <div class="table-container">
<table class="table"> <table class="table table-stack">
<thead> <thead>
<tr> <tr>
<th><?= __('audit_time') ?></th> <th><?= __('audit_time') ?></th>
@ -136,34 +127,34 @@ $actionLabels = [
<tbody> <tbody>
<?php foreach ($logs as $log): ?> <?php foreach ($logs as $log): ?>
<tr> <tr>
<td style="white-space:nowrap;color:var(--text-muted);"> <td data-label="<?= __('audit_time') ?>" style="white-space:nowrap;color:var(--text-muted);">
<?= date('d.m.Y', strtotime($log['created_at'])) ?><br> <?= date('d.m.Y', strtotime($log['created_at'])) ?><br>
<small><?= date('H:i:s', strtotime($log['created_at'])) ?></small> <small><?= date('H:i:s', strtotime($log['created_at'])) ?></small>
</td> </td>
<td> <td data-label="<?= __('audit_action') ?>">
<span class="action-chip"> <span class="action-chip">
<?= htmlspecialchars($actionLabels[$log['action']] ?? $log['action']) ?> <?= htmlspecialchars($actionLabels[$log['action']] ?? $log['action']) ?>
</span> </span>
</td> </td>
<td> <td data-label="<?= __('audit_user') ?>">
<?php if ($log['user_name']): ?> <?php if ($log['user_name']): ?>
<strong style="font-size:13px;"><?= htmlspecialchars($log['user_name']) ?></strong><br> <strong style="font-size:13px;"><?= htmlspecialchars($log['user_name']) ?></strong><br>
<small style="color:var(--text-muted);"><?= htmlspecialchars($log['user_email'] ?? '') ?></small> <small style="color:var(--text-muted);"><?= htmlspecialchars($log['user_email'] ?? '') ?></small>
<?php else: ?> <?php else: ?>
<em style="color:var(--text-muted);">System/Anonym</em> <em style="color:var(--text-muted);"><?= __('audit_system_anon') ?></em>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="color:var(--text-secondary);"> <td data-label="<?= __('audit_entity') ?>" style="color:var(--text-secondary);">
<?php if ($log['entity_type']): ?> <?php if ($log['entity_type']): ?>
<code style="font-size:11px;"><?= htmlspecialchars($log['entity_type']) ?>:<?= htmlspecialchars($log['entity_id'] ?? '') ?></code> <code style="font-size:11px;"><?= htmlspecialchars($log['entity_type']) ?>:<?= htmlspecialchars($log['entity_id'] ?? '') ?></code>
<?php else: ?> <?php else: ?>
<span style="color:var(--text-muted);">-</span> <span style="color:var(--text-muted);">-</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="details-cell" title="<?= htmlspecialchars($log['details'] ?? '') ?>"> <td class="details-cell" data-label="<?= __('audit_details') ?>" title="<?= htmlspecialchars($log['details'] ?? '') ?>">
<?= htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, '…')) ?> <?= htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, '…')) ?>
</td> </td>
<td class="ip-cell"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td> <td class="ip-cell" data-label="<?= __('audit_ip') ?>"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
@ -173,18 +164,18 @@ $actionLabels = [
<?php if ($pages > 1): ?> <?php if ($pages > 1): ?>
<div class="pagination"> <div class="pagination">
<span class="page-info">Seite <?= $page ?> von <?= $pages ?> (<?= $total ?> Einträge)</span> <span class="page-info"><?= str_replace(['{page}', '{pages}', '{total}'], [(string)$page, (string)$pages, number_format((int)$total, 0, ',', '.')], __('audit_page_info')) ?></span>
<div class="page-btns"> <div class="page-btns">
<?php <?php
$baseUrl = '?' . http_build_query(array_filter(['action' => $filterAction, 'user_id' => $filterUser])); $baseUrl = '?' . http_build_query(array_filter(['action' => $filterAction, 'user_id' => $filterUser]));
if ($page > 1): ?> if ($page > 1): ?>
<a href="<?= $baseUrl ?>&page=<?= $page - 1 ?>" class="page-btn"><i class="fas fa-chevron-left"></i></a> <a href="<?= $baseUrl ?>&page=<?= $page - 1 ?>" class="page-btn"><i class="fas fa-chevron-left" aria-hidden="true"></i></a>
<?php endif; <?php endif;
for ($p = max(1, $page - 2); $p <= min($pages, $page + 2); $p++): ?> for ($p = max(1, $page - 2); $p <= min($pages, $page + 2); $p++): ?>
<a href="<?= $baseUrl ?>&page=<?= $p ?>" class="page-btn <?= $p === $page ? 'active' : '' ?>"><?= $p ?></a> <a href="<?= $baseUrl ?>&page=<?= $p ?>" class="page-btn <?= $p === $page ? 'active' : '' ?>"><?= $p ?></a>
<?php endfor; <?php endfor;
if ($page < $pages): ?> if ($page < $pages): ?>
<a href="<?= $baseUrl ?>&page=<?= $page + 1 ?>" class="page-btn"><i class="fas fa-chevron-right"></i></a> <a href="<?= $baseUrl ?>&page=<?= $page + 1 ?>" class="page-btn"><i class="fas fa-chevron-right" aria-hidden="true"></i></a>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>

View file

@ -43,12 +43,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf'); $error = __('error_csrf');
} elseif (empty($_FILES['backup']['tmp_name'])) { } elseif (empty($_FILES['backup']['tmp_name'])) {
$error = 'Bitte eine Backup-Datei auswählen.'; $error = __('backup_choose_file');
} else { } else {
$raw = file_get_contents($_FILES['backup']['tmp_name']); $raw = file_get_contents($_FILES['backup']['tmp_name']);
$data = json_decode($raw, true); $data = json_decode($raw, true);
if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') { if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') {
$error = 'Ungültige oder fremde Backup-Datei.'; $error = __('backup_invalid_file');
} else { } else {
$importSites = isset($_POST['import_sites']); $importSites = isset($_POST['import_sites']);
$importTemplates = isset($_POST['import_templates']); $importTemplates = isset($_POST['import_templates']);
@ -95,7 +95,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
} }
} }
$auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert'); $auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert');
$success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile."; $success = str_replace(['{settings}', '{sites}', '{templates}'],
[(string)$counts['settings'], (string)$counts['sites'], (string)$counts['templates']],
__('backup_imported'));
} catch (Exception $e) { } catch (Exception $e) {
$error = 'Import-Fehler: ' . $e->getMessage(); $error = 'Import-Fehler: ' . $e->getMessage();
} }
@ -112,12 +114,12 @@ $adminBase = '';
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backup & Restore <?= htmlspecialchars($appTitle) ?></title> <title><?= __('backup_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?> <?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">Backup &amp; Restore</h1> <h1 class="page-title"><?= __('backup_title') ?></h1>
<p class="page-subtitle">Konfiguration und Daten sichern oder wiederherstellen.</p> <p class="page-subtitle"><?= __('backup_subtitle') ?></p>
</div> </div>
</div> </div>
@ -125,21 +127,21 @@ $adminBase = '';
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?> <?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<div class="card"> <div class="card">
<h2>Export</h2> <h2><?= __('backup_export') ?></h2>
<p class="muted">Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem <code>APP_KEY</code> dieser Installation verschlüsselt ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.</p> <p class="muted"><?= __('backup_export_hint') ?> <code>APP_KEY</code> <?= __('backup_export_hint2') ?></p>
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>">Konfiguration exportieren</a> <a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>"><?= __('backup_export_btn') ?></a>
</div> </div>
<div class="card"> <div class="card">
<h2>Import / Restore</h2> <h2><?= __('backup_import') ?></h2>
<p class="muted">Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.</p> <p class="muted"><?= __('backup_import_hint') ?></p>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<input type="file" name="backup" accept="application/json,.json" required><br> <input type="file" name="backup" accept="application/json,.json" required><br>
<label class="chk"><input type="checkbox" name="import_settings" checked> Einstellungen</label> <label class="chk"><input type="checkbox" name="import_settings" checked> <?= __('backup_opt_settings') ?></label>
<label class="chk"><input type="checkbox" name="import_sites" checked> Sites</label> <label class="chk"><input type="checkbox" name="import_sites" checked> <?= __('backup_opt_sites') ?></label>
<label class="chk"><input type="checkbox" name="import_templates" checked> Voucher-Profile</label> <label class="chk"><input type="checkbox" name="import_templates" checked> <?= __('backup_opt_templates') ?></label>
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('Import jetzt durchführen?');">Importieren</button> <button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('<?= __('backup_import_confirm') ?>');"><?= __('import_submit') ?></button>
</form> </form>
</div> </div>

View file

@ -74,7 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['do_import'])) {
Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null); Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null);
$auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert"); $auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert");
} }
$success = "$created Voucher erstellt."; $success = str_replace('{count}', (string)$created, __('import_created'));
} catch (Exception $e) { } catch (Exception $e) {
$error = $e->getMessage(); $error = $e->getMessage();
} }
@ -95,8 +95,8 @@ $adminBase = '';
<?php require __DIR__ . '/../includes/admin_nav.php'; ?> <?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">Voucher-Import</h1> <h1 class="page-title"><?= __('import_title') ?></h1>
<p class="page-subtitle">Mehrere Vouchers auf einmal aus einer CSV-Liste erstellen.</p> <p class="page-subtitle"><?= __('import_subtitle') ?></p>
</div> </div>
</div> </div>
@ -104,27 +104,27 @@ $adminBase = '';
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?> <?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<div class="card"> <div class="card">
<h2>Mehrere Voucher erstellen</h2> <h2><?= __('import_card_title') ?></h2>
<p class="muted">Eine Zeile pro Voucher: <code>Name,MaxGeräte,Minuten</code> MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:<br> <p class="muted"><?= __('import_format_hint') ?> <code>Name,MaxGeräte,Minuten</code> <?= __('import_format_hint2') ?><br>
<code>Gast Müller,1,480</code> · <code>Konferenzraum A,5,240</code> · <code>Tagespass</code></p> <code>Gast Müller,1,480</code> · <code>Konferenzraum A,5,240</code> · <code>Tagespass</code></p>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<label>Standort</label> <label><?= __('import_site') ?></label>
<select class="input" name="site_id" required> <select class="input" name="site_id" required>
<?php foreach ($sites as $s): ?><option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?> <?php foreach ($sites as $s): ?><option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?>
</select> </select>
<label>CSV-Datei (optional)</label> <label><?= __('import_file') ?></label>
<input class="input" type="file" name="csv" accept=".csv,text/csv"> <input class="input" type="file" name="csv" accept=".csv,text/csv">
<label> oder direkt einfügen</label> <label><?= __('import_paste') ?></label>
<textarea name="csv_text" placeholder="Gast Müller,1,480&#10;Konferenzraum A,5,240"></textarea> <textarea name="csv_text" placeholder="Gast Müller,1,480&#10;Konferenzraum A,5,240"></textarea>
<button class="btn" type="submit" name="do_import" style="margin-top:14px;" onclick="return confirm('Import jetzt starten?');">Importieren</button> <button class="btn" type="submit" name="do_import" style="margin-top:14px;" onclick="return confirm('<?= __('import_confirm') ?>');"><?= __('import_submit') ?></button>
</form> </form>
</div> </div>
<?php if (!empty($results)): ?> <?php if (!empty($results)): ?>
<div class="card"> <div class="card">
<h2>Ergebnis</h2> <h2><?= __('import_result') ?></h2>
<table><tr><th>Name</th><th>Code / Fehler</th><th>Status</th></tr> <table><tr><th><?= __('label_name') ?></th><th><?= __('import_col_code') ?></th><th><?= __('label_status') ?></th></tr>
<?php foreach ($results as $r): ?> <?php foreach ($results as $r): ?>
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><code><?= htmlspecialchars($r['code']) ?></code></td><td><?= $r['ok'] ? '✅' : '❌' ?></td></tr> <tr><td><?= htmlspecialchars($r['name']) ?></td><td><code><?= htmlspecialchars($r['code']) ?></code></td><td><?= $r['ok'] ? '✅' : '❌' ?></td></tr>
<?php endforeach; ?> <?php endforeach; ?>

View file

@ -6,6 +6,7 @@ ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Ui.php';
require_once __DIR__ . '/../includes/UniFiController.php'; require_once __DIR__ . '/../includes/UniFiController.php';
require_once __DIR__ . '/../includes/I18n.php'; require_once __DIR__ . '/../includes/I18n.php';
@ -90,7 +91,7 @@ $currentPage = 'dashboard';
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('dashboard_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title><?= __('dashboard_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <div class="page-header">
@ -104,7 +105,7 @@ $currentPage = 'dashboard';
<span id="liveStatus">DB</span> <span id="liveStatus">DB</span>
</span> </span>
<button onclick="refreshData('live')" class="btn btn-secondary btn-sm" id="refreshBtn"> <button onclick="refreshData('live')" class="btn btn-secondary btn-sm" id="refreshBtn">
<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?> <i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>
</button> </button>
</div> </div>
</div> </div>
@ -113,21 +114,21 @@ $currentPage = 'dashboard';
<div class="stat-card"> <div class="stat-card">
<div class="stat-card-header"> <div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_active_sites') ?></div> <div class="stat-card-title"><?= __('dashboard_active_sites') ?></div>
<div class="stat-card-icon info"><i class="fas fa-location-dot"></i></div> <div class="stat-card-icon info"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
</div> </div>
<div class="stat-card-value"><?= $stats['total_sites'] ?></div> <div class="stat-card-value"><?= $stats['total_sites'] ?></div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-card-header"> <div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_users') ?></div> <div class="stat-card-title"><?= __('dashboard_users') ?></div>
<div class="stat-card-icon accent"><i class="fas fa-users"></i></div> <div class="stat-card-icon accent"><i class="fas fa-users" aria-hidden="true"></i></div>
</div> </div>
<div class="stat-card-value"><?= $stats['total_users'] ?></div> <div class="stat-card-value"><?= $stats['total_users'] ?></div>
</div> </div>
<div class="stat-card live"> <div class="stat-card live">
<div class="stat-card-header"> <div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_valid') ?></div> <div class="stat-card-title"><?= __('dashboard_valid') ?></div>
<div class="stat-card-icon success"><i class="fas fa-circle-check"></i></div> <div class="stat-card-icon success"><i class="fas fa-circle-check" aria-hidden="true"></i></div>
</div> </div>
<div class="stat-card-value valid" id="liveValid"><?= (int)($voucherStats['valid']??0) ?></div> <div class="stat-card-value valid" id="liveValid"><?= (int)($voucherStats['valid']??0) ?></div>
<div class="stat-card-sub" id="subValid"><?= $lastCronSync ? date('H:i', strtotime($lastCronSync)) : __('never') ?></div> <div class="stat-card-sub" id="subValid"><?= $lastCronSync ? date('H:i', strtotime($lastCronSync)) : __('never') ?></div>
@ -135,21 +136,21 @@ $currentPage = 'dashboard';
<div class="stat-card live"> <div class="stat-card live">
<div class="stat-card-header"> <div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_used') ?></div> <div class="stat-card-title"><?= __('dashboard_used') ?></div>
<div class="stat-card-icon warning"><i class="fas fa-user-check"></i></div> <div class="stat-card-icon warning"><i class="fas fa-user-check" aria-hidden="true"></i></div>
</div> </div>
<div class="stat-card-value used" id="liveUsed"><?= (int)($voucherStats['used']??0) ?></div> <div class="stat-card-value used" id="liveUsed"><?= (int)($voucherStats['used']??0) ?></div>
</div> </div>
<div class="stat-card live"> <div class="stat-card live">
<div class="stat-card-header"> <div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_expired') ?></div> <div class="stat-card-title"><?= __('dashboard_expired') ?></div>
<div class="stat-card-icon danger"><i class="fas fa-circle-xmark"></i></div> <div class="stat-card-icon danger"><i class="fas fa-circle-xmark" aria-hidden="true"></i></div>
</div> </div>
<div class="stat-card-value expired" id="liveExpired"><?= (int)($voucherStats['expired']??0) ?></div> <div class="stat-card-value expired" id="liveExpired"><?= (int)($voucherStats['expired']??0) ?></div>
</div> </div>
<div class="stat-card live"> <div class="stat-card live">
<div class="stat-card-header"> <div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_total') ?></div> <div class="stat-card-title"><?= __('dashboard_total') ?></div>
<div class="stat-card-icon"><i class="fas fa-ticket"></i></div> <div class="stat-card-icon"><i class="fas fa-ticket" aria-hidden="true"></i></div>
</div> </div>
<div class="stat-card-value" id="liveTotal"><?= (int)($voucherStats['total']??0) ?></div> <div class="stat-card-value" id="liveTotal"><?= (int)($voucherStats['total']??0) ?></div>
</div> </div>
@ -182,9 +183,9 @@ $currentPage = 'dashboard';
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($sites)): ?> <?php if (empty($sites)): ?>
<div class="empty-state" style="grid-column:1/-1;"> <div class="empty-state" style="grid-column:1/-1;">
<div class="empty-icon"><i class="fas fa-location-dot"></i></div> <div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
<p><?= __('dashboard_no_data') ?></p> <p><?= __('dashboard_no_data') ?></p>
<a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus"></i> <?= __('sites_add') ?></a> <a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?></a>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
@ -203,7 +204,7 @@ $currentPage = 'dashboard';
<div class="card-header"><h2 class="card-title"><?= __('dashboard_top_users') ?></h2></div> <div class="card-header"><h2 class="card-title"><?= __('dashboard_top_users') ?></h2></div>
<div class="card-body"> <div class="card-body">
<?php if (empty($topUsers)): ?> <?php if (empty($topUsers)): ?>
<div class="empty-state"><i class="fas fa-users"></i><p><?= __('dashboard_no_data') ?></p></div> <div class="empty-state"><i class="fas fa-users" aria-hidden="true"></i><p><?= __('dashboard_no_data') ?></p></div>
<?php else: ?> <?php else: ?>
<ul class="top-users-list"> <ul class="top-users-list">
<?php foreach ($topUsers as $u): ?> <?php foreach ($topUsers as $u): ?>
@ -224,21 +225,21 @@ $currentPage = 'dashboard';
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"><h2 class="card-title"><?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-arrow-up-right-from-square"></i> Live</a></div> <div class="card-header"><h2 class="card-title"><?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> Live</a></div>
<div class="card-body" style="padding:0;"> <div class="card-body" style="padding:0;">
<?php if (empty($recentVouchers)): ?> <?php if (empty($recentVouchers)): ?>
<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('dashboard_no_vouchers') ?></p></div> <div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= __('dashboard_no_vouchers') ?></p></div>
<?php else: ?> <?php else: ?>
<div class="table-container"> <div class="table-container">
<table class="table"> <table class="table table-stack">
<thead><tr><th><?= __('label_created') ?></th><th><?= __('label_code') ?></th><th><?= __('label_site') ?></th><th><?= __('label_status') ?></th></tr></thead> <thead><tr><th><?= __('label_created') ?></th><th><?= __('label_code') ?></th><th><?= __('label_site') ?></th><th><?= __('label_status') ?></th></tr></thead>
<tbody> <tbody>
<?php foreach ($recentVouchers as $v): ?> <?php foreach ($recentVouchers as $v): ?>
<tr> <tr>
<td style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td> <td data-label="<?= __('label_created') ?>" style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
<td><code><?= htmlspecialchars($v['voucher_code']) ?></code></td> <td data-label="<?= __('label_code') ?>"><code><?= htmlspecialchars($v['voucher_code']) ?></code></td>
<td><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td> <td data-label="<?= __('label_site') ?>"><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
<td> <td data-label="<?= __('label_status') ?>">
<?php $st=$v['status']??'valid'; ?> <?php $st=$v['status']??'valid'; ?>
<span class="badge badge-<?= $st==='valid'?'success':($st==='used'?'warning':'danger') ?>"><?= __('status_'.$st) ?></span> <span class="badge badge-<?= $st==='valid'?'success':($st==='used'?'warning':'danger') ?>"><?= __('status_'.$st) ?></span>
</td> </td>
@ -254,7 +255,7 @@ $currentPage = 'dashboard';
</main> </main>
<div id="toast-container"></div> <div id="toast-container" role="status" aria-live="polite"></div>
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
<script> <script>
const rootStyles = getComputedStyle(document.documentElement); const rootStyles = getComputedStyle(document.documentElement);
@ -301,7 +302,7 @@ async function refreshData(mode='db') {
liveBadge.className = 'live-badge loading'; liveBadge.className = 'live-badge loading';
liveStatus.textContent = '...'; liveStatus.textContent = '...';
refreshBtn.disabled = true; refreshBtn.disabled = true;
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>';
try { try {
const url = `index.php?ajax_stats=1${mode==='live'?'&sync=1':''}`; const url = `index.php?ajax_stats=1${mode==='live'?'&sync=1':''}`;
const result = await fetch(url).then(r=>r.json()); const result = await fetch(url).then(r=>r.json());
@ -339,7 +340,7 @@ async function refreshData(mode='db') {
} }
isLoading = false; isLoading = false;
refreshBtn.disabled = false; refreshBtn.disabled = false;
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>'; refreshBtn.innerHTML = '<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>';
} }
</script> </script>
</body> </body>

View file

@ -49,13 +49,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
$db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0))); $db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0)));
$db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30))); $db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30)));
$auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert'); $auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert');
$success = 'Einstellungen gespeichert.'; $success = __('settings_saved');
} }
} }
if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']); Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']);
$success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).'; $success = __('int_webhook_test_sent');
} }
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1'; $enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
@ -92,12 +92,12 @@ $adminBase = '';
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Integration & Wartung <?= htmlspecialchars($appTitle) ?></title> <title><?= __('int_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?> <?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">Integration &amp; Wartung</h1> <h1 class="page-title"><?= __('int_title') ?></h1>
<p class="page-subtitle">SSO, Webhooks, SMS-Versand und Aufbewahrungsfristen.</p> <p class="page-subtitle"><?= __('int_subtitle') ?></p>
</div> </div>
</div> </div>
@ -108,64 +108,64 @@ $adminBase = '';
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<div class="card"> <div class="card">
<h2>Sicherheitsrichtlinie</h2> <h2><?= __('int_security') ?></h2>
<p class="muted">Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.</p> <p class="muted"><?= __('int_security_hint') ?></p>
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label> <label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> <?= __('int_enforce_2fa') ?></label>
<label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</label> <label><?= __('int_daily_limit') ?></label>
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;"> <input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
<label>Session-Speicher</label> <label><?= __('int_session_driver') ?></label>
<select class="input" name="session_driver" style="max-width:340px;"> <select class="input" name="session_driver" style="max-width:340px;">
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>>PHP-Standard (Dateien)</option> <option value="php" <?= $sessionDriver==='php'?'selected':'' ?>><?= __('int_session_php') ?></option>
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>>Datenbank (ermöglicht „überall abmelden")</option> <option value="db" <?= $sessionDriver==='db'?'selected':'' ?>><?= __('int_session_db') ?></option>
</select> </select>
<label>Captcha im öffentlichen Modus</label> <label><?= __('int_captcha') ?></label>
<select class="input" name="captcha_mode" style="max-width:340px;"> <select class="input" name="captcha_mode" style="max-width:340px;">
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option> <option value="off" <?= $captchaMode==='off'?'selected':'' ?>><?= __('int_captcha_off') ?></option>
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>>Rechenaufgabe (ohne externen Dienst)</option> <option value="math" <?= $captchaMode==='math'?'selected':'' ?>><?= __('int_captcha_math') ?></option>
<option value="hcaptcha" <?= $captchaMode==='hcaptcha'?'selected':'' ?>>hCaptcha</option> <option value="hcaptcha" <?= $captchaMode==='hcaptcha'?'selected':'' ?>>hCaptcha</option>
</select> </select>
<div class="row3" style="margin-top:10px;"> <div class="row3" style="margin-top:10px;">
<div><label>hCaptcha Site-Key</label><input class="input" type="text" name="captcha_site_key" value="<?= htmlspecialchars($captchaSiteKey) ?>"></div> <div><label>hCaptcha Site-Key</label><input class="input" type="text" name="captcha_site_key" value="<?= htmlspecialchars($captchaSiteKey) ?>"></div>
<div><label>hCaptcha Secret<?= $captchaSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div> <div><label>hCaptcha Secret<?= $captchaSecretSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? __('int_secret_placeholder') : '' ?>"></div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2>Reverse-Proxy</h2> <h2><?= __('int_proxy') ?></h2>
<p class="muted">IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus <code>X-Forwarded-For</code> für Rate-Limit & Audit verwendet.</p> <p class="muted"><?= __('int_proxy_hint') ?> <code>X-Forwarded-For</code> <?= __('int_proxy_hint2') ?></p>
<input class="input" type="text" name="trusted_proxy" value="<?= htmlspecialchars($trustedProxy) ?>" placeholder="z.B. 10.0.0.1, 172.18.0.1"> <input class="input" type="text" name="trusted_proxy" value="<?= htmlspecialchars($trustedProxy) ?>" placeholder="z.B. 10.0.0.1, 172.18.0.1">
</div> </div>
<div class="card"> <div class="card">
<h2>Webhook-Benachrichtigungen</h2> <h2><?= __('int_webhook') ?></h2>
<p class="muted">Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.</p> <p class="muted"><?= __('int_webhook_hint') ?></p>
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> Webhook aktiv</label> <label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> <?= __('int_webhook_active') ?></label>
<label>Webhook-URL</label> <label><?= __('int_webhook_url') ?></label>
<input class="input" type="url" name="webhook_url" value="<?= htmlspecialchars($webhookUrl) ?>" placeholder="https://hooks.slack.com/services/…"> <input class="input" type="url" name="webhook_url" value="<?= htmlspecialchars($webhookUrl) ?>" placeholder="https://hooks.slack.com/services/…">
<div style="margin-top:12px;"> <div style="margin-top:12px;">
<a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>">Test senden</a> <a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>"><?= __('int_webhook_test') ?></a>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2>SMS-Versand (Twilio)</h2> <h2><?= __('int_sms') ?></h2>
<p class="muted">Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.</p> <p class="muted"><?= __('int_sms_hint') ?></p>
<label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> SMS-Versand aktiv</label> <label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> <?= __('int_sms_active') ?></label>
<div class="row3" style="margin-top:10px;"> <div class="row3" style="margin-top:10px;">
<div><label>Account SID</label><input class="input" type="text" name="twilio_sid" value="<?= htmlspecialchars($twilioSid) ?>"></div> <div><label>Account SID</label><input class="input" type="text" name="twilio_sid" value="<?= htmlspecialchars($twilioSid) ?>"></div>
<div><label>Auth Token<?= $twilioTokenSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? '••••••• (leer = unverändert)' : '' ?>"></div> <div><label>Auth Token<?= $twilioTokenSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? __('int_secret_placeholder') : '' ?>"></div>
<div><label>Absender (From)</label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div> <div><label><?= __('int_sms_from') ?></label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2>Single Sign-On (OpenID Connect)</h2> <h2><?= __('int_sso') ?></h2>
<p class="muted">Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI: <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p> <p class="muted"><?= __('int_sso_hint') ?> <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p>
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> OIDC-Login aktiv</label> <label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> <?= __('int_sso_active') ?></label>
<div class="row3" style="margin-top:10px;"> <div class="row3" style="margin-top:10px;">
<div><label>Button-Text</label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div> <div><label><?= __('int_sso_button') ?></label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div>
<div><label>Client ID</label><input class="input" type="text" name="oidc_client_id" value="<?= htmlspecialchars($oidcClientId) ?>"></div> <div><label>Client ID</label><input class="input" type="text" name="oidc_client_id" value="<?= htmlspecialchars($oidcClientId) ?>"></div>
<div><label>Client Secret<?= $oidcSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div> <div><label>Client Secret<?= $oidcSecretSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? __('int_secret_placeholder') : '' ?>"></div>
</div> </div>
<label>Authorization Endpoint</label><input class="input" type="url" name="oidc_auth_url" value="<?= htmlspecialchars($oidcAuthUrl) ?>" placeholder="https://idp/authorize"> <label>Authorization Endpoint</label><input class="input" type="url" name="oidc_auth_url" value="<?= htmlspecialchars($oidcAuthUrl) ?>" placeholder="https://idp/authorize">
<label>Token Endpoint</label><input class="input" type="url" name="oidc_token_url" value="<?= htmlspecialchars($oidcTokenUrl) ?>" placeholder="https://idp/token"> <label>Token Endpoint</label><input class="input" type="url" name="oidc_token_url" value="<?= htmlspecialchars($oidcTokenUrl) ?>" placeholder="https://idp/token">
@ -174,18 +174,18 @@ $adminBase = '';
</div> </div>
<div class="card"> <div class="card">
<h2>Datenhaltung & Cleanup (DSGVO)</h2> <h2><?= __('int_cleanup') ?></h2>
<p class="muted">Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per <code>cron_cleanup.php</code> (täglich empfohlen). <p class="muted"><?= __('int_cleanup_hint') ?> <code>cron_cleanup.php</code> <?= __('int_cleanup_hint2') ?>
<?php if ($lastCleanup): ?><br>Letzter Lauf: <?= htmlspecialchars($lastCleanup) ?><?php endif; ?> <?php if ($lastCleanup): ?><br><?= __('int_cleanup_last') ?> <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
</p> </p>
<div class="row"> <div class="row">
<div><label>Abgelaufene Voucher</label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div> <div><label><?= __('int_cleanup_expired') ?></label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
<div><label>Audit-Log</label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div> <div><label><?= __('int_cleanup_audit') ?></label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
<div><label>Login-Versuche</label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div> <div><label><?= __('int_cleanup_logins') ?></label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div>
</div> </div>
</div> </div>
<button class="btn btn-primary" type="submit" name="save">Speichern</button> <button class="btn btn-primary" type="submit" name="save"><?= __('btn_save') ?></button>
</form> </form>
</main> </main>

View file

@ -6,6 +6,7 @@ ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Ui.php';
require_once __DIR__ . '/../includes/I18n.php'; require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth(); $auth = new Auth();
@ -95,46 +96,46 @@ $adminBase = '';
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reporting <?= htmlspecialchars($appTitle) ?></title> <title><?= __('rep_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?> <?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">Reporting</h1> <h1 class="page-title"><?= __('rep_title') ?></h1>
<p class="page-subtitle">Auswertungen nach Zeitraum, Site und Benutzer.</p> <p class="page-subtitle"><?= __('rep_subtitle') ?></p>
</div> </div>
</div> </div>
<div class="toolbar no-print"> <div class="toolbar no-print">
<form method="get" style="display:flex;gap:8px;align-items:center;"> <form method="get" style="display:flex;gap:8px;align-items:center;">
<label style="margin:0;">Zeitraum</label> <label style="margin:0;"><?= __('rep_period') ?></label>
<select class="input" name="days" onchange="this.form.submit()"> <select class="input" name="days" onchange="this.form.submit()">
<?php foreach ([7,30,90,365] as $d): ?> <?php foreach ([7,30,90,365] as $d): ?>
<option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> Tage</option> <option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> <?= __('rep_days') ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</form> </form>
<a class="btn btn-secondary" href="?export=daily&days=<?= $days ?>"><i class="fas fa-download"></i> CSV (täglich)</a> <a class="btn btn-secondary" href="?export=daily&days=<?= $days ?>"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_daily') ?></a>
<a class="btn btn-secondary" href="?export=per_site"><i class="fas fa-download"></i> CSV (pro Site)</a> <a class="btn btn-secondary" href="?export=per_site"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_site') ?></a>
<a class="btn btn-secondary" href="?export=per_user"><i class="fas fa-download"></i> CSV (pro Nutzer)</a> <a class="btn btn-secondary" href="?export=per_user"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_user') ?></a>
<button class="btn btn-secondary" onclick="window.print()"><i class="fas fa-print"></i> Drucken/PDF</button> <button class="btn btn-secondary" onclick="window.print()"><i class="fas fa-print" aria-hidden="true"></i> <?= __('rep_print') ?></button>
</div> </div>
<div class="grid4"> <div class="grid4">
<div class="stat"><div class="n"><?= (int)$totals['total'] ?></div><div class="l">Vouchers gesamt</div></div> <div class="stat"><div class="n"><?= (int)$totals['total'] ?></div><div class="l"><?= __('rep_total') ?></div></div>
<div class="stat"><div class="n"><?= (int)$totals['valid'] ?></div><div class="l">Gültig</div></div> <div class="stat"><div class="n"><?= (int)$totals['valid'] ?></div><div class="l"><?= __('status_valid') ?></div></div>
<div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l">Verwendet</div></div> <div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l"><?= __('status_used') ?></div></div>
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l">In <?= $days ?> Tagen erstellt</div></div> <div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l"><?= str_replace('{days}', (string)$days, __('rep_in_period')) ?></div></div>
</div> </div>
<div class="card"> <div class="card">
<h2>Erstellte Voucher (<?= $days ?> Tage)</h2> <h2><?= str_replace('{days}', (string)$days, __('rep_chart_title')) ?></h2>
<canvas id="chart" height="90"></canvas> <canvas id="chart" height="90"></canvas>
</div> </div>
<div class="card"> <div class="card">
<h2>Pro Site</h2> <h2><?= __('rep_per_site') ?></h2>
<table><tr><th>Site</th><th>Gesamt</th><th>Gültig</th><th>Verwendet</th><th>Abgelaufen</th></tr> <table><tr><th><?= __('label_site') ?></th><th><?= __('label_total') ?></th><th><?= __('status_valid') ?></th><th><?= __('status_used') ?></th><th><?= __('status_expired') ?></th></tr>
<?php foreach ($perSite as $r): ?> <?php foreach ($perSite as $r): ?>
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><?= (int)$r['total'] ?></td><td><?= (int)$r['valid'] ?></td><td><?= (int)$r['used'] ?></td><td><?= (int)$r['expired'] ?></td></tr> <tr><td><?= htmlspecialchars($r['name']) ?></td><td><?= (int)$r['total'] ?></td><td><?= (int)$r['valid'] ?></td><td><?= (int)$r['used'] ?></td><td><?= (int)$r['expired'] ?></td></tr>
<?php endforeach; ?> <?php endforeach; ?>
@ -142,12 +143,12 @@ $adminBase = '';
</div> </div>
<div class="card"> <div class="card">
<h2>Top-Nutzer</h2> <h2><?= __('rep_top_users') ?></h2>
<table><tr><th>Benutzer</th><th>Voucher erstellt</th></tr> <table><tr><th><?= __('label_user') ?></th><th><?= __('rep_col_created') ?></th></tr>
<?php foreach ($perUser as $r): ?> <?php foreach ($perUser as $r): ?>
<tr><td><?= htmlspecialchars($r['name'] ?? '') ?></td><td><?= (int)$r['c'] ?></td></tr> <tr><td><?= htmlspecialchars($r['name'] ?? '') ?></td><td><?= (int)$r['c'] ?></td></tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);">Keine Daten</td></tr><?php endif; ?> <?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);"><?= __('rep_no_data') ?></td></tr><?php endif; ?>
</table> </table>
</div> </div>

View file

@ -6,6 +6,10 @@ ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Ui.php';
I18n::init();
$auth = new Auth(); $auth = new Auth();
$auth->requireLogin(); $auth->requireLogin();
@ -24,20 +28,20 @@ $setupRequired = isset($_GET['setup_required']);
// 2FA aktivieren (Code bestaetigen) // 2FA aktivieren (Code bestaetigen)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = 'Ungültiges Sicherheits-Token'; $error = __('sec_token_invalid');
} else { } else {
$secret = $_SESSION['totp_setup_secret'] ?? ''; $secret = $_SESSION['totp_setup_secret'] ?? '';
$code = trim($_POST['code'] ?? ''); $code = trim($_POST['code'] ?? '');
if ($secret === '') { if ($secret === '') {
$error = 'Setup abgelaufen, bitte erneut starten.'; $error = __('sec_setup_expired');
} elseif (!Totp::verify($secret, $code)) { } elseif (!Totp::verify($secret, $code)) {
$error = 'Code ungültig. Bitte erneut versuchen.'; $error = __('sec_code_invalid');
} else { } else {
$backupCodes = $auth->enableTotp($user['id'], $secret); $backupCodes = $auth->enableTotp($user['id'], $secret);
unset($_SESSION['totp_setup_secret']); unset($_SESSION['totp_setup_secret']);
$totpEnabled = true; $totpEnabled = true;
$user = $auth->getCurrentUser(); $user = $auth->getCurrentUser();
$success = 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!'; $success = __('sec_enabled');
} }
} }
} }
@ -45,32 +49,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
// Überall abmelden (andere Sessions beenden) // Überall abmelden (andere Sessions beenden)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = 'Ungültiges Sicherheits-Token'; $error = __('sec_token_invalid');
} else { } else {
$auth->logoutOtherSessions(); $auth->logoutOtherSessions();
$success = 'Alle anderen Sitzungen wurden beendet.'; $success = __('sec_sessions_closed');
} }
} }
// Recovery-Codes neu erzeugen // Recovery-Codes neu erzeugen
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = 'Ungültiges Sicherheits-Token'; $error = __('sec_token_invalid');
} elseif (!empty($user['totp_enabled'])) { } elseif (!empty($user['totp_enabled'])) {
$backupCodes = $auth->regenerateBackupCodes($user['id']); $backupCodes = $auth->regenerateBackupCodes($user['id']);
$user = $auth->getCurrentUser(); $user = $auth->getCurrentUser();
$success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.'; $success = __('sec_codes_new');
} }
} }
// 2FA deaktivieren // 2FA deaktivieren
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = 'Ungültiges Sicherheits-Token'; $error = __('sec_token_invalid');
} else { } else {
$auth->disableTotp($user['id']); $auth->disableTotp($user['id']);
$totpEnabled = false; $totpEnabled = false;
$success = 'Zwei-Faktor-Authentifizierung wurde deaktiviert.'; $success = __('sec_disabled');
} }
} }
@ -87,41 +91,36 @@ $dbSessions = $db->getSetting('session_driver', 'php') === 'db';
$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0; $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="<?= I18n::getLanguage() ?>">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zwei-Faktor-Authentifizierung <?= htmlspecialchars($appTitle) ?></title> <title><?= __('sec_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php if (!$totpEnabled && $hasPassword): ?> <?php if (!$totpEnabled && $hasPassword): ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
<?php endif; ?> <?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com"> <?= Ui::head($db, '../') ?>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="../assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
</head> </head>
<body class="app-body focus-page"> <body class="app-body focus-page">
<div class="focus-card card"> <div class="focus-card card">
<div class="focus-head"> <div class="focus-head">
<span class="focus-icon"><i class="fas fa-shield-halved"></i></span> <span class="focus-icon"><i class="fas fa-shield-halved" aria-hidden="true"></i></span>
<div> <div>
<h1>Zwei-Faktor-Authentifizierung</h1> <h1><?= __('sec_title') ?></h1>
<p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p> <p class="sub"><?= __('sec_account') ?> <?= htmlspecialchars($user['email']) ?></p>
</div> </div>
</div> </div>
<?php if ($setupRequired && !$totpEnabled): ?> <?php if ($setupRequired && !$totpEnabled): ?>
<div class="alert alert-error">Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.</div> <div class="alert alert-error"><?= __('sec_required_hint') ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?> <?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?> <?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<?php if (!empty($backupCodes)): ?> <?php if (!empty($backupCodes)): ?>
<div class="codes-box"> <div class="codes-box">
<strong><i class="fas fa-key"></i> Recovery-Codes</strong> <strong><i class="fas fa-key" aria-hidden="true"></i> <?= __('sec_recovery_codes') ?></strong>
<p>Bewahren Sie diese sicher auf. Jeder Code funktioniert <em>einmal</em>, falls Sie keinen Zugriff auf Ihre App haben.</p> <p><?= __('sec_recovery_hint') ?></p>
<div class="codes"> <div class="codes">
<?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?> <?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?>
</div> </div>
@ -129,34 +128,34 @@ $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
<?php endif; ?> <?php endif; ?>
<?php if (!$hasPassword): ?> <?php if (!$hasPassword): ?>
<div class="status off"><i class="fas fa-circle-minus"></i> Nicht verfügbar</div> <div class="status off"><i class="fas fa-circle-minus" aria-hidden="true"></i> <?= __('sec_unavailable') ?></div>
<p class="sub">Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.</p> <p class="sub"><?= __('sec_m365_hint') ?></p>
<?php elseif ($totpEnabled): ?> <?php elseif ($totpEnabled): ?>
<div class="status on"><i class="fas fa-circle-check"></i> Aktiv</div> <div class="status on"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('sec_active') ?></div>
<p class="sub">Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.<br> <p class="sub"><?= __('sec_active_hint') ?><br>
Verbleibende Recovery-Codes: <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p> <?= __('sec_codes_left') ?> <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
<form method="post" style="margin-bottom:10px;"> <form method="post" style="margin-bottom:10px;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<button type="submit" name="regen_codes" class="btn btn-secondary btn-lg btn-block">Recovery-Codes neu erzeugen</button> <button type="submit" name="regen_codes" class="btn btn-secondary btn-lg btn-block"><?= __('sec_regen_codes') ?></button>
</form> </form>
<form method="post" onsubmit="return confirm('2FA wirklich deaktivieren?');"> <form method="post" onsubmit="return confirm('<?= __('sec_disable_confirm') ?>');">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<button type="submit" name="disable_totp" class="btn btn-danger btn-lg btn-block">2FA deaktivieren</button> <button type="submit" name="disable_totp" class="btn btn-danger btn-lg btn-block"><?= __('sec_disable') ?></button>
</form> </form>
<?php else: ?> <?php else: ?>
<div class="status off"><i class="fas fa-circle-minus"></i> Inaktiv</div> <div class="status off"><i class="fas fa-circle-minus" aria-hidden="true"></i> <?= __('sec_inactive') ?></div>
<ol> <ol>
<li>Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator )</li> <li><?= __('sec_step_1') ?></li>
<li>QR-Code scannen <em>oder</em> Secret manuell eingeben</li> <li><?= __('sec_step_2') ?></li>
<li>Den angezeigten 6-stelligen Code unten eingeben</li> <li><?= __('sec_step_3') ?></li>
</ol> </ol>
<div class="qr"><div id="qrcode"></div></div> <div class="qr"><div id="qrcode"></div></div>
<div class="secret"><?= htmlspecialchars($setupSecret) ?></div> <div class="secret"><?= htmlspecialchars($setupSecret) ?></div>
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<label for="code">6-stelliger Code</label> <label for="code"><?= __('sec_code_label') ?></label>
<input type="text" id="code" name="code" class="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required placeholder="123456"> <input type="text" id="code" name="code" class="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required placeholder="123456">
<button type="submit" name="enable_totp" class="btn btn-primary btn-lg btn-block" style="margin-top:14px;">2FA aktivieren</button> <button type="submit" name="enable_totp" class="btn btn-primary btn-lg btn-block" style="margin-top:14px;"><?= __('sec_enable') ?></button>
</form> </form>
<script> <script>
new QRCode(document.getElementById('qrcode'), { new QRCode(document.getElementById('qrcode'), {
@ -169,14 +168,14 @@ $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
<?php if ($dbSessions): ?> <?php if ($dbSessions): ?>
<hr> <hr>
<p class="sub">Aktive Sitzungen: <strong><?= (int)$activeSessions ?></strong></p> <p class="sub"><?= __('sec_sessions') ?> <strong><?= (int)$activeSessions ?></strong></p>
<form method="post" onsubmit="return confirm('Alle anderen Sitzungen abmelden?');"> <form method="post" onsubmit="return confirm('<?= __('sec_logout_others_confirm') ?>');">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<button type="submit" name="logout_others" class="btn btn-secondary btn-lg btn-block">Auf allen anderen Geräten abmelden</button> <button type="submit" name="logout_others" class="btn btn-secondary btn-lg btn-block"><?= __('sec_logout_others') ?></button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<div class="auth-links"><a class="back-link" href="../index.php"><i class="fas fa-arrow-left"></i> Zurück</a></div> <div class="auth-links"><a class="back-link" href="../index.php"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('nav_back') ?></a></div>
</div> </div>
</body> </body>
</html> </html>

View file

@ -8,6 +8,8 @@ require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Mailer.php'; require_once __DIR__ . '/../includes/Mailer.php';
require_once __DIR__ . '/../includes/I18n.php'; require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Ui.php';
require_once __DIR__ . '/../includes/Upload.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
@ -40,6 +42,33 @@ if (isset($_POST['ajax_smtp_test'])) {
$error = ''; $error = '';
$success = ''; $success = '';
/**
* Liefert den neuen Wert eines Bildfeldes: Upload schlaegt URL, und ein
* gesetzter Entfernen-Schalter loescht die bisherige Datei.
*/
function resolveImageField(string $name, Database $db, string $kind): string
{
$current = (string)$db->getSetting($name, '');
$uploaded = Upload::store($_FILES[$name . '_file'] ?? [], $kind);
if ($uploaded !== '') {
Upload::delete($current);
return $uploaded;
}
if (!empty($_POST[$name . '_remove'])) {
Upload::delete($current);
return '';
}
$value = trim($_POST[$name] ?? '');
if ($value !== $current && Upload::isLocal($current) && !Upload::isLocal($value)) {
Upload::delete($current);
}
return $value;
}
// Einstellungen speichern // Einstellungen speichern
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
@ -51,22 +80,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if ($formType === 'general') { if ($formType === 'general') {
$settings['app_title'] = trim($_POST['app_title'] ?? ''); $settings['app_title'] = trim($_POST['app_title'] ?? '');
$settings['logo_url'] = trim($_POST['logo_url'] ?? ''); $settings['logo_url'] = resolveImageField('logo_url', $db, 'image');
$settings['favicon_url'] = trim($_POST['favicon_url'] ?? ''); $settings['favicon_url'] = resolveImageField('favicon_url', $db, 'favicon');
$settings['instruction_header'] = trim($_POST['instruction_header'] ?? ''); $settings['instruction_header'] = trim($_POST['instruction_header'] ?? '');
$settings['instruction_text'] = $_POST['instruction_text'] ?? ''; $settings['instruction_text'] = $_POST['instruction_text'] ?? '';
$settings['public_access'] = isset($_POST['public_access']) ? '1' : '0'; $settings['public_access'] = isset($_POST['public_access']) ? '1' : '0';
} }
if ($formType === 'branding') {
foreach (['brand_accent', 'brand_accent_dark', 'brand_gradient_from', 'brand_gradient_to'] as $key) {
$value = strtolower(trim($_POST[$key] ?? ''));
$settings[$key] = preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : '';
}
$radius = (int)($_POST['brand_radius'] ?? Ui::DEFAULT_RADIUS);
$settings['brand_radius'] = (string)max(0, min(28, $radius));
}
if ($formType === 'login') { if ($formType === 'login') {
$settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0'; $settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0';
$settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? ''); $settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? '');
$settings['login_logo_url'] = trim($_POST['login_logo_url'] ?? ''); $settings['login_logo_url'] = resolveImageField('login_logo_url', $db, 'image');
$settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? ''); $settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? '');
$settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? ''); $settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? '');
$settings['login_features'] = trim($_POST['login_features'] ?? ''); $settings['login_features'] = trim($_POST['login_features'] ?? '');
$settings['login_footer'] = trim($_POST['login_footer'] ?? ''); $settings['login_footer'] = trim($_POST['login_footer'] ?? '');
$settings['login_bg_image'] = trim($_POST['login_bg_image'] ?? ''); $settings['login_bg_image'] = resolveImageField('login_bg_image', $db, 'image');
$settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? ''); $settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? '');
$settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? ''); $settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? '');
$overlay = (int)($_POST['login_bg_overlay'] ?? 40); $overlay = (int)($_POST['login_bg_overlay'] ?? 40);
@ -113,7 +151,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
} }
if ($formType === 'system') { if ($formType === 'system') {
$settings['tinymce_api_key'] = trim($_POST['tinymce_api_key'] ?? '');
$settings['print_template'] = $_POST['print_template'] ?? ''; $settings['print_template'] = $_POST['print_template'] ?? '';
} }
@ -122,6 +159,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
} }
$success = __('settings_saved'); $success = __('settings_saved');
} catch (RuntimeException $e) {
$error = $e->getMessage();
} catch (Exception $e) { } catch (Exception $e) {
$error = 'Fehler: ' . $e->getMessage(); $error = 'Fehler: ' . $e->getMessage();
} }
@ -200,11 +239,15 @@ $cs = [
'smtp_from_name' => $db->getSetting('smtp_from_name', ''), 'smtp_from_name' => $db->getSetting('smtp_from_name', ''),
'system_url' => $db->getSetting('system_url', $autoDetectedUrl), 'system_url' => $db->getSetting('system_url', $autoDetectedUrl),
'email_voucher_subject' => $db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'), 'email_voucher_subject' => $db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'),
'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nIhr Code: {VOUCHER_CODE}\n\nGültigkeit: 8h\nGeräte: {MAX_USES}\nSite: {SITE_NAME}"), 'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\n<strong>Maximale Geräte:</strong> {MAX_USES}<br>\n<strong>Standort:</strong> {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}"),
'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'), 'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'),
'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"), 'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"),
'tinymce_api_key' => $db->getSetting('tinymce_api_key', ''), 'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()),
'print_template' => $db->getSetting('print_template', '<div style="text-align:center;padding:40px"><h1>{APP_TITLE}</h1><h2>WLAN Code</h2><div style="font-size:48px;font-weight:bold;margin:30px 0;font-family:monospace">{VOUCHER_CODE}</div><p><strong>Gültig bis:</strong> {EXPIRY_DATE} {EXPIRY_TIME}</p><p><strong>Site:</strong> {SITE_NAME}</p><p><strong>Geräte:</strong> {MAX_USES}</p><hr style="margin:30px 0"><div>{INSTRUCTIONS}</div></div>'), 'brand_accent' => $db->getSetting('brand_accent', '') ?: Ui::DEFAULT_ACCENT,
'brand_accent_dark' => $db->getSetting('brand_accent_dark', '') ?: Ui::DEFAULT_ACCENT_DARK,
'brand_gradient_from' => $db->getSetting('brand_gradient_from', '') ?: Ui::DEFAULT_GRADIENT_FROM,
'brand_gradient_to' => $db->getSetting('brand_gradient_to', '') ?: Ui::DEFAULT_GRADIENT_TO,
'brand_radius' => $db->getSetting('brand_radius', (string)Ui::DEFAULT_RADIUS),
'login_panel_enabled' => $db->getSetting('login_panel_enabled', '1'), 'login_panel_enabled' => $db->getSetting('login_panel_enabled', '1'),
'login_brand_name' => $db->getSetting('login_brand_name', ''), 'login_brand_name' => $db->getSetting('login_brand_name', ''),
'login_logo_url' => $db->getSetting('login_logo_url', ''), 'login_logo_url' => $db->getSetting('login_logo_url', ''),
@ -220,6 +263,41 @@ $cs = [
'last_cron_sync' => $db->getSetting('last_cron_sync', ''), 'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
]; ];
/**
* Bildfeld: Vorschau, Upload, alternativ URL plus Entfernen-Schalter.
*/
function imageField(string $name, string $label, ?string $value, string $hint = '', string $accept = 'image/*'): void
{
$value = (string)$value;
$preview = Ui::mediaUrl($value, '../');
?>
<div class="form-group">
<label><?= htmlspecialchars($label) ?></label>
<div class="image-field">
<div class="image-preview">
<?php if ($preview !== ''): ?>
<img src="<?= htmlspecialchars($preview) ?>" alt="">
<?php else: ?>
<i class="fas fa-image" aria-hidden="true"></i>
<?php endif; ?>
</div>
<div class="image-field-controls">
<input type="file" name="<?= $name ?>_file" accept="<?= htmlspecialchars($accept) ?>">
<input type="text" name="<?= $name ?>" value="<?= htmlspecialchars($value) ?>"
placeholder="<?= htmlspecialchars(__('settings_image_url_placeholder')) ?>">
<?php if ($value !== ''): ?>
<label class="chk"><input type="checkbox" name="<?= $name ?>_remove" value="1"> <?= __('settings_image_remove') ?></label>
<?php endif; ?>
</div>
</div>
<?php if ($hint !== ''): ?><div class="help-text"><?= htmlspecialchars($hint) ?></div><?php endif; ?>
</div>
<?php
}
// Aktiver Tab per ?tab=… (Deep-Link, funktioniert auch ohne JavaScript)
$activeTab = preg_replace('/[^a-z_]/', '', (string)($_GET['tab'] ?? 'general')) ?: 'general';
$currentPage = 'settings'; $currentPage = 'settings';
$adminBase = ''; $adminBase = '';
?> ?>
@ -230,13 +308,9 @@ $adminBase = '';
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?></title> <title><?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
<?php if (!empty($cs['tinymce_api_key'])): ?> <!-- TinyMCE wird lokal ausgeliefert (GPL-Variante) keine externen Aufrufe. -->
<script src="https://cdn.tiny.cloud/1/<?= htmlspecialchars($cs['tinymce_api_key']) ?>/tinymce/6/tinymce.min.js"></script> <?= Ui::script('assets/vendor/tinymce/tinymce.min.js', '../') ?>
<?php else: ?> <script>window.TINYMCE_BASE_URL = '../assets/vendor/tinymce';</script>
<!-- Ohne API-Key die GPL-Variante von cdnjs laden: gleiche Funktionen, kein Hinweis-Banner. -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.3/tinymce.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>window.TINYMCE_BASE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.3';</script>
<?php endif; ?>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
@ -248,48 +322,49 @@ $adminBase = '';
</div> </div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i><span><?= htmlspecialchars($error) ?></span></div> <div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i><span><?= htmlspecialchars($error) ?></span></div>
<?php endif; ?> <?php endif; ?>
<?php if ($success): ?> <?php if ($success): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i><span><?= htmlspecialchars($success) ?></span></div> <div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i><span><?= htmlspecialchars($success) ?></span></div>
<?php endif; ?> <?php endif; ?>
<div class="tab-container"> <div class="tab-container">
<div class="tab-navigation" id="tabNav"> <div class="tab-navigation" id="tabNav">
<button class="tab-button active" data-tab="general"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></button> <button class="tab-button<?= $activeTab === 'general' ? ' active' : '' ?>" data-tab="general"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></button>
<button class="tab-button" data-tab="defaults"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></button> <button class="tab-button<?= $activeTab === 'defaults' ? ' active' : '' ?>" data-tab="defaults"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></button>
<button class="tab-button" data-tab="login"><i class="fas fa-right-to-bracket"></i> <?= __('settings_tab_login') ?></button> <button class="tab-button<?= $activeTab === 'branding' ? ' active' : '' ?>" data-tab="branding"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></button>
<button class="tab-button" data-tab="cron"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></button> <button class="tab-button<?= $activeTab === 'login' ? ' active' : '' ?>" data-tab="login"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></button>
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft"></i> <?= __('settings_tab_m365') ?></button> <button class="tab-button<?= $activeTab === 'cron' ? ' active' : '' ?>" data-tab="cron"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></button>
<button class="tab-button" data-tab="smtp"><i class="fas fa-envelope"></i> <?= __('settings_tab_smtp') ?></button> <button class="tab-button" data-tab="m365"><i class="fab fa-microsoft" aria-hidden="true"></i> <?= __('settings_tab_m365') ?></button>
<button class="tab-button" data-tab="templates_email"><i class="fas fa-file-alt"></i> <?= __('settings_tab_templates_email') ?></button> <button class="tab-button<?= $activeTab === 'smtp' ? ' active' : '' ?>" data-tab="smtp"><i class="fas fa-envelope" aria-hidden="true"></i> <?= __('settings_tab_smtp') ?></button>
<button class="tab-button" data-tab="system"><i class="fas fa-cogs"></i> <?= __('settings_tab_system') ?></button> <button class="tab-button<?= $activeTab === 'templates_email' ? ' active' : '' ?>" data-tab="templates_email"><i class="fas fa-file-alt" aria-hidden="true"></i> <?= __('settings_tab_templates_email') ?></button>
<button class="tab-button" data-tab="password"><i class="fas fa-key"></i> <?= __('settings_tab_password') ?></button> <button class="tab-button<?= $activeTab === 'system' ? ' active' : '' ?>" data-tab="system"><i class="fas fa-cogs" aria-hidden="true"></i> <?= __('settings_tab_system') ?></button>
<button class="tab-button<?= $activeTab === 'password' ? ' active' : '' ?>" data-tab="password"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button>
</div> </div>
<!-- Allgemein --> <!-- Allgemein -->
<div id="tab-general" class="tab-content active"> <div id="tab-general" class="tab-content<?= $activeTab === 'general' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></h2>
<form method="post"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="general"> <input type="hidden" name="form_type" value="general">
<div class="form-group"><label><?= __('settings_app_title') ?></label><input type="text" name="app_title" value="<?= htmlspecialchars($cs['app_title']) ?>" required></div> <div class="form-group"><label><?= __('settings_app_title') ?></label><input type="text" name="app_title" value="<?= htmlspecialchars($cs['app_title']) ?>" required></div>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"><label><?= __('settings_logo_url') ?></label><input type="url" name="logo_url" value="<?= htmlspecialchars($cs['logo_url']) ?>" placeholder="https://example.com/logo.png"></div> <?php imageField('logo_url', __('settings_logo_url'), $cs['logo_url'], __('settings_upload_hint')); ?>
<div class="form-group"><label><?= __('settings_favicon_url') ?></label><input type="url" name="favicon_url" value="<?= htmlspecialchars($cs['favicon_url']) ?>" placeholder="https://example.com/favicon.ico"><div class="help-text"><?= __('settings_favicon_hint') ?></div></div> <?php imageField('favicon_url', __('settings_favicon_url'), $cs['favicon_url'], __('settings_favicon_hint'), 'image/x-icon,image/png,image/svg+xml'); ?>
</div> </div>
<hr class="section-divider"> <hr class="section-divider">
<div class="form-group"><label><?= __('settings_instr_header') ?></label><input type="text" name="instruction_header" value="<?= htmlspecialchars($cs['instruction_header']) ?>"></div> <div class="form-group"><label><?= __('settings_instr_header') ?></label><input type="text" name="instruction_header" value="<?= htmlspecialchars($cs['instruction_header']) ?>"></div>
<div class="form-group"><label><?= __('settings_instr_text') ?></label><textarea name="instruction_text" class="tinymce-editor"><?= htmlspecialchars($cs['instruction_text']) ?></textarea></div> <div class="form-group"><label><?= __('settings_instr_text') ?></label><textarea name="instruction_text" class="tinymce-editor"><?= htmlspecialchars($cs['instruction_text']) ?></textarea></div>
<div class="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="public_access" id="public_access" <?= $cs['public_access'] == '1' ? 'checked' : '' ?>><label for="public_access" style="margin:0;"><?= __('settings_public_access') ?></label></div> <div class="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="public_access" id="public_access" <?= $cs['public_access'] == '1' ? 'checked' : '' ?>><label for="public_access" style="margin:0;"><?= __('settings_public_access') ?></label></div>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
</form> </form>
</div> </div>
<!-- Voucher-Standards --> <!-- Voucher-Standards -->
<div id="tab-defaults" class="tab-content"> <div id="tab-defaults" class="tab-content<?= $activeTab === 'defaults' ? ' active' : '' ?>">
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></h2> <h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></h2>
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;">Diese Werte werden als Vorgabe im Voucher-Formular verwendet.</p> <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_defaults_hint') ?></p>
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="defaults"> <input type="hidden" name="form_type" value="defaults">
@ -311,22 +386,91 @@ $adminBase = '';
</div> </div>
</div> </div>
<div class="info-box" style="margin-top: 10px;"> <div class="info-box" style="margin-top: 10px;">
<h4><i class="fas fa-info-circle"></i> Gültigkeits-Referenz</h4> <h4><i class="fas fa-info-circle" aria-hidden="true"></i> Gültigkeits-Referenz</h4>
<p> <p>
60 Min = 1 Stunde &nbsp;|&nbsp; 480 Min = 8 Stunden &nbsp;|&nbsp; 60 Min = 1 Stunde &nbsp;|&nbsp; 480 Min = 8 Stunden &nbsp;|&nbsp;
1440 Min = 1 Tag &nbsp;|&nbsp; 10080 Min = 1 Woche &nbsp;|&nbsp; 1440 Min = 1 Tag &nbsp;|&nbsp; 10080 Min = 1 Woche &nbsp;|&nbsp;
43200 Min = 30 Tage 43200 Min = 30 Tage
</p> </p>
</div> </div>
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top: 16px;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary" style="margin-top: 16px;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
</form>
</div>
<!-- Design & Branding -->
<div id="tab-branding" class="tab-content<?= $activeTab === 'branding' ? ' active' : '' ?>">
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></h2>
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_branding_intro') ?></p>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="branding">
<div class="form-grid">
<div class="form-group">
<label><?= __('settings_brand_accent') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="brand_accent" value="<?= htmlspecialchars($cs['brand_accent']) ?>">
<input type="text" id="brand_accent" name="brand_accent" value="<?= htmlspecialchars($cs['brand_accent']) ?>" placeholder="<?= Ui::DEFAULT_ACCENT ?>">
</div>
<div class="help-text"><?= __('settings_brand_accent_hint') ?></div>
</div>
<div class="form-group">
<label><?= __('settings_brand_accent_dark') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="brand_accent_dark" value="<?= htmlspecialchars($cs['brand_accent_dark']) ?>">
<input type="text" id="brand_accent_dark" name="brand_accent_dark" value="<?= htmlspecialchars($cs['brand_accent_dark']) ?>" placeholder="<?= Ui::DEFAULT_ACCENT_DARK ?>">
</div>
<div class="help-text"><?= __('settings_brand_accent_dark_hint') ?></div>
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label><?= __('settings_brand_gradient_from') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="brand_gradient_from" value="<?= htmlspecialchars($cs['brand_gradient_from']) ?>">
<input type="text" id="brand_gradient_from" name="brand_gradient_from" value="<?= htmlspecialchars($cs['brand_gradient_from']) ?>">
</div>
</div>
<div class="form-group">
<label><?= __('settings_brand_gradient_to') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="brand_gradient_to" value="<?= htmlspecialchars($cs['brand_gradient_to']) ?>">
<input type="text" id="brand_gradient_to" name="brand_gradient_to" value="<?= htmlspecialchars($cs['brand_gradient_to']) ?>">
</div>
<div class="help-text"><?= __('settings_brand_gradient_hint') ?></div>
</div>
<div class="form-group">
<label><?= __('settings_brand_radius') ?></label>
<select id="brand_radius" name="brand_radius">
<?php foreach ([6 => __('settings_brand_radius_sharp'), 14 => __('settings_brand_radius_default'), 20 => __('settings_brand_radius_round')] as $value => $label): ?>
<option value="<?= $value ?>" <?= (int)$cs['brand_radius'] === $value ? 'selected' : '' ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="card" id="brandPreview" style="margin-top: 8px;">
<h3><?= __('settings_brand_preview') ?></h3>
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:14px;">
<span class="brand-mark" style="width:38px;height:38px;"><i class="fas fa-wifi" aria-hidden="true"></i></span>
<button type="button" class="btn btn-primary"><i class="fas fa-ticket" aria-hidden="true"></i> <?= __('voucher_create_btn') ?></button>
<button type="button" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
<span class="badge badge-success"><?= __('status_valid') ?></span>
<span class="chip"><?= __('nav_vouchers') ?></span>
<a href="#" onclick="return false;"><?= __('settings_brand_link') ?></a>
</div>
</div>
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top:16px;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
</form> </form>
</div> </div>
<!-- Login-Seite --> <!-- Login-Seite -->
<div id="tab-login" class="tab-content"> <div id="tab-login" class="tab-content<?= $activeTab === 'login' ? ' active' : '' ?>">
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-right-to-bracket"></i> <?= __('settings_tab_login') ?></h2> <h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></h2>
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_login_intro') ?></p> <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_login_intro') ?></p>
<form method="post"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="login"> <input type="hidden" name="form_type" value="login">
@ -341,11 +485,7 @@ $adminBase = '';
<input type="text" name="login_brand_name" value="<?= htmlspecialchars($cs['login_brand_name']) ?>" placeholder="<?= htmlspecialchars($cs['app_title']) ?>"> <input type="text" name="login_brand_name" value="<?= htmlspecialchars($cs['login_brand_name']) ?>" placeholder="<?= htmlspecialchars($cs['app_title']) ?>">
<div class="help-text"><?= __('settings_login_brand_hint') ?></div> <div class="help-text"><?= __('settings_login_brand_hint') ?></div>
</div> </div>
<div class="form-group"> <?php imageField('login_logo_url', __('settings_login_logo'), $cs['login_logo_url'], __('settings_login_logo_hint')); ?>
<label><?= __('settings_login_logo') ?></label>
<input type="url" name="login_logo_url" value="<?= htmlspecialchars($cs['login_logo_url']) ?>" placeholder="https://example.com/logo.svg">
<div class="help-text"><?= __('settings_login_logo_hint') ?></div>
</div>
</div> </div>
<hr class="section-divider"> <hr class="section-divider">
@ -370,11 +510,7 @@ $adminBase = '';
<hr class="section-divider"> <hr class="section-divider">
<div class="form-group"> <?php imageField('login_bg_image', __('settings_login_bg_image'), $cs['login_bg_image'], __('settings_login_bg_image_hint')); ?>
<label><?= __('settings_login_bg_image') ?></label>
<input type="url" name="login_bg_image" value="<?= htmlspecialchars($cs['login_bg_image']) ?>" placeholder="https://example.com/empfang.jpg">
<div class="help-text"><?= __('settings_login_bg_image_hint') ?></div>
</div>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label><?= __('settings_login_bg_from') ?></label> <label><?= __('settings_login_bg_from') ?></label>
@ -400,34 +536,34 @@ $adminBase = '';
</div> </div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:16px;"> <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:16px;">
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
<a href="../login.php?preview=1" target="_blank" rel="noopener" class="btn btn-secondary"> <a href="../login.php?preview=1" target="_blank" rel="noopener" class="btn btn-secondary">
<i class="fas fa-arrow-up-right-from-square"></i> <?= __('settings_login_preview') ?> <i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> <?= __('settings_login_preview') ?>
</a> </a>
</div> </div>
</form> </form>
</div> </div>
<!-- Cron-Sync --> <!-- Cron-Sync -->
<div id="tab-cron" class="tab-content"> <div id="tab-cron" class="tab-content<?= $activeTab === 'cron' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></h2>
<div class="info-box"> <div class="info-box">
<h4><i class="fas fa-info-circle"></i> Was macht der Cron-Job?</h4> <h4><i class="fas fa-info-circle" aria-hidden="true"></i> <?= __('settings_cron_what') ?></h4>
<p>Der Cron-Job synchronisiert automatisch alle Voucher von Ihren UniFi Controllern in die lokale Datenbank.</p> <p>Der Cron-Job synchronisiert automatisch alle Voucher von Ihren UniFi Controllern in die lokale Datenbank.</p>
</div> </div>
<hr class="section-divider"> <hr class="section-divider">
<?php if (empty($cs['cron_token'])): ?> <?php if (empty($cs['cron_token'])): ?>
<p style="color: var(--text-muted); margin-bottom: 15px;"><i class="fas fa-exclamation-triangle" style="color: var(--warning);"></i> Kein Token konfiguriert.</p> <p style="color: var(--text-muted); margin-bottom: 15px;"><i class="fas fa-exclamation-triangle" style="color: var(--warning);" aria-hidden="true"></i> Kein Token konfiguriert.</p>
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<button type="submit" name="generate_cron_token" class="btn btn-primary"><i class="fas fa-key"></i> Token generieren</button> <button type="submit" name="generate_cron_token" class="btn btn-primary"><i class="fas fa-key" aria-hidden="true"></i> Token generieren</button>
</form> </form>
<?php else: ?> <?php else: ?>
<div class="token-box" style="margin-bottom: 15px;"><?= htmlspecialchars($cs['cron_token']) ?></div> <div class="token-box" style="margin-bottom: 15px;"><?= htmlspecialchars($cs['cron_token']) ?></div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;"> <div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
<button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy"></i> Kopieren</button> <button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy" aria-hidden="true"></i> Kopieren</button>
<form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync"></i> Neu generieren</button></form> <form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync" aria-hidden="true"></i> Neu generieren</button></form>
<form method="post" style="display:inline;" onsubmit="return confirm('Token wirklich löschen?');"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="delete_cron_token" class="btn btn-secondary" style="color: var(--danger);"><i class="fas fa-trash"></i> Löschen</button></form> <form method="post" style="display:inline;" onsubmit="return confirm('<?= __('js_confirm_delete_token') ?>');"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="delete_cron_token" class="btn btn-secondary" style="color: var(--danger);"><i class="fas fa-trash" aria-hidden="true"></i> Löschen</button></form>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php <?php
@ -437,7 +573,7 @@ $adminBase = '';
<label>Cron-URL</label> <label>Cron-URL</label>
<div style="display:flex;gap:10px;"> <div style="display:flex;gap:10px;">
<input type="text" id="cronUrl" value="<?= htmlspecialchars($cronUrl) ?>" readonly> <input type="text" id="cronUrl" value="<?= htmlspecialchars($cronUrl) ?>" readonly>
<button onclick="copyToClipboard(document.getElementById('cronUrl').value)" class="btn btn-secondary"><i class="fas fa-copy"></i></button> <button onclick="copyToClipboard(document.getElementById('cronUrl').value)" class="btn btn-secondary"><i class="fas fa-copy" aria-hidden="true"></i></button>
</div> </div>
</div> </div>
<div class="placeholder-info" style="background: var(--bg-hover); border-color: var(--border-color);"> <div class="placeholder-info" style="background: var(--bg-hover); border-color: var(--border-color);">
@ -448,7 +584,7 @@ $adminBase = '';
</div> </div>
<?php if ($cs['cron_token']): ?> <?php if ($cs['cron_token']): ?>
<div style="margin-top: 20px;"> <div style="margin-top: 20px;">
<button onclick="testCronJob()" class="btn btn-primary" id="testCronBtn"><i class="fas fa-play"></i> Jetzt ausführen</button> <button onclick="testCronJob()" class="btn btn-primary" id="testCronBtn"><i class="fas fa-play" aria-hidden="true"></i> Jetzt ausführen</button>
<span id="testCronResult" style="margin-left: 15px;"></span> <span id="testCronResult" style="margin-left: 15px;"></span>
</div> </div>
<?php endif; ?> <?php endif; ?>
@ -457,9 +593,9 @@ $adminBase = '';
<!-- M365 --> <!-- M365 -->
<div id="tab-m365" class="tab-content"> <div id="tab-m365" class="tab-content">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fab fa-microsoft"></i> Microsoft 365</h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fab fa-microsoft" aria-hidden="true"></i> Microsoft 365</h2>
<div class="info-box"> <div class="info-box">
<h4><i class="fas fa-info-circle"></i> Azure AD App</h4> <h4><i class="fas fa-info-circle" aria-hidden="true"></i> Azure AD App</h4>
<p>Redirect URI: <strong><?= $protocol . '://' . $host . $scriptPath ?>/m365_callback.php</strong></p> <p>Redirect URI: <strong><?= $protocol . '://' . $host . $scriptPath ?>/m365_callback.php</strong></p>
</div> </div>
<form method="post"> <form method="post">
@ -468,13 +604,13 @@ $adminBase = '';
<div class="form-group"><label>Client ID</label><input type="text" name="m365_client_id" value="<?= htmlspecialchars($cs['m365_client_id']) ?>"></div> <div class="form-group"><label>Client ID</label><input type="text" name="m365_client_id" value="<?= htmlspecialchars($cs['m365_client_id']) ?>"></div>
<div class="form-group"><label>Client Secret</label><input type="password" name="m365_client_secret" value="<?= htmlspecialchars($cs['m365_client_secret']) ?>"></div> <div class="form-group"><label>Client Secret</label><input type="password" name="m365_client_secret" value="<?= htmlspecialchars($cs['m365_client_secret']) ?>"></div>
<div class="form-group"><label>Tenant ID</label><input type="text" name="m365_tenant_id" value="<?= htmlspecialchars($cs['m365_tenant_id']) ?>"></div> <div class="form-group"><label>Tenant ID</label><input type="text" name="m365_tenant_id" value="<?= htmlspecialchars($cs['m365_tenant_id']) ?>"></div>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
</form> </form>
</div> </div>
<!-- SMTP --> <!-- SMTP -->
<div id="tab-smtp" class="tab-content"> <div id="tab-smtp" class="tab-content<?= $activeTab === 'smtp' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope"></i> SMTP</h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope" aria-hidden="true"></i> SMTP</h2>
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="smtp"> <input type="hidden" name="form_type" value="smtp">
@ -486,60 +622,60 @@ $adminBase = '';
<div class="form-group"><label>Verschlüsselung</label><select name="smtp_encryption"><option value="tls" <?= $cs['smtp_encryption']==='tls'?'selected':'' ?>>TLS</option><option value="ssl" <?= $cs['smtp_encryption']==='ssl'?'selected':'' ?>>SSL</option><option value="none" <?= $cs['smtp_encryption']==='none'?'selected':'' ?>>Keine</option></select></div> <div class="form-group"><label>Verschlüsselung</label><select name="smtp_encryption"><option value="tls" <?= $cs['smtp_encryption']==='tls'?'selected':'' ?>>TLS</option><option value="ssl" <?= $cs['smtp_encryption']==='ssl'?'selected':'' ?>>SSL</option><option value="none" <?= $cs['smtp_encryption']==='none'?'selected':'' ?>>Keine</option></select></div>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"><label>Benutzername</label><input type="text" name="smtp_username" value="<?= htmlspecialchars($cs['smtp_username']) ?>"></div> <div class="form-group"><label>Benutzername</label><input type="text" name="smtp_username" value="<?= htmlspecialchars($cs['smtp_username']) ?>"></div>
<div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="Leer = nicht ändern"></div> <div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="<?= __('settings_leave_empty') ?>"></div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"><label>Absender E-Mail</label><input type="email" name="smtp_from_email" value="<?= htmlspecialchars($cs['smtp_from_email']) ?>"></div> <div class="form-group"><label>Absender E-Mail</label><input type="email" name="smtp_from_email" value="<?= htmlspecialchars($cs['smtp_from_email']) ?>"></div>
<div class="form-group"><label>Absender Name</label><input type="text" name="smtp_from_name" value="<?= htmlspecialchars($cs['smtp_from_name']) ?>"></div> <div class="form-group"><label><?= __('settings_smtp_from_name') ?></label><input type="text" name="smtp_from_name" value="<?= htmlspecialchars($cs['smtp_from_name']) ?>"></div>
</div> </div>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
</form> </form>
<hr class="section-divider"> <hr class="section-divider">
<h3 style="margin-bottom:15px; color: var(--text-primary);">SMTP testen</h3> <h3 style="margin-bottom:15px; color: var(--text-primary);">SMTP testen</h3>
<div style="display:flex;gap:10px;align-items:flex-end;"> <div style="display:flex;gap:10px;align-items:flex-end;">
<div style="flex:1;"><label>Test-E-Mail senden an</label><input type="email" id="smtpTestEmail" placeholder="empfaenger@example.com" style="margin-top:6px;"></div> <div style="flex:1;"><label>Test-E-Mail senden an</label><input type="email" id="smtpTestEmail" placeholder="empfaenger@example.com" style="margin-top:6px;"></div>
<button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn"><i class="fas fa-paper-plane"></i> <?= __('btn_test') ?></button> <button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn"><i class="fas fa-paper-plane" aria-hidden="true"></i> <?= __('btn_test') ?></button>
</div> </div>
<span id="smtpTestResult" style="display:block;margin-top:10px;font-size:13px;"></span> <span id="smtpTestResult" style="display:block;margin-top:10px;font-size:13px;"></span>
</div> </div>
<!-- E-Mail Templates --> <!-- E-Mail Templates -->
<div id="tab-templates_email" class="tab-content"> <div id="tab-templates_email" class="tab-content<?= $activeTab === 'templates_email' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt"></i> E-Mail Templates</h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt" aria-hidden="true"></i> E-Mail Templates</h2>
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="templates"> <input type="hidden" name="form_type" value="templates">
<div class="form-group"><label>System-URL</label><input type="url" name="system_url" value="<?= htmlspecialchars($cs['system_url']) ?>"><div class="help-text">Auto: <code><?= $autoDetectedUrl ?></code></div></div> <div class="form-group"><label>System-URL</label><input type="url" name="system_url" value="<?= htmlspecialchars($cs['system_url']) ?>"><div class="help-text">Auto: <code><?= $autoDetectedUrl ?></code></div></div>
<hr class="section-divider"> <hr class="section-divider">
<h3 style="margin-bottom:15px; color: var(--text-primary);">Voucher E-Mail</h3> <h3 style="margin-bottom:15px; color: var(--text-primary);"><?= __('settings_tpl_voucher_mail') ?></h3>
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{VOUCHER_CODE}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div></div> <div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{VOUCHER_CARD}</code><code>{VOUCHER_CODE}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div><p class="help-text"><?= __('settings_card_hint') ?></p></div>
<div class="form-group"><label>Betreff</label><input type="text" name="email_voucher_subject" value="<?= htmlspecialchars($cs['email_voucher_subject']) ?>"></div> <div class="form-group"><label>Betreff</label><input type="text" name="email_voucher_subject" value="<?= htmlspecialchars($cs['email_voucher_subject']) ?>"></div>
<div class="form-group"><label>E-Mail Text</label><textarea name="email_voucher_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_voucher_body']) ?></textarea></div> <div class="form-group"><label>E-Mail Text</label><textarea name="email_voucher_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_voucher_body']) ?></textarea></div>
<hr class="section-divider"> <hr class="section-divider">
<h3 style="margin-bottom:15px; color: var(--text-primary);">Benutzer-Benachrichtigung</h3> <h3 style="margin-bottom:15px; color: var(--text-primary);"><?= __('settings_tpl_user_notify') ?></h3>
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{USER_NAME}</code><code>{CHANGES}</code><code>{APP_TITLE}</code><code>{SYSTEM_URL}</code></div></div> <div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{USER_NAME}</code><code>{CHANGES}</code><code>{APP_TITLE}</code><code>{SYSTEM_URL}</code></div></div>
<div class="form-group"><label>Betreff</label><input type="text" name="email_user_notification_subject" value="<?= htmlspecialchars($cs['email_user_notification_subject']) ?>"></div> <div class="form-group"><label>Betreff</label><input type="text" name="email_user_notification_subject" value="<?= htmlspecialchars($cs['email_user_notification_subject']) ?>"></div>
<div class="form-group"><label>E-Mail Text</label><textarea name="email_user_notification_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_user_notification_body']) ?></textarea></div> <div class="form-group"><label>E-Mail Text</label><textarea name="email_user_notification_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_user_notification_body']) ?></textarea></div>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
</form> </form>
</div> </div>
<!-- System --> <!-- System -->
<div id="tab-system" class="tab-content"> <div id="tab-system" class="tab-content<?= $activeTab === 'system' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs"></i> System & Erweitert</h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs" aria-hidden="true"></i> System & Erweitert</h2>
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="system"> <input type="hidden" name="form_type" value="system">
<div class="info-box"> <div class="info-box">
<h4><i class="fas fa-info-circle"></i> TinyMCE API Key</h4> <h4><i class="fas fa-info-circle" aria-hidden="true"></i> WYSIWYG-Editor</h4>
<p>Kostenlosen API Key: <a href="https://www.tiny.cloud/auth/signup/" target="_blank" rel="noopener" style="color:#0066cc;">tiny.cloud/signup</a></p> <p><?= __('settings_editor_hint') ?> <code>assets/vendor/</code> <?= __('settings_editor_hint2') ?></p>
</div> </div>
<div class="form-group"><label>TinyMCE API Key</label><input type="text" name="tinymce_api_key" value="<?= htmlspecialchars($cs['tinymce_api_key']) ?>" placeholder="your-api-key-here"><div class="help-text">Für WYSIWYG-Editor in Anleitungen</div></div>
<hr class="section-divider"> <hr class="section-divider">
<h3 style="margin-bottom:15px; color: var(--text-primary);">Druck-Template</h3> <h3 style="margin-bottom:15px; color: var(--text-primary);">Druck-Template</h3>
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{VOUCHER_CODE}</code><code>{EXPIRY_DATE}</code><code>{EXPIRY_TIME}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div></div> <div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{QR_CODE}</code><code>{VOUCHER_CODE}</code><code>{EXPIRY_DATE}</code><code>{EXPIRY_TIME}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div><p class="help-text"><?= __('settings_qr_hint') ?></p></div>
<div class="form-group"><label>HTML Template für Voucher-Druck</label><textarea name="print_template" class="tinymce-editor" style="min-height:250px;"><?= htmlspecialchars($cs['print_template']) ?></textarea></div> <div class="form-group"><label><?= __('settings_print_template') ?></label><textarea name="print_template" class="tinymce-editor" style="min-height:250px;"><?= htmlspecialchars($cs['print_template']) ?></textarea></div>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
</form> </form>
<hr class="section-divider"> <hr class="section-divider">
<h3 style="margin-bottom:15px; color: var(--text-primary);">System-Information</h3> <h3 style="margin-bottom:15px; color: var(--text-primary);">System-Information</h3>
@ -551,14 +687,14 @@ $adminBase = '';
</div> </div>
<!-- Passwort --> <!-- Passwort -->
<div id="tab-password" class="tab-content"> <div id="tab-password" class="tab-content<?= $activeTab === 'password' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key"></i> <?= __('settings_tab_password') ?></h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></h2>
<form method="post" style="max-width:500px;"> <form method="post" style="max-width:500px;">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<div class="form-group"><label><?= __('settings_pw_current') ?></label><input type="password" name="current_password" required></div> <div class="form-group"><label><?= __('settings_pw_current') ?></label><input type="password" name="current_password" required></div>
<div class="form-group"><label><?= __('settings_pw_new') ?></label><input type="password" name="new_password" required minlength="8"><div class="help-text"><?= __('settings_pw_minlength') ?></div></div> <div class="form-group"><label><?= __('settings_pw_new') ?></label><input type="password" name="new_password" required minlength="8"><div class="help-text"><?= __('settings_pw_minlength') ?></div></div>
<div class="form-group"><label><?= __('settings_pw_confirm') ?></label><input type="password" name="confirm_password" required></div> <div class="form-group"><label><?= __('settings_pw_confirm') ?></label><input type="password" name="confirm_password" required></div>
<button type="submit" name="change_password" class="btn btn-primary"><i class="fas fa-lock"></i> <?= __('settings_tab_password') ?></button> <button type="submit" name="change_password" class="btn btn-primary"><i class="fas fa-lock" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button>
</form> </form>
</div> </div>
</div> </div>
@ -579,17 +715,43 @@ document.querySelectorAll('.tab-button').forEach(btn => {
}); });
}); });
// Branding-Vorschau live faerben
function updateBrandPreview() {
const preview = document.getElementById('brandPreview');
if (!preview) return;
const accent = (document.getElementById('brand_accent') || {}).value || '';
const from = (document.getElementById('brand_gradient_from') || {}).value || '';
const to = (document.getElementById('brand_gradient_to') || {}).value || '';
const radius = (document.getElementById('brand_radius') || {}).value || '14';
if (/^#[0-9a-fA-F]{6}$/.test(accent)) {
preview.style.setProperty('--accent', accent);
preview.style.setProperty('--accent-hover', `color-mix(in srgb, ${accent} 84%, #000)`);
preview.style.setProperty('--accent-soft', `color-mix(in srgb, ${accent} 12%, #fff)`);
preview.style.setProperty('--accent-border', `color-mix(in srgb, ${accent} 32%, #fff)`);
}
if (/^#[0-9a-fA-F]{6}$/.test(from) && /^#[0-9a-fA-F]{6}$/.test(to)) {
preview.style.setProperty('--brand-gradient', `linear-gradient(135deg, ${from} 0%, ${to} 100%)`);
}
preview.style.setProperty('--r-lg', radius + 'px');
}
['brand_accent', 'brand_gradient_from', 'brand_gradient_to', 'brand_radius'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', updateBrandPreview);
if (el) el.addEventListener('change', updateBrandPreview);
});
updateBrandPreview();
// Farbwähler und Hex-Feld synchron halten // Farbwähler und Hex-Feld synchron halten
document.querySelectorAll('.color-swatch').forEach(swatch => { document.querySelectorAll('.color-swatch').forEach(swatch => {
const field = document.getElementById(swatch.dataset.target); const field = document.getElementById(swatch.dataset.target);
if (!field) return; if (!field) return;
swatch.addEventListener('input', () => { field.value = swatch.value; }); swatch.addEventListener('input', () => { field.value = swatch.value; updateBrandPreview(); });
field.addEventListener('input', () => { field.addEventListener('input', () => {
if (/^#[0-9a-fA-F]{6}$/.test(field.value.trim())) swatch.value = field.value.trim(); if (/^#[0-9a-fA-F]{6}$/.test(field.value.trim())) swatch.value = field.value.trim();
}); });
}); });
// Restore tab from hash // Tab aus Anker uebernehmen (der Query-Parameter wird serverseitig gesetzt)
window.addEventListener('DOMContentLoaded', function() { window.addEventListener('DOMContentLoaded', function() {
const hash = location.hash.substring(1); const hash = location.hash.substring(1);
if (hash) { if (hash) {
@ -603,9 +765,9 @@ async function testSmtp() {
const email = document.getElementById('smtpTestEmail').value.trim(); const email = document.getElementById('smtpTestEmail').value.trim();
const btn = document.getElementById('smtpTestBtn'); const btn = document.getElementById('smtpTestBtn');
const result = document.getElementById('smtpTestResult'); const result = document.getElementById('smtpTestResult');
if (!email) { result.textContent = 'Bitte E-Mail eingeben.'; return; } if (!email) { result.textContent = '<?= __('js_enter_email') ?>'; return; }
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>';
const fd = new FormData(); const fd = new FormData();
fd.append('ajax_smtp_test', '1'); fd.append('ajax_smtp_test', '1');
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>'); fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
@ -615,26 +777,26 @@ async function testSmtp() {
result.textContent = data.message; result.textContent = data.message;
result.style.color = data.success ? 'var(--success)' : 'var(--danger)'; result.style.color = data.success ? 'var(--success)' : 'var(--danger)';
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Testen'; btn.innerHTML = '<i class="fas fa-paper-plane" aria-hidden="true"></i> Testen';
} }
async function testCronJob() { async function testCronJob() {
const btn = document.getElementById('testCronBtn'); const btn = document.getElementById('testCronBtn');
const result = document.getElementById('testCronResult'); const result = document.getElementById('testCronResult');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Läuft...'; btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= __('js_running') ?>';
try { try {
const res = await fetch('../cron_sync.php?token=<?= htmlspecialchars($cs['cron_token']) ?>'); const res = await fetch('../cron_sync.php?token=<?= htmlspecialchars($cs['cron_token']) ?>');
const data = await res.json(); const data = await res.json();
result.innerHTML = data.success result.innerHTML = data.success
? `<span style="color:var(--success)"><i class="fas fa-check-circle"></i> ${data.message}</span>` ? `<span style="color:var(--success)"><i class="fas fa-check-circle" aria-hidden="true"></i> ${data.message}</span>`
: `<span style="color:var(--danger)"><i class="fas fa-times-circle"></i> ${data.message}</span>`; : `<span style="color:var(--danger)"><i class="fas fa-times-circle" aria-hidden="true"></i> ${data.message}</span>`;
if (data.success) showToast('success', 'Cron ausgeführt', data.message); if (data.success) showToast('success', 'Cron ausgeführt', data.message);
} catch (e) { } catch (e) {
result.innerHTML = `<span style="color:var(--danger)">Fehler: ${e.message}</span>`; result.innerHTML = `<span style="color:var(--danger)"><?= __('js_error') ?>: ${e.message}</span>`;
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play"></i> Jetzt ausführen'; btn.innerHTML = '<i class="fas fa-play" aria-hidden="true"></i> <?= __('js_run_now') ?>';
} }
function initTinyMCE() { function initTinyMCE() {
@ -655,6 +817,10 @@ function initTinyMCE() {
tinymce.baseURL = window.TINYMCE_BASE_URL; tinymce.baseURL = window.TINYMCE_BASE_URL;
config.base_url = window.TINYMCE_BASE_URL; config.base_url = window.TINYMCE_BASE_URL;
config.suffix = '.min'; config.suffix = '.min';
if (document.documentElement.lang === 'de') {
config.language = 'de';
config.language_url = window.TINYMCE_BASE_URL + '/langs/de.js';
}
} }
tinymce.init(config); tinymce.init(config);
} }

View file

@ -105,20 +105,20 @@ $currentPage = 'sites';
<div class="page-header"> <div class="page-header">
<h1 class="page-title"><?= __('sites_title') ?></h1> <h1 class="page-title"><?= __('sites_title') ?></h1>
<button onclick="openModal()" class="btn btn-primary"> <button onclick="openModal()" class="btn btn-primary">
<i class="fas fa-plus"></i> <?= __('sites_add') ?> <i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?>
</button> </button>
</div> </div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div> <div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($success): ?> <?php if ($success): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div> <div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (empty($sites)): ?> <?php if (empty($sites)): ?>
<div class="empty-card"> <div class="empty-card">
<i class="fas fa-map-marker-alt" style="font-size:48px;margin-bottom:20px;opacity:.3;display:block;"></i> <i class="fas fa-map-marker-alt" style="font-size:48px;margin-bottom:20px;opacity:.3;display:block;" aria-hidden="true"></i>
<p><?= __('sites_none') ?></p> <p><?= __('sites_none') ?></p>
</div> </div>
<?php else: ?> <?php else: ?>
@ -132,43 +132,43 @@ $currentPage = 'sites';
</div> </div>
<div> <div>
<?php if ($site['is_active']): ?> <?php if ($site['is_active']): ?>
<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span> <span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_active') ?></span>
<?php else: ?> <?php else: ?>
<span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span> <span class="badge badge-warning"><i class="fas fa-pause" aria-hidden="true"></i> <?= __('status_inactive') ?></span>
<?php endif; ?> <?php endif; ?>
<?php if ($site['public_access']): ?> <?php if ($site['public_access']): ?>
<span class="badge badge-info"><i class="fas fa-globe"></i> <?= __('status_public') ?></span> <span class="badge badge-info"><i class="fas fa-globe" aria-hidden="true"></i> <?= __('status_public') ?></span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<div class="site-info"> <div class="site-info">
<div class="site-info-item"> <div class="site-info-item">
<i class="fas fa-server" style="color:var(--accent);width:16px;"></i> <i class="fas fa-server" style="color:var(--accent);width:16px;" aria-hidden="true"></i>
<span style="word-break:break-all;"><?= htmlspecialchars($site['unifi_controller_url']) ?></span> <span style="word-break:break-all;"><?= htmlspecialchars($site['unifi_controller_url']) ?></span>
</div> </div>
<div class="site-info-item"> <div class="site-info-item">
<i class="fas fa-user" style="color:var(--accent);width:16px;"></i> <i class="fas fa-user" style="color:var(--accent);width:16px;" aria-hidden="true"></i>
<span><?= htmlspecialchars($site['unifi_username']) ?></span> <span><?= htmlspecialchars($site['unifi_username']) ?></span>
</div> </div>
<div class="site-info-item"> <div class="site-info-item">
<i class="fas fa-clock" style="color:var(--text-muted);width:16px;"></i> <i class="fas fa-clock" style="color:var(--text-muted);width:16px;" aria-hidden="true"></i>
<span style="color:var(--text-muted);"><?= date('d.m.Y', strtotime($site['created_at'])) ?></span> <span style="color:var(--text-muted);"><?= date('d.m.Y', strtotime($site['created_at'])) ?></span>
</div> </div>
</div> </div>
<div class="site-actions"> <div class="site-actions">
<button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>)" <button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>)"
class="btn btn-secondary btn-sm"> class="btn btn-secondary btn-sm">
<i class="fas fa-edit"></i> <?= __('btn_edit') ?> <i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?>
</button> </button>
<a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-sm"> class="btn btn-secondary btn-sm">
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>"></i> <i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i>
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?> <?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
</a> </a>
<a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-danger-soft btn-sm" class="btn btn-danger-soft btn-sm"
onclick="return confirm('Möchten Sie diese Site wirklich löschen?')"> onclick="return confirm('<?= __('js_confirm_delete_site') ?>')">
<i class="fas fa-trash"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</a> </a>
</div> </div>
</div> </div>
@ -196,7 +196,7 @@ $currentPage = 'sites';
<div class="form-group"> <div class="form-group">
<label><?= __('sites_site_id') ?></label> <label><?= __('sites_site_id') ?></label>
<input type="text" name="site_id" required placeholder="z.B. default"> <input type="text" name="site_id" required placeholder="z.B. default">
<small style="color:var(--text-muted);font-size:12px;">Zu finden in der UniFi Controller URL</small> <small style="color:var(--text-muted);font-size:12px;"><?= __('sites_id_hint') ?></small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label><?= __('sites_controller') ?></label> <label><?= __('sites_controller') ?></label>
@ -219,7 +219,7 @@ $currentPage = 'sites';
</div> </div>
<div style="display:flex;gap:10px;margin-top:20px;"> <div style="display:flex;gap:10px;margin-top:20px;">
<button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn"> <button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn">
<i class="fas fa-save"></i> <?= __('sites_add') ?> <i class="fas fa-save" aria-hidden="true"></i> <?= __('sites_add') ?>
</button> </button>
<button type="button" onclick="closeModal('addSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button> <button type="button" onclick="closeModal('addSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div> </div>
@ -259,7 +259,7 @@ $currentPage = 'sites';
</div> </div>
<div class="form-group"> <div class="form-group">
<label><?= __('sites_password_edit') ?></label> <label><?= __('sites_password_edit') ?></label>
<input type="password" id="edit_password" name="password" placeholder="Leer lassen = nicht ändern"> <input type="password" id="edit_password" name="password" placeholder="<?= __('sites_pw_unchanged') ?>">
<small style="color:var(--text-muted);font-size:12px;"><?= __('sites_password_hint') ?></small> <small style="color:var(--text-muted);font-size:12px;"><?= __('sites_password_hint') ?></small>
</div> </div>
</div> </div>
@ -269,7 +269,7 @@ $currentPage = 'sites';
</div> </div>
<div style="display:flex;gap:10px;margin-top:20px;"> <div style="display:flex;gap:10px;margin-top:20px;">
<button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn"> <button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn">
<i class="fas fa-save"></i> <?= __('btn_save') ?> <i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
</button> </button>
<button type="button" onclick="closeModal('editSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button> <button type="button" onclick="closeModal('editSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div> </div>
@ -278,7 +278,7 @@ $currentPage = 'sites';
</div> </div>
</div> </div>
<div id="toast-container"></div> <div id="toast-container" role="status" aria-live="polite"></div>
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
<script> <script>
function openModal() { document.getElementById('addSiteModal').classList.add('active'); } function openModal() { document.getElementById('addSiteModal').classList.add('active'); }
@ -298,12 +298,12 @@ function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAcces
document.getElementById('addSiteForm').addEventListener('submit', function() { document.getElementById('addSiteForm').addEventListener('submit', function() {
const btn = document.getElementById('addSiteSubmitBtn'); const btn = document.getElementById('addSiteSubmitBtn');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>'; btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= addslashes(__('sites_testing')) ?>';
}); });
document.getElementById('editSiteForm').addEventListener('submit', function() { document.getElementById('editSiteForm').addEventListener('submit', function() {
const btn = document.getElementById('editSiteSubmitBtn'); const btn = document.getElementById('editSiteSubmitBtn');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>'; btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= addslashes(__('sites_testing')) ?>';
}); });
['addSiteModal','editSiteModal'].forEach(id => { ['addSiteModal','editSiteModal'].forEach(id => {

View file

@ -107,15 +107,15 @@ $adminBase = '';
<p class="page-subtitle"><?= __('templates_subtitle') ?></p> <p class="page-subtitle"><?= __('templates_subtitle') ?></p>
</div> </div>
<button onclick="openAddModal()" class="btn btn-primary"> <button onclick="openAddModal()" class="btn btn-primary">
<i class="fas fa-plus"></i> <?= __('templates_add') ?> <i class="fas fa-plus" aria-hidden="true"></i> <?= __('templates_add') ?>
</button> </button>
</div> </div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div> <div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($success): ?> <?php if ($success): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div> <div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
<?php endif; ?> <?php endif; ?>
<div class="card"> <div class="card">
@ -125,15 +125,15 @@ $adminBase = '';
</div> </div>
<?php if (empty($templates)): ?> <?php if (empty($templates)): ?>
<div class="empty-state"> <div class="empty-state">
<i class="fas fa-layer-group"></i> <i class="fas fa-layer-group" aria-hidden="true"></i>
<p style="font-size: 15px; margin-bottom: 8px;"><?= __('templates_none') ?></p> <p style="font-size: 15px; margin-bottom: 8px;"><?= __('templates_none') ?></p>
<p style="font-size: 13px;"><?= __('templates_add_hint') ?></p> <p style="font-size: 13px;"><?= __('templates_add_hint') ?></p>
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top: 20px;"><i class="fas fa-plus"></i> <?= __('templates_add') ?></button> <button onclick="openAddModal()" class="btn btn-primary" style="margin-top: 20px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('templates_add') ?></button>
</div> </div>
<?php else: ?> <?php else: ?>
<div style="overflow-x: auto;"> <div style="overflow-x: auto;">
<div class="table-container"> <div class="table-container">
<table class="table"> <table class="table table-stack">
<thead> <thead>
<tr> <tr>
<th><?= __('templates_name') ?></th> <th><?= __('templates_name') ?></th>
@ -147,11 +147,11 @@ $adminBase = '';
<tbody> <tbody>
<?php foreach ($templates as $t): ?> <?php foreach ($templates as $t): ?>
<tr> <tr>
<td><strong><?= htmlspecialchars($t['name']) ?></strong></td> <td data-label="<?= __('templates_name') ?>"><strong><?= htmlspecialchars($t['name']) ?></strong></td>
<td> <td data-label="<?= __('templates_devices') ?>">
<span class="duration-badge"><i class="fas fa-mobile-alt"></i> <?= (int)$t['max_uses'] ?></span> <span class="duration-badge"><i class="fas fa-mobile-alt" aria-hidden="true"></i> <?= (int)$t['max_uses'] ?></span>
</td> </td>
<td> <td data-label="<?= __('templates_duration') ?>">
<?php <?php
$m = (int)$t['expire_minutes']; $m = (int)$t['expire_minutes'];
if ($m >= 1440 && $m % 1440 === 0) { if ($m >= 1440 && $m % 1440 === 0) {
@ -162,22 +162,22 @@ $adminBase = '';
$durLabel = $m . ' Min.'; $durLabel = $m . ' Min.';
} }
?> ?>
<span class="duration-badge"><i class="fas fa-clock"></i> <?= $durLabel ?></span> <span class="duration-badge"><i class="fas fa-clock" aria-hidden="true"></i> <?= $durLabel ?></span>
</td> </td>
<td style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td> <td data-label="<?= __('templates_desc') ?>" style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
<td> <td data-label="<?= __('label_status') ?>">
<?php if ($t['is_active']): ?> <?php if ($t['is_active']): ?>
<span class="badge badge-success"><?= __('status_active') ?></span> <span class="badge badge-success"><?= __('status_active') ?></span>
<?php else: ?> <?php else: ?>
<span class="badge badge-secondary"><?= __('status_inactive') ?></span> <span class="badge badge-secondary"><?= __('status_inactive') ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td data-label="<?= __('label_actions') ?>">
<button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)" <button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)"
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button> class="btn btn-secondary btn-small"><i class="fas fa-edit" aria-hidden="true"></i></button>
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
onclick="return confirm('Profil wirklich löschen?')" onclick="return confirm('<?= __('js_confirm_delete_template') ?>')"
class="btn btn-danger-soft btn-small"><i class="fas fa-trash"></i></a> class="btn btn-danger-soft btn-small"><i class="fas fa-trash" aria-hidden="true"></i></a>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
@ -194,7 +194,7 @@ $adminBase = '';
<div id="addModal" class="modal"> <div id="addModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title"><i class="fas fa-plus-circle" style="color: var(--accent);"></i> <?= __('templates_add') ?></h2> <h2 class="modal-title"><i class="fas fa-plus-circle" style="color: var(--accent);" aria-hidden="true"></i> <?= __('templates_add') ?></h2>
<button class="modal-close" onclick="closeModal('addModal')">&times;</button> <button class="modal-close" onclick="closeModal('addModal')">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -210,17 +210,17 @@ $adminBase = '';
<div class="form-group"> <div class="form-group">
<label><?= __('templates_duration') ?> *</label> <label><?= __('templates_duration') ?> *</label>
<input type="number" name="expire_minutes" value="480" min="1" max="525600"> <input type="number" name="expire_minutes" value="480" min="1" max="525600">
<div class="help-text">480 = 8 Stunden</div> <div class="help-text"><?= __('templates_minutes_hint') ?></div>
</div> </div>
</div> </div>
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="Kurze Beschreibung für Ihr Team"></textarea></div> <div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="<?= __('templates_desc_placeholder') ?>"></textarea></div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div class="form-group"><label>Download (kbit/s)</label><input type="number" name="qos_rate_max_down" min="0" placeholder="0 = unbegrenzt"></div> <div class="form-group"><label>Download (kbit/s)</label><input type="number" name="qos_rate_max_down" min="0" placeholder="0 = unbegrenzt"></div>
<div class="form-group"><label>Upload (kbit/s)</label><input type="number" name="qos_rate_max_up" min="0" placeholder="0 = unbegrenzt"></div> <div class="form-group"><label>Upload (kbit/s)</label><input type="number" name="qos_rate_max_up" min="0" placeholder="0 = unbegrenzt"></div>
<div class="form-group"><label>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" min="0" placeholder="0 = unbegrenzt"></div> <div class="form-group"><label>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" min="0" placeholder="0 = unbegrenzt"></div>
</div> </div>
<div style="display:flex;gap:10px;margin-top:20px;"> <div style="display:flex;gap:10px;margin-top:20px;">
<button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button> <button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button> <button type="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div> </div>
</form> </form>
@ -232,7 +232,7 @@ $adminBase = '';
<div id="editModal" class="modal"> <div id="editModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title"><i class="fas fa-edit" style="color: var(--accent);"></i> <?= __('templates_edit') ?></h2> <h2 class="modal-title"><i class="fas fa-edit" style="color: var(--accent);" aria-hidden="true"></i> <?= __('templates_edit') ?></h2>
<button class="modal-close" onclick="closeModal('editModal')">&times;</button> <button class="modal-close" onclick="closeModal('editModal')">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -261,7 +261,7 @@ $adminBase = '';
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label> <label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
</div> </div>
<div style="display:flex;gap:10px;"> <div style="display:flex;gap:10px;">
<button type="submit" name="edit_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button> <button type="submit" name="edit_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button> <button type="button" onclick="closeModal('editModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div> </div>
</form> </form>

View file

@ -175,26 +175,26 @@ $currentPage = 'users';
<div class="page-header"> <div class="page-header">
<h1 class="page-title"><?= __('users_title') ?></h1> <h1 class="page-title"><?= __('users_title') ?></h1>
<button onclick="openModal()" class="btn btn-primary"> <button onclick="openModal()" class="btn btn-primary">
<i class="fas fa-plus"></i> <?= __('users_add') ?> <i class="fas fa-plus" aria-hidden="true"></i> <?= __('users_add') ?>
</button> </button>
</div> </div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div> <div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($success): ?> <?php if ($success): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div> <div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
<?php endif; ?> <?php endif; ?>
<div class="card"> <div class="card">
<div class="card-header"><h2 class="card-title"><?= __('users_all') ?></h2></div> <div class="card-header"><h2 class="card-title"><?= __('users_all') ?></h2></div>
<div class="card-body" style="padding:0;"> <div class="card-body" style="padding:0;">
<?php if (empty($users)): ?> <?php if (empty($users)): ?>
<div class="empty-state"><i class="fas fa-users"></i><p><?= __('users_none_found') ?></p></div> <div class="empty-state"><i class="fas fa-users" aria-hidden="true"></i><p><?= __('users_none_found') ?></p></div>
<?php else: ?> <?php else: ?>
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<div class="table-container"> <div class="table-container">
<table class="table"> <table class="table table-stack">
<thead> <thead>
<tr> <tr>
<th><?= __('label_name') ?></th> <th><?= __('label_name') ?></th>
@ -210,28 +210,28 @@ $currentPage = 'users';
<?php foreach ($users as $user): ?> <?php foreach ($users as $user): ?>
<?php $userSiteIds = array_column($userSiteAccess[$user['id']]??[], 'id'); ?> <?php $userSiteIds = array_column($userSiteAccess[$user['id']]??[], 'id'); ?>
<tr> <tr>
<td> <td data-label="<?= __('label_name') ?>">
<strong><?= htmlspecialchars($user['name']) ?></strong> <strong><?= htmlspecialchars($user['name']) ?></strong>
<?php if ($user['id'] == $_SESSION['user_id']): ?> <?php if ($user['id'] == $_SESSION['user_id']): ?>
<span class="badge badge-info"><?= __('users_you') ?></span> <span class="badge badge-info"><?= __('users_you') ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td><?= htmlspecialchars($user['email']) ?></td> <td data-label="<?= __('label_email') ?>"><?= htmlspecialchars($user['email']) ?></td>
<td> <td data-label="<?= __('label_role') ?>">
<?php if ($user['is_admin']): ?> <?php if ($user['is_admin']): ?>
<span class="badge badge-danger"><i class="fas fa-crown"></i> <?= __('status_admin') ?></span> <span class="badge badge-danger"><i class="fas fa-crown" aria-hidden="true"></i> <?= __('status_admin') ?></span>
<?php else: ?> <?php else: ?>
<span class="badge badge-info"><?= __('status_user') ?></span> <span class="badge badge-info"><?= __('status_user') ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td data-label="<?= __('label_status') ?>">
<?php if ($user['is_active']): ?> <?php if ($user['is_active']): ?>
<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span> <span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_active') ?></span>
<?php else: ?> <?php else: ?>
<span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span> <span class="badge badge-warning"><i class="fas fa-pause" aria-hidden="true"></i> <?= __('status_inactive') ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td data-label="<?= __('users_site_access') ?>">
<?php if ($user['is_admin']): ?> <?php if ($user['is_admin']): ?>
<em style="color:var(--text-muted);"><?= __('users_all_sites') ?></em> <em style="color:var(--text-muted);"><?= __('users_all_sites') ?></em>
<?php elseif (!empty($userSiteAccess[$user['id']])): ?> <?php elseif (!empty($userSiteAccess[$user['id']])): ?>
@ -242,7 +242,7 @@ $currentPage = 'users';
<em style="color:var(--text-muted);"><?= __('users_none') ?></em> <em style="color:var(--text-muted);"><?= __('users_none') ?></em>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="font-size:13px;"> <td data-label="<?= __('users_last_login') ?>" style="font-size:13px;">
<?php if ($user['last_login']): ?> <?php if ($user['last_login']): ?>
<?= date('d.m.Y H:i', strtotime($user['last_login'])) ?> <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?>
<?php else: ?> <?php else: ?>
@ -253,31 +253,31 @@ $currentPage = 'users';
<div class="action-btns"> <div class="action-btns">
<button onclick="openEditModal(<?= $user['id'] ?>, '<?= htmlspecialchars($user['name'], ENT_QUOTES) ?>', <?= $user['is_admin'] ?>, [<?= implode(',', array_map('intval', $userSiteIds)) ?>])" <button onclick="openEditModal(<?= $user['id'] ?>, '<?= htmlspecialchars($user['name'], ENT_QUOTES) ?>', <?= $user['is_admin'] ?>, [<?= implode(',', array_map('intval', $userSiteIds)) ?>])"
class="btn btn-secondary btn-sm" title="<?= __('btn_edit') ?>"> class="btn btn-secondary btn-sm" title="<?= __('btn_edit') ?>">
<i class="fas fa-edit"></i> <i class="fas fa-edit" aria-hidden="true"></i>
</button> </button>
<?php if ($user['id'] != $_SESSION['user_id']): ?> <?php if ($user['id'] != $_SESSION['user_id']): ?>
<a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-sm" title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>"> class="btn btn-secondary btn-sm" title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>">
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>"></i> <i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i>
</a> </a>
<?php if ($smtpEnabled && !empty($user['password_hash'])): ?> <?php if ($smtpEnabled && !empty($user['password_hash'])): ?>
<a href="?send_reset=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?send_reset=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-warning btn-sm" title="<?= __('users_reset_pw') ?>" class="btn btn-warning btn-sm" title="<?= __('users_reset_pw') ?>"
onclick="return confirm('Passwort-Reset-Link senden an <?= htmlspecialchars($user['email'], ENT_QUOTES) ?>?')"> onclick="return confirm('Passwort-Reset-Link senden an <?= htmlspecialchars($user['email'], ENT_QUOTES) ?>?')">
<i class="fas fa-key"></i> <i class="fas fa-key" aria-hidden="true"></i>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($user['totp_enabled'])): ?> <?php if (!empty($user['totp_enabled'])): ?>
<a href="?reset_2fa=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?reset_2fa=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-sm" title="2FA zurücksetzen" class="btn btn-secondary btn-sm" title="2FA zurücksetzen"
onclick="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')"> onclick="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
<i class="fas fa-user-shield"></i> <i class="fas fa-user-shield" aria-hidden="true"></i>
</a> </a>
<?php endif; ?> <?php endif; ?>
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>" class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>"
onclick="return confirm('Benutzer wirklich löschen?')"> onclick="return confirm('<?= __('js_confirm_delete_user') ?>')">
<i class="fas fa-trash"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</a> </a>
<?php endif; ?> <?php endif; ?>
</div> </div>
@ -342,7 +342,7 @@ $currentPage = 'users';
</div> </div>
<div style="display:flex;gap:10px;margin-top:20px;"> <div style="display:flex;gap:10px;margin-top:20px;">
<button type="submit" name="add_user" class="btn btn-primary" style="flex:1;"> <button type="submit" name="add_user" class="btn btn-primary" style="flex:1;">
<i class="fas fa-save"></i> <?= __('users_save') ?> <i class="fas fa-save" aria-hidden="true"></i> <?= __('users_save') ?>
</button> </button>
<button type="button" onclick="closeModal('addUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button> <button type="button" onclick="closeModal('addUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div> </div>
@ -386,7 +386,7 @@ $currentPage = 'users';
</div> </div>
<div style="display:flex;gap:10px;margin-top:20px;"> <div style="display:flex;gap:10px;margin-top:20px;">
<button type="submit" name="edit_user" class="btn btn-primary" style="flex:1;"> <button type="submit" name="edit_user" class="btn btn-primary" style="flex:1;">
<i class="fas fa-save"></i> <?= __('users_save_edit') ?> <i class="fas fa-save" aria-hidden="true"></i> <?= __('users_save_edit') ?>
</button> </button>
<button type="button" onclick="closeModal('editUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button> <button type="button" onclick="closeModal('editUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div> </div>
@ -395,7 +395,7 @@ $currentPage = 'users';
</div> </div>
</div> </div>
<div id="toast-container"></div> <div id="toast-container" role="status" aria-live="polite"></div>
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
<script> <script>
function openModal() { document.getElementById('addUserModal').classList.add('active'); } function openModal() { document.getElementById('addUserModal').classList.add('active'); }

View file

@ -143,9 +143,9 @@ $currentPage = 'vouchers';
<?php if (empty($sites)): ?> <?php if (empty($sites)): ?>
<div class="no-sites-warning"> <div class="no-sites-warning">
<i class="fas fa-triangle-exclamation" style="font-size:26px;margin-bottom:12px;display:block;"></i> <i class="fas fa-triangle-exclamation" style="font-size:26px;margin-bottom:12px;display:block;" aria-hidden="true"></i>
<h3><?= __('vouchers_no_sites') ?></h3> <h3><?= __('vouchers_no_sites') ?></h3>
<a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus"></i> <?= __('nav_sites') ?></a> <a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('nav_sites') ?></a>
</div> </div>
<?php else: ?> <?php else: ?>
@ -168,7 +168,7 @@ $currentPage = 'vouchers';
</select> </select>
<input type="search" id="searchInput" class="search-bar" placeholder="<?= __('vouchers_search') ?>" oninput="filterAndRender()" style="display:none;"> <input type="search" id="searchInput" class="search-bar" placeholder="<?= __('vouchers_search') ?>" oninput="filterAndRender()" style="display:none;">
<button id="refreshBtn" class="btn btn-secondary" onclick="loadVouchers(true)" disabled> <button id="refreshBtn" class="btn btn-secondary" onclick="loadVouchers(true)" disabled>
<i class="fas fa-sync-alt"></i> <?= __('btn_refresh') ?> <i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('btn_refresh') ?>
</button> </button>
</div> </div>
</div> </div>
@ -187,12 +187,12 @@ $currentPage = 'vouchers';
<div class="card-header"> <div class="card-header">
<h2 class="card-title" id="voucherListTitle">Vouchers</h2> <h2 class="card-title" id="voucherListTitle">Vouchers</h2>
<a id="csvExportBtn" style="display:none;" class="btn btn-secondary btn-sm" href="#"> <a id="csvExportBtn" style="display:none;" class="btn btn-secondary btn-sm" href="#">
<i class="fas fa-download"></i> <?= __('btn_export_csv') ?> <i class="fas fa-download" aria-hidden="true"></i> <?= __('btn_export_csv') ?>
</a> </a>
</div> </div>
<div class="card-body" style="padding:0;"> <div class="card-body" style="padding:0;">
<div id="voucherContent"> <div id="voucherContent">
<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('vouchers_select_hint') ?></p></div> <div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= __('vouchers_select_hint') ?></p></div>
</div> </div>
</div> </div>
</div> </div>
@ -200,10 +200,12 @@ $currentPage = 'vouchers';
</main> </main>
<div id="toast-container"></div> <div id="toast-container" role="status" aria-live="polite"></div>
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
<script> <script>
const csrfToken = '<?= $auth->getCsrfToken() ?>'; const csrfToken = '<?= $auth->getCsrfToken() ?>';
// Datums- und Zeitformat folgen der gewaehlten Sprache
const LOCALE = '<?= I18n::getLanguage() === 'en' ? 'en-GB' : 'de-DE' ?>';
let currentSiteId = null; let currentSiteId = null;
let allVouchers = []; let allVouchers = [];
let currentFilter = 'all'; let currentFilter = 'all';
@ -218,7 +220,7 @@ async function loadVouchers(syncFirst=false) {
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
if (!siteId) { if (!siteId) {
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_select_hint')) ?></p></div>`; document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= addslashes(__('vouchers_select_hint')) ?></p></div>`;
document.getElementById('statsContainer').style.display = 'none'; document.getElementById('statsContainer').style.display = 'none';
searchInput.style.display = 'none'; searchInput.style.display = 'none';
refreshBtn.disabled = true; refreshBtn.disabled = true;
@ -227,9 +229,9 @@ async function loadVouchers(syncFirst=false) {
currentSiteId = siteId; currentSiteId = siteId;
refreshBtn.disabled = false; refreshBtn.disabled = false;
refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`; refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner"></i><span>${syncFirst ? 'Synchronisiere...' : 'Lade...'}</span></div>`; document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner" aria-hidden="true"></i><span>${syncFirst ? 'Synchronisiere...' : 'Lade...'}</span></div>`;
try { try {
const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json()); const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json());
@ -247,15 +249,15 @@ async function loadVouchers(syncFirst=false) {
csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`; csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`;
if (syncFirst) showToast('success', '<?= addslashes(__('btn_refresh')) ?>', `${result.count} Vouchers geladen`); if (syncFirst) showToast('success', '<?= addslashes(__('btn_refresh')) ?>', `${result.count} Vouchers geladen`);
} else { } else {
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>${result.message}</p></div>`; document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)" aria-hidden="true"></i><p>${result.message}</p></div>`;
document.getElementById('statsContainer').style.display = 'none'; document.getElementById('statsContainer').style.display = 'none';
} }
} catch(e) { } catch(e) {
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>Verbindungsfehler: ${e.message}</p></div>`; document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)" aria-hidden="true"></i><p>Verbindungsfehler: ${e.message}</p></div>`;
document.getElementById('statsContainer').style.display = 'none'; document.getElementById('statsContainer').style.display = 'none';
} }
refreshBtn.disabled = false; refreshBtn.disabled = false;
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= addslashes(__('btn_refresh')) ?>'; refreshBtn.innerHTML = '<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= addslashes(__('btn_refresh')) ?>';
} }
function updateStats() { function updateStats() {
@ -293,7 +295,7 @@ function renderVouchers() {
const pageVouchers = vouchers.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE); const pageVouchers = vouchers.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE);
if (vouchers.length === 0) { if (vouchers.length === 0) {
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`; document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`;
return; return;
} }
@ -309,7 +311,7 @@ function renderVouchers() {
<button class="filter-btn ${currentFilter==='expired'?'active':''}" data-filter="expired" onclick="setFilter('expired')"><?= __('vouchers_filter_expired') ?> (${expiredCnt})</button> <button class="filter-btn ${currentFilter==='expired'?'active':''}" data-filter="expired" onclick="setFilter('expired')"><?= __('vouchers_filter_expired') ?> (${expiredCnt})</button>
</div> </div>
</div> </div>
<div class="table-container"><table class="table"> <div class="table-container"><table class="table table-stack">
<thead><tr> <thead><tr>
<th><?= __('label_created') ?></th> <th><?= __('label_created') ?></th>
<th><?= __('label_code') ?></th> <th><?= __('label_code') ?></th>
@ -330,21 +332,21 @@ function renderVouchers() {
})() : ''; })() : '';
const usagePct = v.quota > 0 ? Math.min(100, (v.used/v.quota)*100) : 0; const usagePct = v.quota > 0 ? Math.min(100, (v.used/v.quota)*100) : 0;
const statusBadge = v.status==='valid' const statusBadge = v.status==='valid'
? `<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_valid') ?></span>` ? `<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_valid') ?></span>`
: v.status==='used' : v.status==='used'
? `<span class="badge badge-warning"><i class="fas fa-user-check"></i> <?= __('status_used') ?></span>` ? `<span class="badge badge-warning"><i class="fas fa-user-check" aria-hidden="true"></i> <?= __('status_used') ?></span>`
: `<span class="badge badge-danger"><i class="fas fa-times"></i> <?= __('status_expired') ?></span>`; : `<span class="badge badge-danger"><i class="fas fa-times" aria-hidden="true"></i> <?= __('status_expired') ?></span>`;
html += `<tr id="voucher-${v._id}"> html += `<tr id="voucher-${v._id}">
<td><strong>${createDate.toLocaleDateString('de-DE')}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}</small></td> <td data-label="<?= __('label_created') ?>"><strong>${createDate.toLocaleDateString(LOCALE)}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString(LOCALE,{hour:'2-digit',minute:'2-digit'})}</small></td>
<td><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','Kopiert!')" title="Kopieren" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td> <td data-label="<?= __('label_code') ?>"><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','<?= __('js_copied') ?>')" title="<?= __('js_copy') ?>" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
<td class="voucher-note" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td> <td class="voucher-note" data-label="<?= __('label_note') ?>" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
<td>${statusBadge}</td> <td data-label="<?= __('label_status') ?>">${statusBadge}</td>
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td> <td data-label="<?= __('label_usage') ?>"><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
<td>${remaining?`<span style="color:var(--success)"><i class="fas fa-clock"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} Min.</small></td> <td data-label="<?= __('label_expires') ?>">${remaining?`<span style="color:var(--success)"><i class="fas fa-clock" aria-hidden="true"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} <?= __('label_minutes_short') ?></small></td>
<td style="white-space:nowrap;"> <td data-label="<?= __('label_actions') ?>" style="white-space:nowrap;">
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="Per E-Mail senden"><i class="fas fa-envelope"></i></button> <button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="<?= __('vouchers_resend') ?>" aria-label="<?= __('vouchers_resend') ?>"><i class="fas fa-envelope" aria-hidden="true"></i></button>
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button> <button onclick="deleteVoucher('${v._id}')" class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>"><i class="fas fa-trash" aria-hidden="true"></i></button>
</td> </td>
</tr>`; </tr>`;
}); });
@ -356,11 +358,11 @@ function renderVouchers() {
<span style="font-size:13px;color:var(--text-muted);">${<?= json_encode(__('vouchers_page_of')) ?> <span style="font-size:13px;color:var(--text-muted);">${<?= json_encode(__('vouchers_page_of')) ?>
.replace('{current}',currentPage).replace('{total}',totalPages)} (${vouchers.length})</span> .replace('{current}',currentPage).replace('{total}',totalPages)} (${vouchers.length})</span>
<div style="display:flex;gap:5px;"> <div style="display:flex;gap:5px;">
<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage-1})" ${currentPage<=1?'disabled':''}><i class="fas fa-chevron-left"></i></button>`; <button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage-1})" ${currentPage<=1?'disabled':''}><i class="fas fa-chevron-left" aria-hidden="true"></i></button>`;
for (let p=Math.max(1,currentPage-2); p<=Math.min(totalPages,currentPage+2); p++) { for (let p=Math.max(1,currentPage-2); p<=Math.min(totalPages,currentPage+2); p++) {
html += `<button class="btn btn-sm ${p===currentPage?'btn-primary':'btn-secondary'}" onclick="setPage(${p})">${p}</button>`; html += `<button class="btn btn-sm ${p===currentPage?'btn-primary':'btn-secondary'}" onclick="setPage(${p})">${p}</button>`;
} }
html += `<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}><i class="fas fa-chevron-right"></i></button> html += `<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
</div></div>`; </div></div>`;
} }
@ -384,7 +386,7 @@ async function resendVoucher(voucherId, code) {
} }
async function deleteVoucher(voucherId) { async function deleteVoucher(voucherId) {
if (!confirm('Voucher wirklich löschen?')) return; if (!confirm('<?= __('js_confirm_delete_voucher') ?>')) return;
const row = document.getElementById(`voucher-${voucherId}`); const row = document.getElementById(`voucher-${voucherId}`);
if (row) row.classList.add('deleting'); if (row) row.classList.add('deleting');
try { try {

View file

@ -27,7 +27,7 @@
/* Text */ /* Text */
--text-primary: #101625; --text-primary: #101625;
--text-secondary: #525c6e; --text-secondary: #525c6e;
--text-muted: #8a93a3; --text-muted: #6b7280;
--text-inverse: #ffffff; --text-inverse: #ffffff;
/* Linien */ /* Linien */
@ -107,7 +107,7 @@
--text-primary: #e9ecf3; --text-primary: #e9ecf3;
--text-secondary: #a2abbd; --text-secondary: #a2abbd;
--text-muted: #6f7889; --text-muted: #8a93a6;
--text-inverse: #0a0c11; --text-inverse: #0a0c11;
--border-color: #232838; --border-color: #232838;
@ -562,7 +562,28 @@ input:focus, select:focus, textarea:focus, .input:focus {
} }
input:disabled, select:disabled, textarea:disabled { background: var(--bg-hover); color: var(--text-muted); cursor: not-allowed; } input:disabled, select:disabled, textarea:disabled { background: var(--bg-hover); color: var(--text-muted); cursor: not-allowed; }
input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; } input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; }
input[type="file"] { font-size: 13px; color: var(--text-secondary); } input[type="file"] {
width: 100%;
font-size: 13px;
color: var(--text-secondary);
}
input[type="file"]::file-selector-button {
margin-right: 10px;
padding: 7px 13px;
border: 1px solid var(--border-color);
border-radius: var(--r-sm);
background: var(--bg-card);
color: var(--text-primary);
font: inherit;
font-size: 12.5px;
font-weight: 550;
cursor: pointer;
transition: background-color .15s, border-color .15s;
}
input[type="file"]::file-selector-button:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
}
.chk, label.chk { .chk, label.chk {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
@ -1060,6 +1081,22 @@ input[type="file"] { font-size: 13px; color: var(--text-secondary); }
border-radius: var(--r-xl); border-radius: var(--r-xl);
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-xl);
} }
/* Bildfeld: Vorschau, Upload und URL nebeneinander */
.image-field { display: flex; gap: 14px; align-items: flex-start; }
.image-preview {
width: 84px; height: 64px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
padding: 6px;
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-radius: var(--r-md);
color: var(--text-muted);
overflow: hidden;
}
.image-preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-field-controls { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.image-field-controls .chk { margin: 0; font-size: 12.5px; color: var(--text-secondary); }
/* Farbwähler mit Hex-Eingabe */ /* Farbwähler mit Hex-Eingabe */
.color-field { display: flex; align-items: center; gap: 8px; } .color-field { display: flex; align-items: center; gap: 8px; }
.color-field input[type="color"] { .color-field input[type="color"] {
@ -1494,6 +1531,71 @@ input.search-bar, .site-selector .search-bar { min-width: 240px; width: auto; fl
.row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } .row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
@media (max-width: 720px) { .row, .row3 { grid-template-columns: 1fr; } } @media (max-width: 720px) { .row, .row3 { grid-template-columns: 1fr; } }
/* QR-Code auf der Druckkarte */
.print-qr { display: flex; justify-content: center; line-height: 0; }
.print-qr img, .print-qr canvas { display: block; }
/* Sprungmarke zum Inhalt (nur bei Tastaturfokus sichtbar) */
.skip-link {
position: absolute;
left: 12px;
top: -60px;
z-index: 999;
padding: 10px 16px;
background: var(--bg-card);
border: 1px solid var(--accent);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
color: var(--accent);
font-size: 13.5px;
font-weight: 600;
transition: top .15s;
}
.skip-link:focus { top: 12px; }
/* Tabellen, die auf schmalen Geräten zu Karten werden.
Die Spaltenüberschrift steht dann als data-label vor dem Wert. */
@media (max-width: 720px) {
.table-stack thead { display: none; }
.table-stack, .table-stack tbody, .table-stack tr, .table-stack td { display: block; width: 100%; }
.table-stack tr {
margin-bottom: 10px;
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: var(--r-md);
background: var(--bg-card);
}
.table-stack td {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
text-align: right;
}
.table-stack tr td:last-child { border-bottom: none; }
.table-stack td::before {
content: attr(data-label);
flex-shrink: 0;
color: var(--text-muted);
font-size: 11.5px;
font-weight: 620;
text-transform: uppercase;
letter-spacing: .04em;
text-align: left;
}
.table-stack td:not([data-label])::before { content: ''; }
.table-stack .details-cell, .table-stack .voucher-note { max-width: none; white-space: normal; }
}
/* Nutzer, die Bewegung reduzieren möchten, bekommen keine Animationen. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
scroll-behavior: auto !important;
}
}
/* ========================================================================= /* =========================================================================
13. RESPONSIVE 13. RESPONSIVE
========================================================================= */ ========================================================================= */

View file

@ -1,7 +1,21 @@
/* === DARK MODE === */ /* === DARK MODE ===
Reihenfolge: ausdrueckliche Auswahl des Nutzers > Systemeinstellung. */
(function() { (function() {
const saved = localStorage.getItem('theme') || 'light'; const saved = localStorage.getItem('theme');
document.documentElement.setAttribute('data-theme', saved); // Der Inline-Schnipsel im <head> hat das Theme bereits gesetzt diese
// Entscheidung wird hier nicht überschrieben.
const current = document.documentElement.getAttribute('data-theme');
const system = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', saved || current || system);
// Solange nichts ausgewaehlt wurde, folgt die Oberflaeche dem System.
if (!saved && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (localStorage.getItem('theme')) return;
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
if (typeof updateDarkModeBtn === 'function') updateDarkModeBtn();
});
}
})(); })();
function toggleDarkMode() { function toggleDarkMode() {

30
assets/vendor/README.md vendored Normal file
View file

@ -0,0 +1,30 @@
# Lokale Drittanbieter-Assets
Alle Frontend-Bibliotheken werden aus diesem Ordner ausgeliefert. Damit gibt es
im Betrieb **keine Verbindungen zu externen CDNs** wichtig für den Datenschutz
(keine IP-Übertragung an Dritte) und für abgeschottete Netze ohne Internetzugang.
| Ordner | Inhalt | Version | Lizenz |
|---|---|---|---|
| `inter/` | Schriftschnitte 400/500/600/700 als woff2 | Inter 4.0 | SIL OFL 1.1 |
| `fontawesome/` | Solid- und Brands-Icons, gekürzte CSS (nur woff2) | Font Awesome Free 6.4.0 | Icons CC BY 4.0, Fonts SIL OFL 1.1, Code MIT |
| `chartjs/` | Diagramme für Dashboard und Reporting | Chart.js 4.4.0 | MIT |
| `qrcodejs/` | QR-Code-Erzeugung im Browser | qrcodejs 1.0.0 | MIT |
| `tinymce/` | WYSIWYG-Editor (GPL-Variante), auf die genutzten Plugins gekürzt | TinyMCE 6.8.3 | GPL-2.0-or-later |
Eingebunden werden sie über `Ui::head()` bzw. `Ui::script()` aus
`includes/Ui.php` inklusive Versionsstempel (`?v=<filemtime>`), damit Browser
nach einem Update nicht die alten Dateien aus dem Cache verwenden.
## Aktualisieren
```bash
# Beispiel Chart.js
curl -L -o assets/vendor/chartjs/chart.umd.min.js \
https://cdn.jsdelivr.net/npm/chart.js@<version>/dist/chart.umd.min.js
```
Bei TinyMCE werden nur `tinymce.min.js`, `themes/silver`, `models/dom`,
`icons/default`, die Skins `oxide`/`oxide-dark`, die deutsche Sprachdatei und
die tatsächlich genutzten Plugins übernommen (siehe `initTinyMCE()` in
`admin/settings.php`).

20
assets/vendor/chartjs/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

BIN
assets/vendor/inter/Inter-Bold.woff2 vendored Normal file

Binary file not shown.

BIN
assets/vendor/inter/Inter-Medium.woff2 vendored Normal file

Binary file not shown.

BIN
assets/vendor/inter/Inter-Regular.woff2 vendored Normal file

Binary file not shown.

BIN
assets/vendor/inter/Inter-SemiBold.woff2 vendored Normal file

Binary file not shown.

30
assets/vendor/inter/inter.css vendored Normal file
View file

@ -0,0 +1,30 @@
/* Inter v4 lokal ausgeliefert (SIL Open Font License 1.1).
Quelle: https://github.com/rsms/inter/releases nur die vier genutzten Schnitte. */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('Inter-Bold.woff2') format('woff2');
}

1
assets/vendor/qrcodejs/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

406
assets/vendor/tinymce/langs/de.js vendored Normal file
View file

@ -0,0 +1,406 @@
tinymce.addI18n("de", {
"Redo": "Wiederholen",
"Undo": "R\xfcckg\xe4ngig machen",
"Cut": "Ausschneiden",
"Copy": "Kopieren",
"Paste": "Einf\xfcgen",
"Select all": "Alles ausw\xe4hlen",
"New document": "Neues Dokument",
"Ok": "Ok",
"Cancel": "Abbrechen",
"Visual aids": "Visuelle Hilfen",
"Bold": "Fett",
"Italic": "Kursiv",
"Underline": "Unterstrichen",
"Strikethrough": "Durchgestrichen",
"Superscript": "Hochgestellt",
"Subscript": "Tiefgestellt",
"Clear formatting": "Formatierung entfernen",
"Remove": "Entfernen",
"Align left": "Linksb\xfcndig ausrichten",
"Align center": "Zentrieren",
"Align right": "Rechtsb\xfcndig ausrichten",
"No alignment": "Keine Ausrichtung",
"Justify": "Blocksatz",
"Bullet list": "Aufz\xe4hlung",
"Numbered list": "Nummerierte Liste",
"Decrease indent": "Einzug verkleinern",
"Increase indent": "Einzug vergr\xf6\xdfern",
"Close": "Schlie\xdfen",
"Formats": "Formate",
"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X/C/V keyboard shortcuts instead.": "Ihr Browser unterst\xfctzt leider keinen direkten Zugriff auf die Zwischenablage. Bitte benutzen Sie die Tastenkombinationen Strg+X/C/V.",
"Headings": "\xdcberschriften",
"Heading 1": "\xdcberschrift 1",
"Heading 2": "\xdcberschrift 2",
"Heading 3": "\xdcberschrift 3",
"Heading 4": "\xdcberschrift 4",
"Heading 5": "\xdcberschrift 5",
"Heading 6": "\xdcberschrift 6",
"Preformatted": "Vorformatiert",
"Div": "Div",
"Pre": "Pre",
"Code": "Code",
"Paragraph": "Absatz",
"Blockquote": "Blockzitat",
"Inline": "Zeichenformate",
"Blocks": "Bl\xf6cke",
"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Einf\xfcgen ist nun im unformatierten Textmodus. Inhalte werden ab jetzt als unformatierter Text eingef\xfcgt, bis Sie diese Einstellung wieder deaktivieren.",
"Fonts": "Schriftarten",
"Font sizes": "Schriftgr\xf6\xdfen",
"Class": "Klasse",
"Browse for an image": "Bild...",
"OR": "ODER",
"Drop an image here": "Bild hier ablegen",
"Upload": "Hochladen",
"Uploading image": "Bild wird hochgeladen",
"Block": "Blocksatz",
"Align": "Ausrichtung",
"Default": "Standard",
"Circle": "Kreis",
"Disc": "Scheibe",
"Square": "Rechteck",
"Lower Alpha": "Lateinisches Alphabet in Kleinbuchstaben",
"Lower Greek": "Griechische Kleinbuchstaben",
"Lower Roman": "Kleiner r\xf6mischer Buchstabe",
"Upper Alpha": "Lateinisches Alphabet in Gro\xdfbuchstaben",
"Upper Roman": "Gro\xdfer r\xf6mischer Buchstabe",
"Anchor...": "Textmarke",
"Anchor": "Anker",
"Name": "Name",
"ID": "ID",
"ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Die ID muss mit einem Buchstaben beginnen gefolgt von Buchstaben, Zahlen, Bindestrichen, Punkten, Doppelpunkten oder Unterstrichen.",
"You have unsaved changes are you sure you want to navigate away?": "Die \xc4nderungen wurden noch nicht gespeichert. Sind Sie sicher, dass Sie diese Seite verlassen wollen?",
"Restore last draft": "Letzten Entwurf wiederherstellen",
"Special character...": "Sonderzeichen...",
"Special Character": "Sonderzeichen",
"Source code": "Quellcode",
"Insert/Edit code sample": "Codebeispiel einf\xfcgen/bearbeiten",
"Language": "Sprache",
"Code sample...": "Codebeispiel...",
"Left to right": "Von links nach rechts",
"Right to left": "Von rechts nach links",
"Title": "Titel",
"Fullscreen": "Vollbild",
"Action": "Aktion",
"Shortcut": "Tastenkombination",
"Help": "Hilfe",
"Address": "Adresse",
"Focus to menubar": "Fokus auf Men\xfcleiste",
"Focus to toolbar": "Fokus auf Symbolleiste",
"Focus to element path": "Fokus auf Elementpfad",
"Focus to contextual toolbar": "Fokus auf kontextbezogene Symbolleiste",
"Insert link (if link plugin activated)": "Link einf\xfcgen (wenn Link-Plugin aktiviert ist)",
"Save (if save plugin activated)": "Speichern (wenn Save-Plugin aktiviert ist)",
"Find (if searchreplace plugin activated)": "Suchen (wenn Suchen/Ersetzen-Plugin aktiviert ist)",
"Plugins installed ({0}):": "Installierte Plugins ({0}):",
"Premium plugins:": "Premium-Plugins:",
"Learn more...": "Erfahren Sie mehr dazu...",
"You are using {0}": "Sie verwenden {0}",
"Plugins": "Plugins",
"Handy Shortcuts": "Praktische Tastenkombinationen",
"Horizontal line": "Horizontale Linie",
"Insert/edit image": "Bild einf\xfcgen/bearbeiten",
"Alternative description": "Alternative Beschreibung",
"Accessibility": "Barrierefreiheit",
"Image is decorative": "Bild ist dekorativ",
"Source": "Quelle",
"Dimensions": "Abmessungen",
"Constrain proportions": "Seitenverh\xe4ltnis beibehalten",
"General": "Allgemein",
"Advanced": "Erweitert",
"Style": "Formatvorlage",
"Vertical space": "Vertikaler Raum",
"Horizontal space": "Horizontaler Raum",
"Border": "Rahmen",
"Insert image": "Bild einf\xfcgen",
"Image...": "Bild...",
"Image list": "Bildliste",
"Resize": "Skalieren",
"Insert date/time": "Datum/Uhrzeit einf\xfcgen",
"Date/time": "Datum/Uhrzeit",
"Insert/edit link": "Link einf\xfcgen/bearbeiten",
"Text to display": "Anzuzeigender Text",
"Url": "URL",
"Open link in...": "Link \xf6ffnen in...",
"Current window": "Aktuelles Fenster",
"None": "Keine",
"New window": "Neues Fenster",
"Open link": "Link \xf6ffnen",
"Remove link": "Link entfernen",
"Anchors": "Anker",
"Link...": "Link...",
"Paste or type a link": "Link einf\xfcgen oder eingeben",
"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "Diese URL scheint eine E-Mail-Adresse zu sein. M\xf6chten Sie das dazu ben\xf6tigte mailto: voranstellen?",
"The URL you entered seems to be an external link. Do you want to add the required http:// prefix?": "Diese URL scheint ein externer Link zu sein. M\xf6chten Sie das dazu ben\xf6tigte http:// voranstellen?",
"The URL you entered seems to be an external link. Do you want to add the required https:// prefix?": "Die eingegebene URL scheint ein externer Link zu sein. Soll das fehlende https:// davor erg\xe4nzt werden?",
"Link list": "Linkliste",
"Insert video": "Video einf\xfcgen",
"Insert/edit video": "Video einf\xfcgen/bearbeiten",
"Insert/edit media": "Medien einf\xfcgen/bearbeiten",
"Alternative source": "Alternative Quelle",
"Alternative source URL": "URL der alternativen Quelle",
"Media poster (Image URL)": "Medienposter (Bild-URL)",
"Paste your embed code below:": "F\xfcgen Sie Ihren Einbettungscode unten ein:",
"Embed": "Einbettung",
"Media...": "Medien...",
"Nonbreaking space": "Gesch\xfctztes Leerzeichen",
"Page break": "Seitenumbruch",
"Paste as text": "Als Text einf\xfcgen",
"Preview": "Vorschau",
"Print": "Drucken",
"Print...": "Drucken...",
"Save": "Speichern",
"Find": "Suchen",
"Replace with": "Ersetzen durch",
"Replace": "Ersetzen",
"Replace all": "Alle ersetzen",
"Previous": "Vorherige",
"Next": "N\xe4chste",
"Find and Replace": "Suchen und Ersetzen",
"Find and replace...": "Suchen und ersetzen...",
"Could not find the specified string.": "Die angegebene Zeichenfolge wurde nicht gefunden.",
"Match case": "Gro\xdf-/Kleinschreibung beachten",
"Find whole words only": "Nur ganze W\xf6rter suchen",
"Find in selection": "In Auswahl suchen",
"Insert table": "Tabelle einf\xfcgen",
"Table properties": "Tabelleneigenschaften",
"Delete table": "Tabelle l\xf6schen",
"Cell": "Zelle",
"Row": "Zeile",
"Column": "Spalte",
"Cell properties": "Zelleigenschaften",
"Merge cells": "Zellen verbinden",
"Split cell": "Zelle aufteilen",
"Insert row before": "Neue Zeile davor einf\xfcgen",
"Insert row after": "Neue Zeile danach einf\xfcgen",
"Delete row": "Zeile l\xf6schen",
"Row properties": "Zeileneigenschaften",
"Cut row": "Zeile ausschneiden",
"Cut column": "Spalte ausschneiden",
"Copy row": "Zeile kopieren",
"Copy column": "Spalte kopieren",
"Paste row before": "Zeile davor einf\xfcgen",
"Paste column before": "Spalte davor einf\xfcgen",
"Paste row after": "Zeile danach einf\xfcgen",
"Paste column after": "Spalte danach einf\xfcgen",
"Insert column before": "Neue Spalte davor einf\xfcgen",
"Insert column after": "Neue Spalte danach einf\xfcgen",
"Delete column": "Spalte l\xf6schen",
"Cols": "Spalten",
"Rows": "Zeilen",
"Width": "Breite",
"Height": "H\xf6he",
"Cell spacing": "Zellenabstand",
"Cell padding": "Zelleninnenabstand",
"Row clipboard actions": "Zeilen-Zwischenablage-Aktionen",
"Column clipboard actions": "Spalten-Zwischenablage-Aktionen",
"Table styles": "Tabellenstil",
"Cell styles": "Zellstil",
"Column header": "Spaltenkopf",
"Row header": "Zeilenkopf",
"Table caption": "Tabellenbeschriftung",
"Caption": "Beschriftung",
"Show caption": "Beschriftung anzeigen",
"Left": "Links",
"Center": "Zentriert",
"Right": "Rechts",
"Cell type": "Zelltyp",
"Scope": "Bereich",
"Alignment": "Ausrichtung",
"Horizontal align": "Horizontal ausrichten",
"Vertical align": "Vertikal ausrichten",
"Top": "Oben",
"Middle": "Mitte",
"Bottom": "Unten",
"Header cell": "Kopfzelle",
"Row group": "Zeilengruppe",
"Column group": "Spaltengruppe",
"Row type": "Zeilentyp",
"Header": "Kopfzeile",
"Body": "Inhalt",
"Footer": "Fu\xdfzeile",
"Border color": "Rahmenfarbe",
"Solid": "Durchgezogen",
"Dotted": "Gepunktet",
"Dashed": "Gestrichelt",
"Double": "Doppelt",
"Groove": "Gekantet",
"Ridge": "Eingeritzt",
"Inset": "Eingelassen",
"Outset": "Hervorstehend",
"Hidden": "Unsichtbar",
"Insert template...": "Vorlage einf\xfcgen...",
"Templates": "Vorlagen",
"Template": "Vorlage",
"Insert Template": "Vorlage einf\xfcgen",
"Text color": "Textfarbe",
"Background color": "Hintergrundfarbe",
"Custom...": "Benutzerdefiniert...",
"Custom color": "Benutzerdefinierte Farbe",
"No color": "Keine Farbe",
"Remove color": "Farbauswahl aufheben",
"Show blocks": "Bl\xf6cke anzeigen",
"Show invisible characters": "Unsichtbare Zeichen anzeigen",
"Word count": "Anzahl der W\xf6rter",
"Count": "Anzahl",
"Document": "Dokument",
"Selection": "Auswahl",
"Words": "W\xf6rter",
"Words: {0}": "Wortzahl: {0}",
"{0} words": "{0} W\xf6rter",
"File": "Datei",
"Edit": "Bearbeiten",
"Insert": "Einf\xfcgen",
"View": "Ansicht",
"Format": "Format",
"Table": "Tabelle",
"Tools": "Werkzeuge",
"Powered by {0}": "Betrieben von {0}",
"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Rich-Text-Bereich. Dr\xfccken Sie Alt+F9 f\xfcr das Men\xfc. Dr\xfccken Sie Alt+F10 f\xfcr die Symbolleiste. Dr\xfccken Sie Alt+0 f\xfcr Hilfe.",
"Image title": "Bildtitel",
"Border width": "Rahmenbreite",
"Border style": "Rahmenstil",
"Error": "Fehler",
"Warn": "Warnung",
"Valid": "G\xfcltig",
"To open the popup, press Shift+Enter": "Dr\xfccken Sie Umschalt+Eingabe, um das Popup-Fenster zu \xf6ffnen.",
"Rich Text Area": "Rich-Text-Area",
"Rich Text Area. Press ALT-0 for help.": "Rich-Text-Bereich. Dr\xfccken Sie Alt+0 f\xfcr Hilfe.",
"System Font": "Betriebssystemschriftart",
"Failed to upload image: {0}": "Bild konnte nicht hochgeladen werden: {0}",
"Failed to load plugin: {0} from url {1}": "Plugin konnte nicht geladen werden: {0} von URL {1}",
"Failed to load plugin url: {0}": "Plugin-URL konnte nicht geladen werden: {0}",
"Failed to initialize plugin: {0}": "Plugin konnte nicht initialisiert werden: {0}",
"example": "Beispiel",
"Search": "Suchen",
"All": "Alle",
"Currency": "W\xe4hrung",
"Text": "Text",
"Quotations": "Anf\xfchrungszeichen",
"Mathematical": "Mathematisch",
"Extended Latin": "Erweitertes Latein",
"Symbols": "Symbole",
"Arrows": "Pfeile",
"User Defined": "Benutzerdefiniert",
"dollar sign": "Dollarzeichen",
"currency sign": "W\xe4hrungssymbol",
"euro-currency sign": "Eurozeichen",
"colon sign": "Doppelpunkt",
"cruzeiro sign": "Cruzeirozeichen",
"french franc sign": "Franczeichen",
"lira sign": "Lirezeichen",
"mill sign": "Millzeichen",
"naira sign": "Nairazeichen",
"peseta sign": "Pesetazeichen",
"rupee sign": "Rupiezeichen",
"won sign": "Wonzeichen",
"new sheqel sign": "Schekelzeichen",
"dong sign": "Dongzeichen",
"kip sign": "Kipzeichen",
"tugrik sign": "Tugrikzeichen",
"drachma sign": "Drachmezeichen",
"german penny symbol": "Pfennigzeichen",
"peso sign": "Pesozeichen",
"guarani sign": "Guaranizeichen",
"austral sign": "Australzeichen",
"hryvnia sign": "Hrywnjazeichen",
"cedi sign": "Cedizeichen",
"livre tournois sign": "Livrezeichen",
"spesmilo sign": "Spesmilozeichen",
"tenge sign": "Tengezeichen",
"indian rupee sign": "Indisches Rupiezeichen",
"turkish lira sign": "T\xfcrkisches Lirazeichen",
"nordic mark sign": "Zeichen nordische Mark",
"manat sign": "Manatzeichen",
"ruble sign": "Rubelzeichen",
"yen character": "Yenzeichen",
"yuan character": "Yuanzeichen",
"yuan character, in hong kong and taiwan": "Yuanzeichen in Hongkong und Taiwan",
"yen/yuan character variant one": "Yen-/Yuanzeichen Variante 1",
"Emojis": "Emojis",
"Emojis...": "Emojis...",
"Loading emojis...": "Lade Emojis...",
"Could not load emojis": "Emojis konnten nicht geladen werden",
"People": "Menschen",
"Animals and Nature": "Tiere und Natur",
"Food and Drink": "Essen und Trinken",
"Activity": "Aktivit\xe4t",
"Travel and Places": "Reisen und Orte",
"Objects": "Objekte",
"Flags": "Flaggen",
"Characters": "Zeichen",
"Characters (no spaces)": "Zeichen (ohne Leerzeichen)",
"{0} characters": "{0}\xa0Zeichen",
"Error: Form submit field collision.": "Fehler: Kollision der Formularbest\xe4tigungsfelder.",
"Error: No form element found.": "Fehler: Kein Formularelement gefunden.",
"Color swatch": "Farbpalette",
"Color Picker": "Farbwahl",
"Invalid hex color code: {0}": "Ung\xfcltiger Hexadezimal-Farbwert: {0}",
"Invalid input": "Ung\xfcltige Eingabe",
"R": "R",
"Red component": "Rotanteil",
"G": "G",
"Green component": "Gr\xfcnanteil",
"B": "B",
"Blue component": "Blauanteil",
"#": "#",
"Hex color code": "Hexadezimal-Farbwert",
"Range 0 to 255": "Spanne 0 bis 255",
"Turquoise": "T\xfcrkis",
"Green": "Gr\xfcn",
"Blue": "Blau",
"Purple": "Violett",
"Navy Blue": "Marineblau",
"Dark Turquoise": "Dunkelt\xfcrkis",
"Dark Green": "Dunkelgr\xfcn",
"Medium Blue": "Mittleres Blau",
"Medium Purple": "Mittelviolett",
"Midnight Blue": "Mitternachtsblau",
"Yellow": "Gelb",
"Orange": "Orange",
"Red": "Rot",
"Light Gray": "Hellgrau",
"Gray": "Grau",
"Dark Yellow": "Dunkelgelb",
"Dark Orange": "Dunkelorange",
"Dark Red": "Dunkelrot",
"Medium Gray": "Mittelgrau",
"Dark Gray": "Dunkelgrau",
"Light Green": "Hellgr\xfcn",
"Light Yellow": "Hellgelb",
"Light Red": "Hellrot",
"Light Purple": "Helllila",
"Light Blue": "Hellblau",
"Dark Purple": "Dunkellila",
"Dark Blue": "Dunkelblau",
"Black": "Schwarz",
"White": "Wei\xdf",
"Switch to or from fullscreen mode": "Vollbildmodus umschalten",
"Open help dialog": "Hilfe-Dialog \xf6ffnen",
"history": "Historie",
"styles": "Stile",
"formatting": "Formatierung",
"alignment": "Ausrichtung",
"indentation": "Einr\xfcckungen",
"Font": "Schriftart",
"Size": "Schriftgr\xf6\xdfe",
"More...": "Mehr...",
"Select...": "Auswahl...",
"Preferences": "Einstellungen",
"Yes": "Ja",
"No": "Nein",
"Keyboard Navigation": "Tastaturnavigation",
"Version": "Version",
"Code view": "Code Ansicht",
"Open popup menu for split buttons": "\xd6ffne Popup Menge um Buttons zu trennen",
"List Properties": "Liste Eigenschaften",
"List properties...": "Liste Eigenschaften",
"Start list at number": "Beginne Liste mit Nummer",
"Line height": "Liniendicke",
"Dropped file type is not supported": "Hereingezogener Dateityp wird nicht unterst\xfctzt",
"Loading...": "Wird geladen...",
"ImageProxy HTTP error: Rejected request": "Image Proxy HTTP Fehler: Abgewiesene Anfrage",
"ImageProxy HTTP error: Could not find Image Proxy": "Image Proxy HTTP Fehler: Kann Image Proxy nicht finden",
"ImageProxy HTTP error: Incorrect Image Proxy URL": "Image Proxy HTTP Fehler: Falsche Image Proxy URL",
"ImageProxy HTTP error: Unknown ImageProxy error": "Image Proxy HTTP Fehler: Unbekannter Image Proxy Fehler"
});

21
assets/vendor/tinymce/license.txt vendored Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,4 @@
/**
* TinyMCE version 6.8.3 (2024-02-08)
*/
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),i=t=>null==t,l=t=>!i(t);var o=tinymce.util.Tools.resolve("tinymce.util.Tools");class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return l(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=t=>e=>l(e)&&t.test(e.nodeName),d=u(/^(OL|UL|DL)$/),g=u(/^(TH|TD)$/),c=t=>i(t)||"default"===t?"":t,h=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,(r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let e=0,n=t.length;e<n;e++){const n=t[e];if(d(r=n)&&!/\btox\-/.test(r.className))return a.some(n);if(s(n,e))break}var r;return a.none()})(e,0,g).exists((e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e))))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)&&t.selection.isEditable()})(t,n)&&t.selection.isEditable())})(t,r.parents))),m=(t,s,r,n,i,l)=>{l.length>1?((t,s,r,n,i,l)=>{t.ui.registry.addSplitButton(s,{tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:t=>{t(o.map(l,(t=>{const e="OL"===i?"num":"bull",s="disc"===t||"decimal"===t?"default":t,r=c(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,(t=>t.toUpperCase())))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}})))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,i,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return a.from(s)})(t);return s.map((t=>e===t)).getOr(!1)},onSetup:h(t,i)})})(t,s,r,n,i,l):((t,s,r,n,i,l)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",onSetup:h(t,i),onAction:()=>t.queryCommandState(n)||""===l?t.execCommand(n):e(t,i,l)})})(t,s,r,n,i,c(l[0]))};t.add("advlist",(t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,circle,square".split(",")})})(t),(t=>{m(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),m(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",((s,r)=>{e(t,"UL",r["list-style-type"])})),t.addCommand("ApplyOrderedListStyle",((s,r)=>{e(t,"OL",r["list-style-type"])}))})(t)):console.error("Please use the Lists plugin together with the Advanced List plugin.")}))}();

View file

@ -0,0 +1,4 @@
/**
* TinyMCE version 6.8.3 (2024-02-08)
*/
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),n=t("autolink_pattern"),o=t("link_default_target"),r=t("link_default_protocol"),a=t("allow_unsafe_link_target"),s=("string",e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e));const l=(void 0,e=>undefined===e);const i=e=>!(e=>null==e)(e),c=Object.hasOwnProperty,d=e=>"\ufeff"===e;var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!d(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=n(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]"))return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),c.call(t,n))||"false"===i.getContentEditable(e);var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return $="www.",(b=t).length>=4&&b.substr(0,4)===$?t=r(e)+"://"+t:((e,t,n=0,o)=>{const r=e.indexOf(t,n);return-1!==r&&(!!l(o)||r+t.length<=o)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,$;return null},k=(e,t)=>{const{dom:n,selection:r}=e,{rng:l,url:i}=t,c=r.getBookmark();r.setRng(l);const d="createlink",u={command:d,ui:!1,value:i};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(d,!1,i),e.dispatch("ExecCommand",u);const t=o(e);if(s(t)){const o=r.getNode();n.setAttrib(o,"target",t),"_blank"!==t||a(e)||n.setAttrib(o,"rel","noopener")}}r.moveToBookmark(c),e.nodeChanged()},p=e=>{const t=m(e,-1);i(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);i(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}();

View file

@ -0,0 +1,4 @@
/**
* TinyMCE version 6.8.3 (2024-02-08)
*/
!function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",(e=>((e=>{e.addCommand("mceCodeEditor",(()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact((()=>{e.setContent(o)})),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)}))})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{})))}();

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,90 @@
tinymce.Resource.add('tinymce.html-i18n.help-keynav.de',
'<h1>Grundlagen der Tastaturnavigation</h1>\n' +
'\n' +
'<dl>\n' +
' <dt>Fokus auf Menüleiste</dt>\n' +
' <dd>Windows oder Linux: ALT+F9</dd>\n' +
' <dd>macOS: &#x2325;F9</dd>\n' +
' <dt>Fokus auf Symbolleiste</dt>\n' +
' <dd>Windows oder Linux: ALT+F10</dd>\n' +
' <dd>macOS: &#x2325;F10</dd>\n' +
' <dt>Fokus auf Fußzeile</dt>\n' +
' <dd>Windows oder Linux: ALT+F11</dd>\n' +
' <dd>macOS: &#x2325;F11</dd>\n' +
' <dt>Fokus auf kontextbezogene Symbolleiste</dt>\n' +
' <dd>Windows, Linux oder macOS: STRG+F9\n' +
'</dl>\n' +
'\n' +
'<p>Die Navigation beginnt beim ersten Benutzeroberflächenelement, welches hervorgehoben ist. Falls sich das erste Element im Pfad der Fußzeile befindet,\n' +
' ist es unterstrichen.</p>\n' +
'\n' +
'<h1>Zwischen Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
'\n' +
'<p>Um von einem Abschnitt der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie <strong>TAB</strong>.</p>\n' +
'\n' +
'<p>Um von einem Abschnitt der Benutzeroberfläche zum vorherigen zu wechseln, drücken Sie <strong>UMSCHALT+TAB</strong>.</p>\n' +
'\n' +
'<p>Die Abschnitte der Benutzeroberfläche haben folgende <strong>TAB</strong>-Reihenfolge:</p>\n' +
'\n' +
'<ol>\n' +
' <li>Menüleiste</li>\n' +
' <li>Einzelne Gruppen der Symbolleiste</li>\n' +
' <li>Randleiste</li>\n' +
' <li>Elementpfad in der Fußzeile</li>\n' +
' <li>Umschaltfläche „Wörter zählen“ in der Fußzeile</li>\n' +
' <li>Branding-Link in der Fußzeile</li>\n' +
' <li>Editor-Ziehpunkt zur Größenänderung in der Fußzeile</li>\n' +
'</ol>\n' +
'\n' +
'<p>Falls ein Abschnitt der Benutzeroberflächen nicht vorhanden ist, wird er übersprungen.</p>\n' +
'\n' +
'<p>Wenn in der Fußzeile die Tastaturnavigation fokussiert ist und keine Randleiste angezeigt wird, wechselt der Fokus durch Drücken von <strong>UMSCHALT+TAB</strong>\n' +
' zur ersten Gruppe der Symbolleiste, nicht zur letzten.</p>\n' +
'\n' +
'<h1>Innerhalb von Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
'\n' +
'<p>Um von einem Element der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie die entsprechende <strong>Pfeiltaste</strong>.</p>\n' +
'\n' +
'<p>Die Pfeiltasten <strong>Links</strong> und <strong>Rechts</strong></p>\n' +
'\n' +
'<ul>\n' +
' <li>wechseln zwischen Menüs in der Menüleiste.</li>\n' +
' <li>öffnen das Untermenü eines Menüs.</li>\n' +
' <li>wechseln zwischen Schaltflächen in einer Gruppe der Symbolleiste.</li>\n' +
' <li>wechseln zwischen Elementen im Elementpfad der Fußzeile.</li>\n' +
'</ul>\n' +
'\n' +
'<p>Die Pfeiltasten <strong>Abwärts</strong> und <strong>Aufwärts</strong></p>\n' +
'\n' +
'<ul>\n' +
' <li>wechseln zwischen Menüelementen in einem Menü.</li>\n' +
' <li>wechseln zwischen Elementen in einem Popupmenü der Symbolleiste.</li>\n' +
'</ul>\n' +
'\n' +
'<p>Die <strong>Pfeiltasten</strong> rotieren innerhalb des fokussierten Abschnitts der Benutzeroberfläche.</p>\n' +
'\n' +
'<p>Um ein geöffnetes Menü, ein geöffnetes Untermenü oder ein geöffnetes Popupmenü zu schließen, drücken Sie die <strong>ESC</strong>-Taste.</p>\n' +
'\n' +
'<p>Wenn sich der aktuelle Fokus ganz oben in einem bestimmten Abschnitt der Benutzeroberfläche befindet, wird durch Drücken der <strong>ESC</strong>-Taste auch\n' +
' die Tastaturnavigation beendet.</p>\n' +
'\n' +
'<h1>Ein Menüelement oder eine Symbolleistenschaltfläche ausführen</h1>\n' +
'\n' +
'<p>Wenn das gewünschte Menüelement oder die gewünschte Symbolleistenschaltfläche hervorgehoben ist, drücken Sie <strong>Zurück</strong>, <strong>Eingabe</strong>\n' +
' oder die <strong>Leertaste</strong>, um das Element auszuführen.</p>\n' +
'\n' +
'<h1>In Dialogfeldern ohne Registerkarten navigieren</h1>\n' +
'\n' +
'<p>In Dialogfeldern ohne Registerkarten ist beim Öffnen eines Dialogfelds die erste interaktive Komponente fokussiert.</p>\n' +
'\n' +
'<p>Navigieren Sie zwischen den interaktiven Komponenten eines Dialogfelds, indem Sie <strong>TAB</strong> oder <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
'\n' +
'<h1>In Dialogfeldern mit Registerkarten navigieren</h1>\n' +
'\n' +
'<p>In Dialogfeldern mit Registerkarten ist beim Öffnen eines Dialogfelds die erste Schaltfläche eines Registerkartenmenüs fokussiert.</p>\n' +
'\n' +
'<p>Navigieren Sie zwischen den interaktiven Komponenten auf dieser Registerkarte des Dialogfelds, indem Sie <strong>TAB</strong> oder\n' +
' <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
'\n' +
'<p>Wechseln Sie zu einer anderen Registerkarte des Dialogfelds, indem Sie den Fokus auf das Registerkartenmenü legen und dann die entsprechende <strong>Pfeiltaste</strong>\n' +
' drücken, um durch die verfügbaren Registerkarten zu rotieren.</p>\n');

View file

@ -0,0 +1,90 @@
tinymce.Resource.add('tinymce.html-i18n.help-keynav.en',
'<h1>Begin keyboard navigation</h1>\n' +
'\n' +
'<dl>\n' +
' <dt>Focus the Menu bar</dt>\n' +
' <dd>Windows or Linux: Alt+F9</dd>\n' +
' <dd>macOS: &#x2325;F9</dd>\n' +
' <dt>Focus the Toolbar</dt>\n' +
' <dd>Windows or Linux: Alt+F10</dd>\n' +
' <dd>macOS: &#x2325;F10</dd>\n' +
' <dt>Focus the footer</dt>\n' +
' <dd>Windows or Linux: Alt+F11</dd>\n' +
' <dd>macOS: &#x2325;F11</dd>\n' +
' <dt>Focus a contextual toolbar</dt>\n' +
' <dd>Windows, Linux or macOS: Ctrl+F9\n' +
'</dl>\n' +
'\n' +
'<p>Navigation will start at the first UI item, which will be highlighted, or underlined in the case of the first item in\n' +
' the Footer element path.</p>\n' +
'\n' +
'<h1>Navigate between UI sections</h1>\n' +
'\n' +
'<p>To move from one UI section to the next, press <strong>Tab</strong>.</p>\n' +
'\n' +
'<p>To move from one UI section to the previous, press <strong>Shift+Tab</strong>.</p>\n' +
'\n' +
'<p>The <strong>Tab</strong> order of these UI sections is:</p>\n' +
'\n' +
'<ol>\n' +
' <li>Menu bar</li>\n' +
' <li>Each toolbar group</li>\n' +
' <li>Sidebar</li>\n' +
' <li>Element path in the footer</li>\n' +
' <li>Word count toggle button in the footer</li>\n' +
' <li>Branding link in the footer</li>\n' +
' <li>Editor resize handle in the footer</li>\n' +
'</ol>\n' +
'\n' +
'<p>If a UI section is not present, it is skipped.</p>\n' +
'\n' +
'<p>If the footer has keyboard navigation focus, and there is no visible sidebar, pressing <strong>Shift+Tab</strong>\n' +
' moves focus to the first toolbar group, not the last.</p>\n' +
'\n' +
'<h1>Navigate within UI sections</h1>\n' +
'\n' +
'<p>To move from one UI element to the next, press the appropriate <strong>Arrow</strong> key.</p>\n' +
'\n' +
'<p>The <strong>Left</strong> and <strong>Right</strong> arrow keys</p>\n' +
'\n' +
'<ul>\n' +
' <li>move between menus in the menu bar.</li>\n' +
' <li>open a sub-menu in a menu.</li>\n' +
' <li>move between buttons in a toolbar group.</li>\n' +
' <li>move between items in the footers element path.</li>\n' +
'</ul>\n' +
'\n' +
'<p>The <strong>Down</strong> and <strong>Up</strong> arrow keys</p>\n' +
'\n' +
'<ul>\n' +
' <li>move between menu items in a menu.</li>\n' +
' <li>move between items in a toolbar pop-up menu.</li>\n' +
'</ul>\n' +
'\n' +
'<p><strong>Arrow</strong> keys cycle within the focused UI section.</p>\n' +
'\n' +
'<p>To close an open menu, an open sub-menu, or an open pop-up menu, press the <strong>Esc</strong> key.</p>\n' +
'\n' +
'<p>If the current focus is at the top of a particular UI section, pressing the <strong>Esc</strong> key also exits\n' +
' keyboard navigation entirely.</p>\n' +
'\n' +
'<h1>Execute a menu item or toolbar button</h1>\n' +
'\n' +
'<p>When the desired menu item or toolbar button is highlighted, press <strong>Return</strong>, <strong>Enter</strong>,\n' +
' or the <strong>Space bar</strong> to execute the item.</p>\n' +
'\n' +
'<h1>Navigate non-tabbed dialogs</h1>\n' +
'\n' +
'<p>In non-tabbed dialogs, the first interactive component takes focus when the dialog opens.</p>\n' +
'\n' +
'<p>Navigate between interactive dialog components by pressing <strong>Tab</strong> or <strong>Shift+Tab</strong>.</p>\n' +
'\n' +
'<h1>Navigate tabbed dialogs</h1>\n' +
'\n' +
'<p>In tabbed dialogs, the first button in the tab menu takes focus when the dialog opens.</p>\n' +
'\n' +
'<p>Navigate between interactive components of this dialog tab by pressing <strong>Tab</strong> or\n' +
' <strong>Shift+Tab</strong>.</p>\n' +
'\n' +
'<p>Switch to another dialog tab by giving the tab menu focus and then pressing the appropriate <strong>Arrow</strong>\n' +
' key to cycle through the available tabs.</p>\n');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,4 @@
/**
* TinyMCE version 6.8.3 (2024-02-08)
*/
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const s=(t,s,o)=>{t.dom.toggleClass(t.getBody(),"mce-visualblocks"),o.set(!o.get()),((t,s)=>{t.dispatch("VisualBlocks",{state:s})})(t,o.get())},o=("visualblocks_default_state",t=>t.options.get("visualblocks_default_state"));const e=(t,s)=>o=>{o.setActive(s.get());const e=t=>o.setActive(t.state);return t.on("VisualBlocks",e),()=>t.off("VisualBlocks",e)};t.add("visualblocks",((t,l)=>{(t=>{(0,t.options.register)("visualblocks_default_state",{processor:"boolean",default:!1})})(t);const a=(t=>{let s=!1;return{get:()=>s,set:t=>{s=t}}})();((t,o,e)=>{t.addCommand("mceVisualBlocks",(()=>{s(t,0,e)}))})(t,0,a),((t,s)=>{const o=()=>t.execCommand("mceVisualBlocks");t.ui.registry.addToggleButton("visualblocks",{icon:"visualblocks",tooltip:"Show blocks",onAction:o,onSetup:e(t,s)}),t.ui.registry.addToggleMenuItem("visualblocks",{text:"Show blocks",icon:"visualblocks",onAction:o,onSetup:e(t,s)})})(t,a),((t,e,l)=>{t.on("PreviewFormats AfterPreviewFormats",(s=>{l.get()&&t.dom.toggleClass(t.getBody(),"mce-visualblocks","afterpreviewformats"===s.type)})),t.on("init",(()=>{o(t)&&s(t,0,l)}))})(t,0,a)}))}();

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
body{background-color:#222f3e;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem}

View file

@ -0,0 +1 @@
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
assets/vendor/tinymce/tinymce.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,9 @@ services:
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));" # nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
APP_KEY: "" APP_KEY: ""
TZ: Europe/Berlin TZ: Europe/Berlin
volumes:
# Hochgeladene Logos/Hintergruende ueberleben so ein Image-Update
- uploads:/var/www/html/uploads
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@ -36,3 +39,4 @@ services:
volumes: volumes:
db_data: db_data:
uploads:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 386 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 379 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

After

Width:  |  Height:  |  Size: 465 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 304 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 412 KiB

Before After
Before After

View file

@ -7,6 +7,7 @@ require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php'; require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php'; require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/Mailer.php'; require_once __DIR__ . '/includes/Mailer.php';
require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/I18n.php'; require_once __DIR__ . '/includes/I18n.php';
$auth = new Auth(); $auth = new Auth();
@ -83,17 +84,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('reset_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title><?= __('reset_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <?= Ui::head($db) ?>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
</head> </head>
<body class="app-body focus-page"> <body class="app-body focus-page">
<div class="focus-card card"> <div class="focus-card card">
<?php if ($logoUrl): ?> <?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo"> <img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
<?php else: ?> <?php else: ?>
<h1><?= htmlspecialchars($appTitle) ?></h1> <h1><?= htmlspecialchars($appTitle) ?></h1>
<?php endif; ?> <?php endif; ?>
@ -118,7 +114,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
</form> </form>
<?php endif; ?> <?php endif; ?>
<div class="auth-links"><a href="login.php" class="back-link"><i class="fas fa-arrow-left"></i> <?= __('reset_back_login') ?></a></div> <div class="auth-links"><a href="login.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('reset_back_login') ?></a></div>
</div> </div>
<script src="assets/global.js"></script> <script src="assets/global.js"></script>
</body> </body>

2
includes/.htaccess Normal file
View file

@ -0,0 +1,2 @@
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
Require all denied

View file

@ -1,265 +1,339 @@
<?php <?php
class Mailer { class Mailer {
private $db; private $db;
private $smtpEnabled; private $smtpEnabled;
private $smtpHost; private $smtpHost;
private $smtpPort; private $smtpPort;
private $smtpUsername; private $smtpUsername;
private $smtpPassword; private $smtpPassword;
private $smtpEncryption; private $smtpEncryption;
private $fromEmail; private $fromEmail;
private $fromName; private $fromName;
public function __construct() { public function __construct() {
$this->db = Database::getInstance(); $this->db = Database::getInstance();
$this->loadSettings(); $this->loadSettings();
} }
private function loadSettings() { private function loadSettings() {
$this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1'; $this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1';
$this->smtpHost = $this->db->getSetting('smtp_host', ''); $this->smtpHost = $this->db->getSetting('smtp_host', '');
$this->smtpPort = (int)$this->db->getSetting('smtp_port', '587'); $this->smtpPort = (int)$this->db->getSetting('smtp_port', '587');
$this->smtpUsername = $this->db->getSetting('smtp_username', ''); $this->smtpUsername = $this->db->getSetting('smtp_username', '');
$this->smtpPassword = $this->db->getSetting('smtp_password', ''); $this->smtpPassword = $this->db->getSetting('smtp_password', '');
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls'); $this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']); $this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']);
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System')); $this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
} }
public function sendRaw($to, $subject, $plainBody) { public function sendRaw($to, $subject, $plainBody) {
return $this->send($to, $subject, $plainBody, false); return $this->send($to, $subject, $plainBody, false);
} }
public function send($to, $subject, $body, $isHtml = false) { public function send($to, $subject, $body, $isHtml = false) {
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry). // Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
$attempts = 2; $attempts = 2;
for ($i = 1; $i <= $attempts; $i++) { for ($i = 1; $i <= $attempts; $i++) {
if (!$this->smtpEnabled || empty($this->smtpHost)) { if (!$this->smtpEnabled || empty($this->smtpHost)) {
$ok = $this->sendWithPhpMail($to, $subject, $body); $ok = $this->sendWithPhpMail($to, $subject, $body);
} else { } else {
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml); $ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
} }
if ($ok) { if ($ok) {
return true; return true;
} }
if ($i < $attempts) { if ($i < $attempts) {
usleep(500000); // 0,5s vor erneutem Versuch usleep(500000); // 0,5s vor erneutem Versuch
} }
} }
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen."); error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
return false; return false;
} }
private function sendWithPhpMail($to, $subject, $body) { private function sendWithPhpMail($to, $subject, $body) {
$headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; $headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
$headers .= "Reply-To: {$this->fromEmail}\r\n"; $headers .= "Reply-To: {$this->fromEmail}\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; $headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
return mail($to, $subject, $body, $headers); return mail($to, $subject, $body, $headers);
} }
private function sendWithSmtp($to, $subject, $body, $isHtml = false) { private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
try { try {
// Verbindung aufbauen // Verbindung aufbauen
$socket = $this->connectToSmtp(); $socket = $this->connectToSmtp();
// EHLO // EHLO
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); $this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
// STARTTLS wenn nötig // STARTTLS wenn nötig
if ($this->smtpEncryption === 'tls') { if ($this->smtpEncryption === 'tls') {
$this->smtpCommand($socket, "STARTTLS"); $this->smtpCommand($socket, "STARTTLS");
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); $this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
} }
// AUTH LOGIN // AUTH LOGIN
$this->smtpCommand($socket, "AUTH LOGIN"); $this->smtpCommand($socket, "AUTH LOGIN");
$this->smtpCommand($socket, base64_encode($this->smtpUsername)); $this->smtpCommand($socket, base64_encode($this->smtpUsername));
$this->smtpCommand($socket, base64_encode($this->smtpPassword)); $this->smtpCommand($socket, base64_encode($this->smtpPassword));
// MAIL FROM // MAIL FROM
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>"); $this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
// RCPT TO // RCPT TO
$this->smtpCommand($socket, "RCPT TO:<{$to}>"); $this->smtpCommand($socket, "RCPT TO:<{$to}>");
// DATA // DATA
$this->smtpCommand($socket, "DATA"); $this->smtpCommand($socket, "DATA");
// Headers // Headers
$message = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; $message = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
$message .= "To: {$to}\r\n"; $message .= "To: {$to}\r\n";
$message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n"; $message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
$message .= "MIME-Version: 1.0\r\n"; $message .= "MIME-Version: 1.0\r\n";
if ($isHtml) { if ($isHtml) {
$message .= "Content-Type: text/html; charset=UTF-8\r\n"; $message .= "Content-Type: text/html; charset=UTF-8\r\n";
} else { } else {
$message .= "Content-Type: text/plain; charset=UTF-8\r\n"; $message .= "Content-Type: text/plain; charset=UTF-8\r\n";
} }
$message .= "\r\n"; $message .= "\r\n";
// Body - bei Plain Text Zeilenumbrüche konvertieren // Body - bei Plain Text Zeilenumbrüche konvertieren
if (!$isHtml) { if (!$isHtml) {
$body = nl2br($body, false); // Für Plain Text $body = nl2br($body, false); // Für Plain Text
$body = str_replace('<br>', "\r\n", $body); $body = str_replace('<br>', "\r\n", $body);
} }
$message .= $body; $message .= $body;
$message .= "\r\n.\r\n"; $message .= "\r\n.\r\n";
fwrite($socket, $message); fwrite($socket, $message);
$response = fgets($socket); $response = fgets($socket);
// QUIT // QUIT
$this->smtpCommand($socket, "QUIT"); $this->smtpCommand($socket, "QUIT");
fclose($socket); fclose($socket);
return strpos($response, '250') === 0; return strpos($response, '250') === 0;
} catch (Exception $e) { } catch (Exception $e) {
error_log("SMTP Error: " . $e->getMessage()); error_log("SMTP Error: " . $e->getMessage());
return false; return false;
} }
} }
private function connectToSmtp() { private function connectToSmtp() {
$context = stream_context_create([ $context = stream_context_create([
'ssl' => [ 'ssl' => [
'verify_peer' => false, 'verify_peer' => false,
'verify_peer_name' => false, 'verify_peer_name' => false,
'allow_self_signed' => true 'allow_self_signed' => true
] ]
]); ]);
if ($this->smtpEncryption === 'ssl') { if ($this->smtpEncryption === 'ssl') {
$host = 'ssl://' . $this->smtpHost; $host = 'ssl://' . $this->smtpHost;
} else { } else {
$host = $this->smtpHost; $host = $this->smtpHost;
} }
$socket = stream_socket_client( $socket = stream_socket_client(
$host . ':' . $this->smtpPort, $host . ':' . $this->smtpPort,
$errno, $errno,
$errstr, $errstr,
30, 30,
STREAM_CLIENT_CONNECT, STREAM_CLIENT_CONNECT,
$context $context
); );
if (!$socket) { if (!$socket) {
throw new Exception("SMTP Connection failed: $errstr ($errno)"); throw new Exception("SMTP Connection failed: $errstr ($errno)");
} }
// Willkommensnachricht lesen // Willkommensnachricht lesen
fgets($socket); fgets($socket);
return $socket; return $socket;
} }
private function smtpCommand($socket, $command) { private function smtpCommand($socket, $command) {
fwrite($socket, $command . "\r\n"); fwrite($socket, $command . "\r\n");
$response = fgets($socket); $response = fgets($socket);
// Prüfen auf Fehler (4xx oder 5xx) // Prüfen auf Fehler (4xx oder 5xx)
if (preg_match('/^[45]/', $response)) { if (preg_match('/^[45]/', $response)) {
throw new Exception("SMTP Error: $response"); throw new Exception("SMTP Error: $response");
} }
return $response; return $response;
} }
// Vordefinierte E-Mail-Templates // Vordefinierte E-Mail-Templates
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) { /**
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); * Legt den Nachrichtentext in ein schlichtes, markentreues HTML-Gerüst.
$instructionHeader = $this->db->getSetting('instruction_header', ''); * Bewusst Tabellen + Inline-Styles: nur so rendern Outlook & Co. zuverlässig.
$instructionText = $this->db->getSetting('instruction_text', ''); */
private function brandedHtml(string $title, string $contentHtml, string $footerNote = ''): string
// System-URL aus Einstellungen oder automatisch erkennen {
$systemUrl = $this->db->getSetting('system_url', ''); $accent = $this->db->getSetting('brand_gradient_from', '') ?: '#5b5bd6';
if (empty($systemUrl)) { $accent2 = $this->db->getSetting('brand_gradient_to', '') ?: '#8b5cf6';
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent)) { $accent = '#5b5bd6'; }
$host = $_SERVER['HTTP_HOST']; if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent2)) { $accent2 = '#8b5cf6'; }
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath; $safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$systemUrl = $protocol . '://' . $host . $scriptPath; $year = date('Y');
} $footer = $footerNote !== '' ? '<div style="margin-top:8px;">' . htmlspecialchars($footerNote, ENT_QUOTES, 'UTF-8') . '</div>' : '';
// Template aus Datenbank laden return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'); . '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
$bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nIhr WLAN-Zugangscode lautet:\n\n<strong>{VOUCHER_CODE}</strong>\n\nGültigkeit: 8 Stunden ab Erstellung\nMaximale Geräte: {MAX_USES}\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); . '<title>' . $safeTitle . '</title></head>'
. '<body style="margin:0;padding:0;background:#f6f7f9;">'
// Anleitung formatieren . '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f7f9;padding:28px 12px;">'
$instructions = ''; . '<tr><td align="center">'
if ($instructionText) { . '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border:1px solid #e5e8ee;border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;">'
$instructions = $instructionHeader . "\n" . $instructionText; . '<tr><td style="background:' . $accent . ';background-image:linear-gradient(135deg,' . $accent . ' 0%,' . $accent2 . ' 100%);padding:22px 26px;">'
} . '<div style="color:#ffffff;font-size:16px;font-weight:600;letter-spacing:-0.01em;">' . $safeTitle . '</div>'
. '</td></tr>'
// Platzhalter ersetzen . '<tr><td style="padding:26px;color:#101625;font-size:15px;line-height:1.6;">' . $contentHtml . '</td></tr>'
$placeholders = [ . '<tr><td style="padding:16px 26px;background:#f8f9fb;border-top:1px solid #e5e8ee;color:#6b7280;font-size:12px;">'
'{VOUCHER_CODE}' => $voucherCode, . '&copy; ' . $year . ' ' . $safeTitle . $footer
'{SITE_NAME}' => $siteName, . '</td></tr>'
'{MAX_USES}' => $maxUses, . '</table></td></tr></table></body></html>';
'{APP_TITLE}' => $appTitle, }
'{INSTRUCTIONS}' => $instructions,
'{SYSTEM_URL}' => $systemUrl /**
]; * Voucher-Code als hervorgehobene Karte für die E-Mail.
*/
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); private function voucherCardHtml(string $code, string $siteName, $maxUses): string
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate); {
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
// HTML oder Plain Text prüfen . 'style="margin:18px 0;background:#f8f9fb;border:1px solid #e5e8ee;border-radius:12px;">'
$isHtml = strip_tags($body) !== $body; . '<tr><td align="center" style="padding:22px;">'
. '<div style="font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">'
return $this->send($to, $subject, $body, $isHtml); . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '</div>'
} . '<div style="margin:10px 0;font-family:Consolas,Menlo,monospace;font-size:28px;font-weight:700;letter-spacing:.12em;color:#101625;">'
. htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</div>'
public function sendTestEmail($to) { . '<div style="font-size:13px;color:#525c6e;">'
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); . htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' '
$subject = '[Test] E-Mail-Konfiguration ' . $appTitle; . htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '</div>'
$body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet."; . '</td></tr></table>';
return $this->send($to, $subject, $body, false); }
}
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
public function sendUserNotification($to, $userName, $changes) { $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); $instructionHeader = $this->db->getSetting('instruction_header', '');
$instructionText = $this->db->getSetting('instruction_text', '');
// System-URL aus Einstellungen oder automatisch erkennen
$systemUrl = $this->db->getSetting('system_url', ''); // System-URL aus Einstellungen oder automatisch erkennen
if (empty($systemUrl)) { $systemUrl = $this->db->getSetting('system_url', '');
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; if (empty($systemUrl)) {
$host = $_SERVER['HTTP_HOST']; $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$scriptPath = dirname($_SERVER['SCRIPT_NAME']); $host = $_SERVER['HTTP_HOST'];
$scriptPath = $scriptPath === '/' ? '' : $scriptPath; $scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$systemUrl = $protocol . '://' . $host . $scriptPath; $scriptPath = $scriptPath === '/' ? '' : $scriptPath;
} $systemUrl = $protocol . '://' . $host . $scriptPath;
}
// Template aus Datenbank laden
$subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert'); // Template aus Datenbank laden
$bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); $subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang');
$bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\n<strong>Maximale Geräte:</strong> {MAX_USES}<br>\n<strong>Standort:</strong> {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}");
// Änderungen formatieren
$changesText = ''; // Anleitung formatieren
foreach ($changes as $change) { $instructions = '';
$changesText .= "$change\n"; if ($instructionText) {
} $instructions = $instructionHeader . "\n" . $instructionText;
}
// Platzhalter ersetzen
$placeholders = [ // Platzhalter ersetzen
'{USER_NAME}' => $userName, $placeholders = [
'{CHANGES}' => $changesText, '{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses),
'{APP_TITLE}' => $appTitle, '{VOUCHER_CODE}' => $voucherCode,
'{SYSTEM_URL}' => $systemUrl '{SITE_NAME}' => $siteName,
]; '{MAX_USES}' => $maxUses,
'{APP_TITLE}' => $appTitle,
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); '{INSTRUCTIONS}' => $instructions,
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate); '{SYSTEM_URL}' => $systemUrl
];
// HTML oder Plain Text prüfen
$isHtml = strip_tags($body) !== $body; $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
return $this->send($to, $subject, $body, $isHtml); // Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln,
} // sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren.
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate || strpos($bodyTemplate, '{VOUCHER_CARD}') !== false;
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
if ($isHtml) {
$body = $this->brandedHtml($appTitle, $body);
}
return $this->send($to, $subject, $body, $isHtml);
}
public function sendTestEmail($to) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
$subject = '[Test] E-Mail-Konfiguration ' . $appTitle;
$body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet.";
return $this->send($to, $subject, $body, false);
}
public function sendUserNotification($to, $userName, $changes) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
// System-URL aus Einstellungen oder automatisch erkennen
$systemUrl = $this->db->getSetting('system_url', '');
if (empty($systemUrl)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$systemUrl = $protocol . '://' . $host . $scriptPath;
}
// Template aus Datenbank laden
$subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert');
$bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
// Änderungen formatieren
$changesText = '';
foreach ($changes as $change) {
$changesText .= "$change\n";
}
// Platzhalter ersetzen
$placeholders = [
'{USER_NAME}' => $userName,
'{CHANGES}' => $changesText,
'{APP_TITLE}' => $appTitle,
'{SYSTEM_URL}' => $systemUrl
];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate;
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
if ($isHtml) {
$body = $this->brandedHtml($appTitle, $body);
}
return $this->send($to, $subject, $body, $isHtml);
}
/**
* Zeilenumbrüche aus dem Vorlagentext in HTML übernehmen, ohne bereits
* vorhandenes Markup (z. B. aus dem WYSIWYG-Editor) zu zerstören.
*/
private function textToHtml(string $body): string
{
if (preg_match('#<(p|div|ul|ol|h[1-6])[\s>]#i', $body)) {
return $body;
}
return nl2br($body, false);
}
} }

183
includes/Ui.php Normal file
View file

@ -0,0 +1,183 @@
<?php
/**
* Gemeinsame Bausteine fuer den Seitenkopf.
*
* - liefert versionierte Asset-URLs (Cache-Busting nach Updates)
* - bindet die lokal ausgelieferten Assets ein (keine externen CDNs)
* - erzeugt die Branding-Overrides aus den Einstellungen
*
* Alle Methoden funktionieren auch ohne Datenbank ($db = null), damit der
* Installer dieselbe Optik nutzen kann.
*/
class Ui
{
/** Standardwerte des Design-Systems (siehe assets/global.css). */
public const DEFAULT_ACCENT = '#5b5bd6';
public const DEFAULT_ACCENT_DARK = '#8b8bf5';
public const DEFAULT_GRADIENT_FROM = '#5b5bd6';
public const DEFAULT_GRADIENT_TO = '#8b5cf6';
public const DEFAULT_RADIUS = 14;
/** Projektwurzel im Dateisystem. */
private static function root(): string
{
return dirname(__DIR__);
}
/**
* URL eines Projekt-Assets inkl. Versionsstempel.
* $base ist der Pfad zur Projektwurzel ('' im Root, '../' in /admin).
*/
public static function asset(string $path, string $base = ''): string
{
$file = self::root() . '/' . ltrim($path, '/');
$version = is_file($file) ? (string)filemtime($file) : '0';
return $base . $path . '?v=' . $version;
}
/** <script src> mit Versionsstempel. */
public static function script(string $path, string $base = '', bool $defer = false): string
{
return '<script src="' . htmlspecialchars(self::asset($path, $base)) . '"'
. ($defer ? ' defer' : '') . '></script>';
}
/**
* Theme-Bootstrap: gespeicherte Auswahl, sonst Systemeinstellung.
* Muss im <head> stehen, damit nichts hell aufblitzt.
*/
public static function themeScript(): string
{
return '<script>(function(){'
. 'var s=localStorage.getItem("theme");'
. 'var t=s||(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");'
. 'document.documentElement.setAttribute("data-theme",t);'
. '})();</script>';
}
/** Gueltige Hex-Farbe oder Fallback. */
private static function color(?string $value, string $fallback): string
{
$value = trim((string)$value);
return preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtolower($value) : $fallback;
}
/**
* CSS-Overrides fuer die Markenfarben. Gibt einen leeren String zurueck,
* wenn nichts vom Standard abweicht.
*/
public static function brandingStyle($db = null): string
{
if (!$db) {
return '';
}
$accent = self::color($db->getSetting('brand_accent', ''), self::DEFAULT_ACCENT);
$accentDark = self::color($db->getSetting('brand_accent_dark', ''), self::DEFAULT_ACCENT_DARK);
$from = self::color($db->getSetting('brand_gradient_from', ''), self::DEFAULT_GRADIENT_FROM);
$to = self::color($db->getSetting('brand_gradient_to', ''), self::DEFAULT_GRADIENT_TO);
$radius = (int)$db->getSetting('brand_radius', (string)self::DEFAULT_RADIUS);
$radius = max(0, min(28, $radius));
$isDefault = $accent === self::DEFAULT_ACCENT
&& $accentDark === self::DEFAULT_ACCENT_DARK
&& $from === self::DEFAULT_GRADIENT_FROM
&& $to === self::DEFAULT_GRADIENT_TO
&& $radius === self::DEFAULT_RADIUS;
if ($isDefault) {
return '';
}
// Abgeleitete Töne über color-mix so genügt eine einzige Grundfarbe.
return '<style>'
. ':root{'
. "--accent:{$accent};"
. "--accent-hover:color-mix(in srgb, {$accent} 84%, #000);"
. "--accent-soft:color-mix(in srgb, {$accent} 12%, #fff);"
. "--accent-border:color-mix(in srgb, {$accent} 32%, #fff);"
. "--accent-2:{$to};"
. "--input-focus:{$accent};"
. "--ring:0 0 0 4px color-mix(in srgb, {$accent} 22%, transparent);"
. "--brand-gradient:linear-gradient(135deg, {$from} 0%, {$to} 100%);"
. "--r-lg:{$radius}px;"
. "--r-xl:" . ($radius + 6) . 'px;'
. '}'
. '[data-theme="dark"]{'
. "--accent:{$accentDark};"
. "--accent-hover:color-mix(in srgb, {$accentDark} 80%, #fff);"
. "--accent-soft:color-mix(in srgb, {$accentDark} 20%, #0a0c11);"
. "--accent-border:color-mix(in srgb, {$accentDark} 42%, #0a0c11);"
. "--input-focus:{$accentDark};"
. "--ring:0 0 0 4px color-mix(in srgb, {$accentDark} 26%, transparent);"
. '}'
. '</style>';
}
/**
* Standard-Druckvorlage (wird nur verwendet, solange keine eigene
* Vorlage gespeichert ist). {QR_CODE} fuellt der Browser.
*/
public static function defaultPrintTemplate(): string
{
$validUntil = function_exists('__') ? __('print_valid_until') : 'Gültig bis';
$devices = function_exists('__') ? __('print_devices') : 'Geräte';
return '<div style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
. 'max-width:420px;margin:0 auto;padding:26px;border:1px dashed #9aa1ae;border-radius:14px;text-align:center;">'
. '<div style="font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">{APP_TITLE}</div>'
. '<div style="margin:6px 0 18px;font-size:15px;color:#101625;">{SITE_NAME}</div>'
. '{QR_CODE}'
. '<div style="margin:18px 0 6px;font-family:Consolas,Menlo,monospace;font-size:30px;font-weight:700;letter-spacing:.14em;color:#101625;">{VOUCHER_CODE}</div>'
. '<div style="font-size:13px;color:#525c6e;">' . $validUntil . ' {EXPIRY_DATE} {EXPIRY_TIME} &middot; {MAX_USES} ' . $devices . '</div>'
. '<div style="margin-top:16px;padding-top:14px;border-top:1px solid #e5e8ee;font-size:12px;color:#525c6e;text-align:left;">{INSTRUCTIONS}</div>'
. '</div>';
}
/**
* URL eines Bildes aus den Einstellungen.
* Hochgeladene Dateien liegen relativ zur Projektwurzel (uploads/),
* externe Adressen bleiben unveraendert.
*/
public static function mediaUrl(string $value, string $base = ''): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if (preg_match('#^(https?:)?//#i', $value) || strncmp($value, 'data:', 5) === 0 || $value[0] === '/') {
return $value;
}
return $base . $value;
}
/**
* Kompletter Standard-Kopf: Favicon, Schrift, Icons, Design-System,
* Theme-Bootstrap und Branding.
*/
public static function head($db = null, string $base = ''): string
{
$out = [];
$favicon = $db ? self::mediaUrl((string)$db->getSetting('favicon_url', ''), $base) : '';
if ($favicon !== '') {
$out[] = '<link rel="icon" href="' . htmlspecialchars($favicon) . '">';
}
$out[] = '<meta name="color-scheme" content="light dark">';
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/vendor/inter/inter.css', $base)) . '">';
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/vendor/fontawesome/fontawesome.css', $base)) . '">';
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/global.css', $base)) . '">';
$out[] = self::themeScript();
$branding = self::brandingStyle($db);
if ($branding !== '') {
$out[] = $branding;
}
return implode("\n ", $out);
}
}

142
includes/Upload.php Normal file
View file

@ -0,0 +1,142 @@
<?php
/**
* Datei-Uploads fuer Branding-Bilder (Logo, Favicon, Hintergrund).
*
* Bewusst eng gefasst: nur Bilder, kleine Groesse, zufaelliger Dateiname,
* Ablage in uploads/ (dort ist die PHP-Ausfuehrung per .htaccess gesperrt).
* SVG-Dateien werden vor dem Speichern von aktiven Inhalten befreit.
*/
class Upload
{
public const MAX_BYTES = 3145728; // 3 MB
/** Erlaubte Endungen je Einsatzzweck. */
private const ALLOWED = [
'image' => ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'],
'favicon' => ['ico', 'png', 'svg'],
];
/**
* Uebersetzte Meldung faellt auf Deutsch zurueck, wenn die Klasse
* ausserhalb einer Seite mit geladener I18n verwendet wird.
*/
private static function msg(string $key, string $fallback): string
{
return function_exists('__') ? __($key) : $fallback;
}
private static function dir(): string
{
return dirname(__DIR__) . '/uploads';
}
/** Legt das Upload-Verzeichnis inkl. Schutzdatei an. */
public static function ensureDir(): bool
{
$dir = self::dir();
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return false;
}
$htaccess = $dir . '/.htaccess';
if (!file_exists($htaccess)) {
@file_put_contents($htaccess, "php_flag engine off\nOptions -ExecCGI\n<FilesMatch \"\\.(php|phtml|phar)$\">\n Require all denied\n</FilesMatch>\n");
}
return is_writable($dir);
}
/** Ist der Pfad eine von uns gespeicherte Datei? */
public static function isLocal(string $path): bool
{
return $path !== '' && strncmp($path, 'uploads/', 8) === 0 && strpos($path, '..') === false;
}
/** Loescht eine zuvor hochgeladene Datei (externe URLs bleiben unberuehrt). */
public static function delete(string $path): void
{
if (!self::isLocal($path)) {
return;
}
$file = dirname(__DIR__) . '/' . $path;
if (is_file($file)) {
@unlink($file);
}
}
/**
* Nimmt einen Upload entgegen und gibt den relativen Pfad zurueck.
*
* @param array $file Eintrag aus $_FILES
* @param string $kind 'image' oder 'favicon'
* @throws RuntimeException bei ungueltigen Dateien
*/
public static function store(array $file, string $kind = 'image'): string
{
if (!isset($file['error']) || $file['error'] === UPLOAD_ERR_NO_FILE) {
return '';
}
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.'));
}
if (!is_uploaded_file($file['tmp_name'])) {
throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.'));
}
if ($file['size'] > self::MAX_BYTES) {
throw new RuntimeException(self::msg('upload_error_size', 'Die Datei ist zu groß (maximal 3 MB).'));
}
$allowed = self::ALLOWED[$kind] ?? self::ALLOWED['image'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext === 'jpeg') {
$ext = 'jpg';
}
if (!in_array($ext, $allowed, true)) {
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
}
$data = (string)file_get_contents($file['tmp_name']);
if ($ext === 'svg') {
$data = self::sanitizeSvg($data);
} elseif ($ext !== 'ico') {
// Raster: muss als Bild lesbar sein
if (@getimagesize($file['tmp_name']) === false) {
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
}
}
if (!self::ensureDir()) {
throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.'));
}
$name = bin2hex(random_bytes(8)) . '.' . $ext;
$dest = self::dir() . '/' . $name;
if (file_put_contents($dest, $data) === false) {
throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.'));
}
@chmod($dest, 0644);
return 'uploads/' . $name;
}
/**
* Entfernt aktive Inhalte aus SVG-Dateien (Skripte, Event-Handler,
* externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke.
*/
private static function sanitizeSvg(string $svg): string
{
if (stripos($svg, '<svg') === false) {
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
}
$svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg);
$svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*/?>#i', '', $svg);
$svg = preg_replace('#\son[a-z]+\s*=\s*"[^"]*"#i', '', $svg);
$svg = preg_replace("#\son[a-z]+\s*=\s*'[^']*'#i", '', $svg);
$svg = preg_replace('#(href|xlink:href)\s*=\s*([\'"])\s*(javascript|data):[^\'"]*\2#i', '', $svg);
$svg = preg_replace('#<!ENTITY[^>]*>#i', '', $svg);
return (string)$svg;
}
}

View file

@ -4,8 +4,9 @@
* Expects $currentPage (string), $appTitle (string), $auth, $db to be set before include. * Expects $currentPage (string), $appTitle (string), $auth, $db to be set before include.
* Expects I18n to be initialized. * Expects I18n to be initialized.
*/ */
require_once __DIR__ . '/Ui.php';
$currentPage = $currentPage ?? ''; $currentPage = $currentPage ?? '';
$faviconUrl = isset($db) ? $db->getSetting('favicon_url', '') : '';
$currentUser = isset($auth) ? $auth->getCurrentUser() : null; $currentUser = isset($auth) ? $auth->getCurrentUser() : null;
$lang = I18n::getLanguage(); $lang = I18n::getLanguage();
$base = $adminBase ?? ''; // Prefix bis zum admin/-Ordner $base = $adminBase ?? ''; // Prefix bis zum admin/-Ordner
@ -43,28 +44,16 @@ foreach ($navGroups as $items) {
} }
} }
?> ?>
<?php if ($faviconUrl): ?> <?= Ui::head($db ?? null, $rootBase) ?>
<link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($faviconUrl) ?>">
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="<?= $rootBase ?>assets/global.css">
<script>
(function(){
const t = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
})();
</script>
</head> </head>
<body> <body>
<a class="skip-link" href="#main-content"><?= __('a11y_skip') ?></a>
<div class="sidebar-overlay" onclick="closeMobileSidebar()"></div> <div class="sidebar-overlay" onclick="closeMobileSidebar()"></div>
<aside class="sidebar" id="adminSidebar"> <aside class="sidebar" id="adminSidebar" aria-label="<?= __('nav_administration') ?>">
<a href="<?= $rootBase ?>index.php" class="sidebar-brand"> <a href="<?= $rootBase ?>index.php" class="sidebar-brand">
<span class="brand-mark"><i class="fas fa-wifi"></i></span> <span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
<span class="brand-text"> <span class="brand-text">
<span class="brand-name"><?= htmlspecialchars($appTitle ?? 'Voucher Tool') ?></span> <span class="brand-name"><?= htmlspecialchars($appTitle ?? 'Voucher Tool') ?></span>
<span class="brand-sub"><?= __('nav_administration') ?></span> <span class="brand-sub"><?= __('nav_administration') ?></span>
@ -77,8 +66,9 @@ foreach ($navGroups as $items) {
<ul class="sidebar-nav"> <ul class="sidebar-nav">
<?php foreach ($items as [$key, $href, $icon, $labelKey]): ?> <?php foreach ($items as [$key, $href, $icon, $labelKey]): ?>
<li> <li>
<a href="<?= $base . $href ?>" class="<?= $currentPage === $key ? 'active' : '' ?>"> <a href="<?= $base . $href ?>" class="<?= $currentPage === $key ? 'active' : '' ?>"
<i class="fas <?= $icon ?>"></i> <?= __($labelKey) ?> <?= $currentPage === $key ? 'aria-current="page"' : '' ?>>
<i class="fas <?= $icon ?>" aria-hidden="true"></i> <?= __($labelKey) ?>
</a> </a>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
@ -94,8 +84,8 @@ foreach ($navGroups as $items) {
<div class="user-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?= htmlspecialchars($currentUser['name']) ?></div> <div class="user-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?= htmlspecialchars($currentUser['name']) ?></div>
<div class="user-role"><?= htmlspecialchars($currentUser['role'] ?? '') ?></div> <div class="user-role"><?= htmlspecialchars($currentUser['role'] ?? '') ?></div>
</div> </div>
<a href="<?= $rootBase ?>logout.php" class="icon-btn" title="<?= __('btn_logout') ?>" style="width:30px;height:30px;font-size:12px;"> <a href="<?= $rootBase ?>logout.php" class="icon-btn" title="<?= __('btn_logout') ?>" aria-label="<?= __('btn_logout') ?>" style="width:30px;height:30px;font-size:12px;">
<i class="fas fa-arrow-right-from-bracket"></i> <i class="fas fa-arrow-right-from-bracket" aria-hidden="true"></i>
</a> </a>
</div> </div>
</div> </div>
@ -104,8 +94,8 @@ foreach ($navGroups as $items) {
<header class="topbar"> <header class="topbar">
<div class="header-left"> <div class="header-left">
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" title="Menu"> <button class="mobile-menu-btn" onclick="toggleMobileSidebar()" aria-label="<?= __('a11y_menu') ?>" title="<?= __('a11y_menu') ?>">
<i class="fas fa-bars"></i> <i class="fas fa-bars" aria-hidden="true"></i>
</button> </button>
<div class="breadcrumb"> <div class="breadcrumb">
<span><?= __('nav_administration') ?></span> <span><?= __('nav_administration') ?></span>
@ -114,20 +104,20 @@ foreach ($navGroups as $items) {
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="lang-switcher"> <div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
<?php foreach (I18n::getAvailable() as $code => $label): ?> <?php foreach (I18n::getAvailable() as $code => $label): ?>
<button class="lang-btn <?= $lang === $code ? 'active' : '' ?>" <button class="lang-btn <?= $lang === $code ? 'active' : '' ?>"
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button> onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode"> <button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
<i class="fas fa-moon"></i> <i class="fas fa-moon" aria-hidden="true"></i>
</button> </button>
<a href="<?= $rootBase ?>index.php" class="btn btn-secondary"> <a href="<?= $rootBase ?>index.php" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left" aria-hidden="true"></i>
<span class="hide-mobile"><?= __('nav_back') ?></span> <span class="hide-mobile"><?= __('nav_back') ?></span>
</a> </a>
</div> </div>
</header> </header>
<main class="main-content"> <main class="main-content" id="main-content">

101
index.php
View file

@ -19,6 +19,7 @@ require_once __DIR__ . '/includes/Mailer.php';
require_once __DIR__ . '/includes/Notifier.php'; require_once __DIR__ . '/includes/Notifier.php';
require_once __DIR__ . '/includes/Captcha.php'; require_once __DIR__ . '/includes/Captcha.php';
require_once __DIR__ . '/includes/Sms.php'; require_once __DIR__ . '/includes/Sms.php';
require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/I18n.php'; require_once __DIR__ . '/includes/I18n.php';
$auth = new Auth(); $auth = new Auth();
@ -74,7 +75,7 @@ $instructionHeader = $db->getSetting('instruction_header', '');
$instructionText = $db->getSetting('instruction_text', ''); $instructionText = $db->getSetting('instruction_text', '');
$publicAccess = $db->getSetting('public_access', 0); $publicAccess = $db->getSetting('public_access', 0);
$smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1'; $smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1';
$printTemplate = $db->getSetting('print_template', '<div style="text-align:center;padding:40px;font-family:sans-serif;"><h1>{APP_TITLE}</h1><h2>WLAN Zugangscode</h2><div style="font-size:48px;font-weight:bold;margin:30px 0;font-family:monospace;letter-spacing:4px;">{VOUCHER_CODE}</div><p><strong>Gültig bis:</strong> {EXPIRY_DATE} {EXPIRY_TIME}</p><p><strong>Standort:</strong> {SITE_NAME}</p><p><strong>Maximale Geräte:</strong> {MAX_USES}</p><hr style="margin:30px 0;"><div style="font-size:14px;text-align:left;">{INSTRUCTIONS}</div></div>'); $printTemplate = $db->getSetting('print_template', Ui::defaultPrintTemplate());
$defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480)); $defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
$defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1)); $defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
$maxUsesLimit = max(1, (int)$db->getSetting('max_uses_limit', 10)); $maxUsesLimit = max(1, (int)$db->getSetting('max_uses_limit', 10));
@ -271,9 +272,12 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
$instructions = $instructionHeader || $instructionText $instructions = $instructionHeader || $instructionText
? htmlspecialchars($instructionHeader) . "\n" . $instructionText ? htmlspecialchars($instructionHeader) . "\n" . $instructionText
: ''; : '';
// {QR_CODE} wird erst im Browser gefuellt (siehe renderPrintQr()).
$qr = '<div class="print-qr" data-code="' . htmlspecialchars(str_replace('-', '', (string)$data['code']), ENT_QUOTES) . '"></div>';
return str_replace( return str_replace(
['{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'], ['{QR_CODE}', '{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'],
[$data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']], [$qr, $data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']],
$template $template
); );
} }
@ -284,17 +288,12 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($appTitle) ?></title> <title><?= htmlspecialchars($appTitle) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <?= Ui::head($db) ?>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/global.css">
<?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?> <?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?>
<script src="https://js.hcaptcha.com/1/api.js" async defer></script> <script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<?php endif; ?> <?php endif; ?>
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script> <?php if ($voucherCreated || $bulkCreated): ?>
<?php if ($voucherCreated): ?> <?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js') ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<?php endif; ?> <?php endif; ?>
<style> <style>
/* Seitenspezifisch: Druckansicht der Voucher-Karten. /* Seitenspezifisch: Druckansicht der Voucher-Karten.
@ -312,45 +311,46 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
</head> </head>
<body class="app-body"> <body class="app-body">
<a class="skip-link no-print" href="#main-content"><?= __('a11y_skip') ?></a>
<header class="app-topbar no-print"> <header class="app-topbar no-print">
<a href="index.php" class="brand"> <a href="index.php" class="brand">
<span class="brand-mark"><i class="fas fa-wifi"></i></span> <span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
<span class="brand-name"><?= htmlspecialchars($appTitle) ?></span> <span class="brand-name"><?= htmlspecialchars($appTitle) ?></span>
</a> </a>
<div class="app-actions"> <div class="app-actions">
<div class="lang-switcher"> <div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
<?php foreach (I18n::getAvailable() as $code => $label): ?> <?php foreach (I18n::getAvailable() as $code => $label): ?>
<button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>" <button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>"
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button> onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode"> <button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
<i class="fas fa-moon"></i> <i class="fas fa-moon" aria-hidden="true"></i>
</button> </button>
<?php if ($currentUser): ?> <?php if ($currentUser): ?>
<?php if ($auth->isAdmin()): ?> <?php if ($auth->isAdmin()): ?>
<a href="admin/" class="btn btn-secondary"> <a href="admin/" class="btn btn-secondary">
<i class="fas fa-sliders"></i> <span class="hide-mobile"><?= __('nav_administration') ?></span> <i class="fas fa-sliders" aria-hidden="true"></i> <span class="hide-mobile"><?= __('nav_administration') ?></span>
</a> </a>
<?php endif; ?> <?php endif; ?>
<div class="user-menu"> <div class="user-menu">
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div> <div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
<div class="user-name hide-mobile"><?= htmlspecialchars($currentUser['name']) ?></div> <div class="user-name hide-mobile"><?= htmlspecialchars($currentUser['name']) ?></div>
<a href="logout.php" class="icon-btn" title="<?= __('btn_logout') ?>" style="width:28px;height:28px;font-size:11px;"> <a href="logout.php" class="icon-btn" title="<?= __('btn_logout') ?>" aria-label="<?= __('btn_logout') ?>" style="width:28px;height:28px;font-size:11px;">
<i class="fas fa-arrow-right-from-bracket"></i> <i class="fas fa-arrow-right-from-bracket" aria-hidden="true"></i>
</a> </a>
</div> </div>
<?php elseif ($publicAccess): ?> <?php elseif ($publicAccess): ?>
<a href="login.php" class="btn btn-secondary"> <a href="login.php" class="btn btn-secondary">
<i class="fas fa-right-to-bracket"></i> <?= __('btn_login') ?> <i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('btn_login') ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
</div> </div>
</header> </header>
<div class="container"> <main class="container" id="main-content">
<?php if ($logoUrl && !$voucherCreated && !$bulkCreated): ?> <?php if ($logoUrl && !$voucherCreated && !$bulkCreated): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo"> <img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
<?php endif; ?> <?php endif; ?>
<?php if (!$voucherCreated && !$bulkCreated): ?> <?php if (!$voucherCreated && !$bulkCreated): ?>
@ -370,17 +370,17 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
</div> </div>
<div class="voucher-result no-print"> <div class="voucher-result no-print">
<div class="result-label"><i class="fas fa-circle-check"></i> <?= __('voucher_success_title') ?></div> <div class="result-label"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('voucher_success_title') ?></div>
<div class="voucher-code" id="voucherCode" onclick="copyCode()" title="Klicken zum Kopieren"> <div class="voucher-code" id="voucherCode" onclick="copyCode()" title="<?= __('js_click_to_copy') ?>">
<?= htmlspecialchars($voucherCode) ?> <?= htmlspecialchars($voucherCode) ?>
</div> </div>
<div class="voucher-info"> <div class="voucher-info">
<i class="fas fa-copy"></i> <?= __('voucher_copy_hint') ?> <i class="fas fa-copy" aria-hidden="true"></i> <?= __('voucher_copy_hint') ?>
</div> </div>
<div class="voucher-meta"> <div class="voucher-meta">
<span><i class="fas fa-clock"></i> <?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?></span> <span><i class="fas fa-clock" aria-hidden="true"></i> <?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?></span>
<span><i class="fas fa-location-dot"></i> <?= htmlspecialchars($voucherData['site_name']) ?></span> <span><i class="fas fa-location-dot" aria-hidden="true"></i> <?= htmlspecialchars($voucherData['site_name']) ?></span>
<span><i class="fas fa-mobile-screen"></i> <?= (int)$voucherData['max_uses'] ?> <?= __('label_devices') ?></span> <span><i class="fas fa-mobile-screen" aria-hidden="true"></i> <?= (int)$voucherData['max_uses'] ?> <?= __('label_devices') ?></span>
</div> </div>
<div class="ticket-divider"></div> <div class="ticket-divider"></div>
<div class="qr-wrapper no-print"> <div class="qr-wrapper no-print">
@ -399,11 +399,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<div class="no-print" style="display:flex;gap:10px;margin-top:18px;"> <div class="no-print" style="display:flex;gap:10px;margin-top:18px;">
<form method="get" style="flex:1;"> <form method="get" style="flex:1;">
<button type="submit" class="btn btn-primary btn-lg btn-block"> <button type="submit" class="btn btn-primary btn-lg btn-block">
<i class="fas fa-plus"></i> <?= __('btn_new_code') ?> <i class="fas fa-plus" aria-hidden="true"></i> <?= __('btn_new_code') ?>
</button> </button>
</form> </form>
<button onclick="window.print()" class="btn btn-secondary btn-lg"> <button onclick="window.print()" class="btn btn-secondary btn-lg">
<i class="fas fa-print"></i> <?= __('voucher_print_btn') ?> <i class="fas fa-print" aria-hidden="true"></i> <?= __('voucher_print_btn') ?>
</button> </button>
</div> </div>
@ -436,8 +436,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<tr> <tr>
<td><?= $i + 1 ?></td> <td><?= $i + 1 ?></td>
<td> <td>
<code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', 'Kopiert!')" <code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', '<?= __('js_copied') ?>')"
title="Klicken zum Kopieren"><?= htmlspecialchars($bv['code']) ?></code> title="<?= __('js_click_to_copy') ?>"><?= htmlspecialchars($bv['code']) ?></code>
</td> </td>
<td><?= htmlspecialchars($bv['site_name']) ?></td> <td><?= htmlspecialchars($bv['site_name']) ?></td>
<td><?= $bv['expiry_date'] ?> <?= $bv['expiry_time'] ?></td> <td><?= $bv['expiry_date'] ?> <?= $bv['expiry_time'] ?></td>
@ -445,12 +445,12 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
<p class="copy-hint"><i class="fas fa-copy"></i> <?= __('voucher_copy_hint') ?></p> <p class="copy-hint"><i class="fas fa-copy" aria-hidden="true"></i> <?= __('voucher_copy_hint') ?></p>
</div> </div>
<div class="no-print" style="display:flex;gap:10px;margin-top:20px;"> <div class="no-print" style="display:flex;gap:10px;margin-top:20px;">
<button onclick="window.print()" class="btn btn-primary btn-lg" style="flex:1;"> <button onclick="window.print()" class="btn btn-primary btn-lg" style="flex:1;">
<i class="fas fa-print"></i> <?= __('bulk_print_all') ?> <i class="fas fa-print" aria-hidden="true"></i> <?= __('bulk_print_all') ?>
</button> </button>
<form method="get" style="flex:1;"> <form method="get" style="flex:1;">
<button type="submit" class="btn btn-secondary btn-lg btn-block"><?= __('btn_new_code') ?></button> <button type="submit" class="btn btn-secondary btn-lg btn-block"><?= __('btn_new_code') ?></button>
@ -459,7 +459,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<?php elseif (empty($sites)): ?> <?php elseif (empty($sites)): ?>
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon"><i class="fas fa-wifi"></i></div> <div class="empty-icon"><i class="fas fa-wifi" aria-hidden="true"></i></div>
<p><?= __('voucher_no_sites') ?><br> <p><?= __('voucher_no_sites') ?><br>
<?php if ($auth->isAdmin()): ?> <?php if ($auth->isAdmin()): ?>
<a href="admin/" style="color:var(--accent);"><?= __('voucher_no_sites_admin') ?></a> <a href="admin/" style="color:var(--accent);"><?= __('voucher_no_sites_admin') ?></a>
@ -543,7 +543,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<div class="email-checkbox-wrapper"> <div class="email-checkbox-wrapper">
<input type="checkbox" id="send_email" name="send_email" onchange="toggleEmailField()"> <input type="checkbox" id="send_email" name="send_email" onchange="toggleEmailField()">
<label for="send_email"> <label for="send_email">
<i class="fas fa-envelope" style="color:var(--accent);"></i> <?= __('voucher_email_send') ?> <i class="fas fa-envelope" style="color:var(--accent);" aria-hidden="true"></i> <?= __('voucher_email_send') ?>
</label> </label>
</div> </div>
<div class="email-input-wrapper" id="email_field"> <div class="email-input-wrapper" id="email_field">
@ -560,7 +560,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<div class="email-option"> <div class="email-option">
<div class="email-checkbox-wrapper"> <div class="email-checkbox-wrapper">
<input type="checkbox" id="send_sms" name="send_sms" onchange="document.getElementById('sms_field').style.display=this.checked?'block':'none'"> <input type="checkbox" id="send_sms" name="send_sms" onchange="document.getElementById('sms_field').style.display=this.checked?'block':'none'">
<label for="send_sms"><i class="fas fa-comment-sms" style="color:var(--accent);"></i> Code per SMS versenden</label> <label for="send_sms"><i class="fas fa-comment-sms" style="color:var(--accent);" aria-hidden="true"></i> Code per SMS versenden</label>
</div> </div>
<div id="sms_field" style="display:none;margin-top:12px;"> <div id="sms_field" style="display:none;margin-top:12px;">
<label for="recipient_phone">Telefonnummer (international, z.B. +49170)</label> <label for="recipient_phone">Telefonnummer (international, z.B. +49170)</label>
@ -570,7 +570,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<?php endif; ?> <?php endif; ?>
<button type="submit" class="btn btn-primary btn-lg btn-block" id="submitBtn"> <button type="submit" class="btn btn-primary btn-lg btn-block" id="submitBtn">
<i class="fas fa-ticket"></i> <?= __('voucher_create_btn') ?> <i class="fas fa-ticket" aria-hidden="true"></i> <?= __('voucher_create_btn') ?>
</button> </button>
</form> </form>
</div> </div>
@ -633,11 +633,28 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </main>
<div id="toast-container"></div> <div id="toast-container" role="status" aria-live="polite"></div>
<script src="assets/global.js"></script> <script src="assets/global.js"></script>
<script> <script>
<?php if ($voucherCreated || $bulkCreated): ?>
// QR-Codes der Druckkarten erzeugen (im Browser, ohne externen Dienst)
function renderPrintQr() {
document.querySelectorAll('.print-qr').forEach(function (el) {
if (el.dataset.done) return;
el.dataset.done = '1';
new QRCode(el, {
text: el.dataset.code || '',
width: 132, height: 132,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
});
}
document.addEventListener('DOMContentLoaded', renderPrintQr);
<?php endif; ?>
<?php if ($voucherCreated): ?> <?php if ($voucherCreated): ?>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
new QRCode(document.getElementById('qrcode'), { new QRCode(document.getElementById('qrcode'), {
@ -649,7 +666,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
}); });
function copyCode() { function copyCode() {
copyToClipboard('<?= addslashes($voucherCode) ?>', 'Code kopiert!'); copyToClipboard('<?= addslashes($voucherCode) ?>', '<?= __('js_code_copied') ?>');
} }
<?php endif; ?> <?php endif; ?>
@ -715,7 +732,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
const btn = document.getElementById('submitBtn'); const btn = document.getElementById('submitBtn');
if (!btn || btn.disabled) { e.preventDefault(); return; } if (!btn || btn.disabled) { e.preventDefault(); return; }
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> <?= __('voucher_creating') ?>'; btn.innerHTML = '<i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> <?= __('voucher_creating') ?>';
}); });
document.getElementById('bulkForm')?.addEventListener('submit', function(e) { document.getElementById('bulkForm')?.addEventListener('submit', function(e) {
@ -723,7 +740,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
if (!btn || btn.disabled) { e.preventDefault(); return; } if (!btn || btn.disabled) { e.preventDefault(); return; }
const count = document.getElementById('bulk_count').value; const count = document.getElementById('bulk_count').value;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> <?= addslashes(str_replace('{count}', "' + count + '", __('bulk_creating'))) ?>'; btn.innerHTML = '<i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> <?= addslashes(str_replace('{count}', "' + count + '", __('bulk_creating'))) ?>';
}); });
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {

View file

@ -1,383 +1,380 @@
<?php <?php
session_start(); session_start();
// Prüfen ob bereits installiert. // Prüfen ob bereits installiert.
// Wenn eine config.php existiert, darf der Installer NICHT mehr ohne Weiteres // Wenn eine config.php existiert, darf der Installer NICHT mehr ohne Weiteres
// erreichbar sein sonst koennte jeder die Konfiguration ueberschreiben und // erreichbar sein sonst koennte jeder die Konfiguration ueberschreiben und
// einen neuen Admin anlegen. Reinstall ist nur fuer angemeldete Admins erlaubt. // einen neuen Admin anlegen. Reinstall ist nur fuer angemeldete Admins erlaubt.
if (file_exists(__DIR__ . '/config.php')) { if (file_exists(__DIR__ . '/config.php')) {
if (!isset($_GET['reinstall'])) { if (!isset($_GET['reinstall'])) {
die('System bereits installiert. Eine Neuinstallation ist nur fuer angemeldete Administratoren ueber install.php?reinstall=1 moeglich.'); die('System bereits installiert. Eine Neuinstallation ist nur fuer angemeldete Administratoren ueber install.php?reinstall=1 moeglich.');
} }
// Reinstall angefordert -> Admin-Authentifizierung erzwingen // Reinstall angefordert -> Admin-Authentifizierung erzwingen
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php'; require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php'; require_once __DIR__ . '/includes/Auth.php';
try { try {
$reinstallAuth = new Auth(); $reinstallAuth = new Auth();
if (!$reinstallAuth->isAdmin()) { if (!$reinstallAuth->isAdmin()) {
die('Neuinstallation nicht erlaubt: Bitte zuerst als Administrator <a href="login.php">anmelden</a>.'); die('Neuinstallation nicht erlaubt: Bitte zuerst als Administrator <a href="login.php">anmelden</a>.');
} }
} catch (Exception $e) { } catch (Exception $e) {
die('Neuinstallation nicht moeglich (Konfigurationsfehler).'); die('Neuinstallation nicht moeglich (Konfigurationsfehler).');
} }
} }
$step = isset($_POST['step']) ? (int)$_POST['step'] : 1; $step = isset($_POST['step']) ? (int)$_POST['step'] : 1;
$errors = []; $errors = [];
$success = false; $success = false;
// Step 1: Datenbankverbindung testen // Step 1: Datenbankverbindung testen
if ($step === 2 && $_SERVER['REQUEST_METHOD'] === 'POST') { if ($step === 2 && $_SERVER['REQUEST_METHOD'] === 'POST') {
$db_host = $_POST['db_host'] ?? ''; $db_host = $_POST['db_host'] ?? '';
$db_name = $_POST['db_name'] ?? ''; $db_name = $_POST['db_name'] ?? '';
$db_user = $_POST['db_user'] ?? ''; $db_user = $_POST['db_user'] ?? '';
$db_pass = $_POST['db_pass'] ?? ''; $db_pass = $_POST['db_pass'] ?? '';
try { try {
$pdo = new PDO("mysql:host=$db_host;charset=utf8mb4", $db_user, $db_pass); $pdo = new PDO("mysql:host=$db_host;charset=utf8mb4", $db_user, $db_pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Datenbank erstellen falls nicht vorhanden // Datenbank erstellen falls nicht vorhanden
$pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); $pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$pdo->exec("USE `$db_name`"); $pdo->exec("USE `$db_name`");
// Tabellen erstellen // Tabellen erstellen
$sql = file_get_contents(__DIR__ . '/database.sql'); $sql = file_get_contents(__DIR__ . '/database.sql');
$pdo->exec($sql); $pdo->exec($sql);
$_SESSION['install_db'] = [ $_SESSION['install_db'] = [
'host' => $db_host, 'host' => $db_host,
'name' => $db_name, 'name' => $db_name,
'user' => $db_user, 'user' => $db_user,
'pass' => $db_pass 'pass' => $db_pass
]; ];
} catch (PDOException $e) { } catch (PDOException $e) {
$errors[] = "Datenbankfehler: " . $e->getMessage(); $errors[] = "Datenbankfehler: " . $e->getMessage();
$step = 1; $step = 1;
} }
} }
// Step 2: Admin-Account erstellen // Step 2: Admin-Account erstellen
if ($step === 3 && $_SERVER['REQUEST_METHOD'] === 'POST') { if ($step === 3 && $_SERVER['REQUEST_METHOD'] === 'POST') {
$admin_email = filter_var($_POST['admin_email'] ?? '', FILTER_VALIDATE_EMAIL); $admin_email = filter_var($_POST['admin_email'] ?? '', FILTER_VALIDATE_EMAIL);
$admin_name = $_POST['admin_name'] ?? ''; $admin_name = $_POST['admin_name'] ?? '';
$admin_password = $_POST['admin_password'] ?? ''; $admin_password = $_POST['admin_password'] ?? '';
$admin_password_confirm = $_POST['admin_password_confirm'] ?? ''; $admin_password_confirm = $_POST['admin_password_confirm'] ?? '';
if (!$admin_email) { if (!$admin_email) {
$errors[] = "Ungültige E-Mail-Adresse"; $errors[] = "Ungültige E-Mail-Adresse";
$step = 2; $step = 2;
} elseif (strlen($admin_password) < 8) { } elseif (strlen($admin_password) < 8) {
$errors[] = "Passwort muss mindestens 8 Zeichen lang sein"; $errors[] = "Passwort muss mindestens 8 Zeichen lang sein";
$step = 2; $step = 2;
} elseif ($admin_password !== $admin_password_confirm) { } elseif ($admin_password !== $admin_password_confirm) {
$errors[] = "Passwörter stimmen nicht überein"; $errors[] = "Passwörter stimmen nicht überein";
$step = 2; $step = 2;
} else { } else {
$_SESSION['install_admin'] = [ $_SESSION['install_admin'] = [
'email' => $admin_email, 'email' => $admin_email,
'name' => $admin_name, 'name' => $admin_name,
'password' => password_hash($admin_password, PASSWORD_DEFAULT) 'password' => password_hash($admin_password, PASSWORD_DEFAULT)
]; ];
} }
} }
// Step 3: Allgemeine Einstellungen // Step 3: Allgemeine Einstellungen
if ($step === 4 && $_SERVER['REQUEST_METHOD'] === 'POST') { if ($step === 4 && $_SERVER['REQUEST_METHOD'] === 'POST') {
$app_title = $_POST['app_title'] ?? 'UniFi Voucher System'; $app_title = $_POST['app_title'] ?? 'UniFi Voucher System';
$logo_url = $_POST['logo_url'] ?? ''; $logo_url = $_POST['logo_url'] ?? '';
$instruction_header = $_POST['instruction_header'] ?? ''; $instruction_header = $_POST['instruction_header'] ?? '';
$instruction_text = $_POST['instruction_text'] ?? ''; $instruction_text = $_POST['instruction_text'] ?? '';
$public_access = isset($_POST['public_access']) ? 1 : 0; $public_access = isset($_POST['public_access']) ? 1 : 0;
// Microsoft 365 OAuth (optional) // Microsoft 365 OAuth (optional)
$m365_client_id = $_POST['m365_client_id'] ?? ''; $m365_client_id = $_POST['m365_client_id'] ?? '';
$m365_client_secret = $_POST['m365_client_secret'] ?? ''; $m365_client_secret = $_POST['m365_client_secret'] ?? '';
$m365_tenant_id = $_POST['m365_tenant_id'] ?? ''; $m365_tenant_id = $_POST['m365_tenant_id'] ?? '';
$_SESSION['install_settings'] = [ $_SESSION['install_settings'] = [
'app_title' => $app_title, 'app_title' => $app_title,
'logo_url' => $logo_url, 'logo_url' => $logo_url,
'instruction_header' => $instruction_header, 'instruction_header' => $instruction_header,
'instruction_text' => $instruction_text, 'instruction_text' => $instruction_text,
'public_access' => $public_access, 'public_access' => $public_access,
'm365_client_id' => $m365_client_id, 'm365_client_id' => $m365_client_id,
'm365_client_secret' => $m365_client_secret, 'm365_client_secret' => $m365_client_secret,
'm365_tenant_id' => $m365_tenant_id 'm365_tenant_id' => $m365_tenant_id
]; ];
} }
// Step 4: Installation abschließen // Step 4: Installation abschließen
if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') { if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
try { try {
$db = $_SESSION['install_db']; $db = $_SESSION['install_db'];
$admin = $_SESSION['install_admin']; $admin = $_SESSION['install_admin'];
$settings = $_SESSION['install_settings']; $settings = $_SESSION['install_settings'];
$pdo = new PDO("mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4", $db['user'], $db['pass']); $pdo = new PDO("mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4", $db['user'], $db['pass']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Admin-User erstellen // Admin-User erstellen
$stmt = $pdo->prepare("INSERT INTO users (email, name, password_hash, is_admin, is_active) VALUES (?, ?, ?, 1, 1)"); $stmt = $pdo->prepare("INSERT INTO users (email, name, password_hash, is_admin, is_active) VALUES (?, ?, ?, 1, 1)");
$stmt->execute([$admin['email'], $admin['name'], $admin['password']]); $stmt->execute([$admin['email'], $admin['name'], $admin['password']]);
// Settings speichern // Settings speichern
$settingsData = [ $settingsData = [
'app_title' => $settings['app_title'], 'app_title' => $settings['app_title'],
'logo_url' => $settings['logo_url'], 'logo_url' => $settings['logo_url'],
'instruction_header' => $settings['instruction_header'], 'instruction_header' => $settings['instruction_header'],
'instruction_text' => $settings['instruction_text'], 'instruction_text' => $settings['instruction_text'],
'public_access' => $settings['public_access'], 'public_access' => $settings['public_access'],
'm365_client_id' => $settings['m365_client_id'], 'm365_client_id' => $settings['m365_client_id'],
'm365_client_secret' => $settings['m365_client_secret'], 'm365_client_secret' => $settings['m365_client_secret'],
'm365_tenant_id' => $settings['m365_tenant_id'] 'm365_tenant_id' => $settings['m365_tenant_id']
]; ];
$stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"); $stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)");
foreach ($settingsData as $key => $value) { foreach ($settingsData as $key => $value) {
$stmt->execute([$key, $value]); $stmt->execute([$key, $value]);
} }
// config.php erstellen // config.php erstellen
$configContent = "<?php\n"; $configContent = "<?php\n";
$configContent .= "// UniFi Voucher Management System - Configuration\n\n"; $configContent .= "// UniFi Voucher Management System - Configuration\n\n";
$configContent .= "define('DB_HOST', '{$db['host']}');\n"; $configContent .= "define('DB_HOST', '{$db['host']}');\n";
$configContent .= "define('DB_NAME', '{$db['name']}');\n"; $configContent .= "define('DB_NAME', '{$db['name']}');\n";
$configContent .= "define('DB_USER', '{$db['user']}');\n"; $configContent .= "define('DB_USER', '{$db['user']}');\n";
$configContent .= "define('DB_PASS', '" . addslashes($db['pass']) . "');\n\n"; $configContent .= "define('DB_PASS', '" . addslashes($db['pass']) . "');\n\n";
$configContent .= "// Anwendungs-Schluessel fuer Verschluesselung-at-rest (z.B. UniFi-Passwoerter)\n"; $configContent .= "// Anwendungs-Schluessel fuer Verschluesselung-at-rest (z.B. UniFi-Passwoerter)\n";
$configContent .= "// NICHT aendern, sonst koennen bestehende verschluesselte Werte nicht mehr gelesen werden.\n"; $configContent .= "// NICHT aendern, sonst koennen bestehende verschluesselte Werte nicht mehr gelesen werden.\n";
$configContent .= "define('APP_KEY', '" . base64_encode(random_bytes(32)) . "');\n\n"; $configContent .= "define('APP_KEY', '" . base64_encode(random_bytes(32)) . "');\n\n";
$configContent .= "// Sitzungs-Einstellungen\n"; $configContent .= "// Sitzungs-Einstellungen\n";
$configContent .= "define('SESSION_LIFETIME', 3600); // 1 Stunde\n\n"; $configContent .= "define('SESSION_LIFETIME', 3600); // 1 Stunde\n\n";
$configContent .= "// Zeitzone\n"; $configContent .= "// Zeitzone\n";
$configContent .= "date_default_timezone_set('Europe/Berlin');\n"; $configContent .= "date_default_timezone_set('Europe/Berlin');\n";
file_put_contents(__DIR__ . '/config.php', $configContent); file_put_contents(__DIR__ . '/config.php', $configContent);
// .htaccess erstellen (ohne Rewrite Rules die Probleme machen) // .htaccess erstellen (ohne Rewrite Rules die Probleme machen)
$htaccess = "# UniFi Voucher System\n\n"; $htaccess = "# UniFi Voucher System\n\n";
$htaccess .= "# Security\n"; $htaccess .= "# Security\n";
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|\\.md)$\">\n"; $htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|\\.md)$\">\n";
$htaccess .= " Order Allow,Deny\n"; $htaccess .= " Order Allow,Deny\n";
$htaccess .= " Deny from all\n"; $htaccess .= " Deny from all\n";
$htaccess .= "</FilesMatch>\n\n"; $htaccess .= "</FilesMatch>\n\n";
$htaccess .= "DirectoryIndex index.php\n"; $htaccess .= "DirectoryIndex index.php\n";
file_put_contents(__DIR__ . '/.htaccess', $htaccess); file_put_contents(__DIR__ . '/.htaccess', $htaccess);
$success = true; $success = true;
// Session-Daten löschen // Session-Daten löschen
unset($_SESSION['install_db'], $_SESSION['install_admin'], $_SESSION['install_settings']); unset($_SESSION['install_db'], $_SESSION['install_admin'], $_SESSION['install_settings']);
} catch (Exception $e) { } catch (Exception $e) {
$errors[] = "Fehler bei der Installation: " . $e->getMessage(); $errors[] = "Fehler bei der Installation: " . $e->getMessage();
$step = 4; $step = 4;
} }
} }
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<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.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UniFi Voucher System - Installation</title> <title>UniFi Voucher System - Installation</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <?php require_once __DIR__ . '/includes/Ui.php'; ?>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <?= Ui::head(null) ?>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"> <style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> /* Installer-spezifisch: Fortschrittsanzeige und Abschnitte */
<link rel="stylesheet" href="assets/global.css"> .install-head { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; }
<style> .progress { position: relative; display: flex; justify-content: space-between; margin: 26px 0 28px; }
/* Installer-spezifisch: Fortschrittsanzeige und Abschnitte */ .progress::before {
.install-head { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; } content: ''; position: absolute; top: 14px; left: 14px; right: 14px;
.progress { position: relative; display: flex; justify-content: space-between; margin: 26px 0 28px; } height: 2px; background: var(--border-color); z-index: 0;
.progress::before { }
content: ''; position: absolute; top: 14px; left: 14px; right: 14px; .progress-step {
height: 2px; background: var(--border-color); z-index: 0; position: relative; z-index: 1;
} width: 28px; height: 28px; border-radius: 50%;
.progress-step { display: flex; align-items: center; justify-content: center;
position: relative; z-index: 1; background: var(--bg-card); border: 1px solid var(--border-color);
width: 28px; height: 28px; border-radius: 50%; color: var(--text-muted); font-size: 12px; font-weight: 650;
display: flex; align-items: center; justify-content: center; }
background: var(--bg-card); border: 1px solid var(--border-color); .progress-step.active { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--ring); }
color: var(--text-muted); font-size: 12px; font-weight: 650; .progress-step.completed { background: var(--success); border-color: var(--success); color: #fff; }
} .section {
.progress-step.active { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--ring); } padding: 18px;
.progress-step.completed { background: var(--success); border-color: var(--success); color: #fff; } margin-bottom: 20px;
.section { background: var(--bg-subtle);
padding: 18px; border: 1px solid var(--border-color);
margin-bottom: 20px; border-radius: var(--r-md);
background: var(--bg-subtle); }
border: 1px solid var(--border-color); .section h3 { font-size: 14px; margin-bottom: 14px; }
border-radius: var(--r-md); .error, .success {
} padding: 12px 15px; margin-bottom: 20px;
.section h3 { font-size: 14px; margin-bottom: 14px; } border-radius: var(--r-md); font-size: 13.5px;
.error, .success { border: 1px solid var(--danger-border); background: var(--danger-soft); color: var(--danger);
padding: 12px 15px; margin-bottom: 20px; }
border-radius: var(--r-md); font-size: 13.5px; .success { border-color: var(--success-border); background: var(--success-soft); color: var(--success); }
border: 1px solid var(--danger-border); background: var(--danger-soft); color: var(--danger); form > h3 { font-size: 15px; margin-bottom: 16px; }
} </style>
.success { border-color: var(--success-border); background: var(--success-soft); color: var(--success); } </head>
form > h3 { font-size: 15px; margin-bottom: 16px; } <body class="app-body focus-page">
</style> <div class="focus-card card">
</head> <div class="install-head">
<body class="app-body focus-page"> <span class="focus-icon"><i class="fas fa-rocket" aria-hidden="true"></i></span>
<div class="focus-card card"> <div>
<div class="install-head"> <h1 style="font-size:20px;">UniFi Voucher System</h1>
<span class="focus-icon"><i class="fas fa-rocket"></i></span> <p class="sub">Installation in fünf Schritten</p>
<div> </div>
<h1 style="font-size:20px;">UniFi Voucher System</h1> </div>
<p class="sub">Installation in fünf Schritten</p>
</div> <div class="progress">
</div> <div class="progress-step <?= $step >= 1 ? 'completed' : '' ?>">1</div>
<div class="progress-step <?= $step >= 2 ? 'completed' : ($step === 1 ? 'active' : '') ?>">2</div>
<div class="progress"> <div class="progress-step <?= $step >= 3 ? 'completed' : ($step === 2 ? 'active' : '') ?>">3</div>
<div class="progress-step <?= $step >= 1 ? 'completed' : '' ?>">1</div> <div class="progress-step <?= $step >= 4 ? 'completed' : ($step === 3 ? 'active' : '') ?>">4</div>
<div class="progress-step <?= $step >= 2 ? 'completed' : ($step === 1 ? 'active' : '') ?>">2</div> <div class="progress-step <?= $step >= 5 ? 'active' : '' ?>">5</div>
<div class="progress-step <?= $step >= 3 ? 'completed' : ($step === 2 ? 'active' : '') ?>">3</div> </div>
<div class="progress-step <?= $step >= 4 ? 'completed' : ($step === 3 ? 'active' : '') ?>">4</div>
<div class="progress-step <?= $step >= 5 ? 'active' : '' ?>">5</div> <?php if (!empty($errors)): ?>
</div> <div class="error">
<?php foreach ($errors as $error): ?>
<?php if (!empty($errors)): ?> <div><?= htmlspecialchars($error) ?></div>
<div class="error"> <?php endforeach; ?>
<?php foreach ($errors as $error): ?> </div>
<div><?= htmlspecialchars($error) ?></div> <?php endif; ?>
<?php endforeach; ?>
</div> <?php if ($success): ?>
<?php endif; ?> <div class="success">
<strong> Installation erfolgreich abgeschlossen!</strong><br>
<?php if ($success): ?> Sie können sich jetzt mit Ihren Admin-Zugangsdaten anmelden.
<div class="success"> </div>
<strong> Installation erfolgreich abgeschlossen!</strong><br> <a href="index.php" class="btn btn-primary btn-lg btn-block">Zum Login</a>
Sie können sich jetzt mit Ihren Admin-Zugangsdaten anmelden. <?php elseif ($step === 1): ?>
</div> <form method="post">
<a href="index.php" class="btn btn-primary btn-lg btn-block">Zum Login</a> <input type="hidden" name="step" value="2">
<?php elseif ($step === 1): ?> <h3>Schritt 1: Datenbank-Konfiguration</h3>
<form method="post">
<input type="hidden" name="step" value="2"> <div class="form-group">
<h3>Schritt 1: Datenbank-Konfiguration</h3> <label>Datenbank-Host</label>
<input type="text" name="db_host" value="localhost" required>
<div class="form-group"> <div class="help-text">Meist "localhost"</div>
<label>Datenbank-Host</label> </div>
<input type="text" name="db_host" value="localhost" required>
<div class="help-text">Meist "localhost"</div> <div class="form-group">
</div> <label>Datenbankname</label>
<input type="text" name="db_name" value="unifi_voucher" required>
<div class="form-group"> <div class="help-text">Name der Datenbank (wird erstellt falls nicht vorhanden)</div>
<label>Datenbankname</label> </div>
<input type="text" name="db_name" value="unifi_voucher" required>
<div class="help-text">Name der Datenbank (wird erstellt falls nicht vorhanden)</div> <div class="form-group">
</div> <label>Datenbank-Benutzer</label>
<input type="text" name="db_user" required>
<div class="form-group"> </div>
<label>Datenbank-Benutzer</label>
<input type="text" name="db_user" required> <div class="form-group">
</div> <label>Datenbank-Passwort</label>
<input type="password" name="db_pass">
<div class="form-group"> </div>
<label>Datenbank-Passwort</label>
<input type="password" name="db_pass"> <button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button>
</div> </form>
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right"></i></button> <?php elseif ($step === 2): ?>
</form> <form method="post">
<input type="hidden" name="step" value="3">
<?php elseif ($step === 2): ?> <h3>Schritt 2: Administrator-Account</h3>
<form method="post">
<input type="hidden" name="step" value="3"> <div class="form-group">
<h3>Schritt 2: Administrator-Account</h3> <label>Name</label>
<input type="text" name="admin_name" required>
<div class="form-group"> </div>
<label>Name</label>
<input type="text" name="admin_name" required> <div class="form-group">
</div> <label>E-Mail</label>
<input type="email" name="admin_email" required>
<div class="form-group"> </div>
<label>E-Mail</label>
<input type="email" name="admin_email" required> <div class="form-group">
</div> <label>Passwort</label>
<input type="password" name="admin_password" required minlength="8">
<div class="form-group"> <div class="help-text">Mindestens 8 Zeichen</div>
<label>Passwort</label> </div>
<input type="password" name="admin_password" required minlength="8">
<div class="help-text">Mindestens 8 Zeichen</div> <div class="form-group">
</div> <label>Passwort bestätigen</label>
<input type="password" name="admin_password_confirm" required>
<div class="form-group"> </div>
<label>Passwort bestätigen</label>
<input type="password" name="admin_password_confirm" required> <button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button>
</div> </form>
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right"></i></button> <?php elseif ($step === 3): ?>
</form> <form method="post">
<input type="hidden" name="step" value="4">
<?php elseif ($step === 3): ?> <h3>Schritt 3: Allgemeine Einstellungen</h3>
<form method="post">
<input type="hidden" name="step" value="4"> <div class="form-group">
<h3>Schritt 3: Allgemeine Einstellungen</h3> <label>Anwendungs-Titel</label>
<input type="text" name="app_title" value="UniFi Voucher System" required>
<div class="form-group"> </div>
<label>Anwendungs-Titel</label>
<input type="text" name="app_title" value="UniFi Voucher System" required> <div class="form-group">
</div> <label>Logo-URL (optional)</label>
<input type="text" name="logo_url" placeholder="https://example.com/logo.png">
<div class="form-group"> </div>
<label>Logo-URL (optional)</label>
<input type="text" name="logo_url" placeholder="https://example.com/logo.png"> <div class="form-group">
</div> <label>Anleitung Überschrift</label>
<input type="text" name="instruction_header" value="So verwenden Sie Ihren Code">
<div class="form-group"> </div>
<label>Anleitung Überschrift</label>
<input type="text" name="instruction_header" value="So verwenden Sie Ihren Code"> <div class="form-group">
</div> <label>Anleitung Text</label>
<textarea name="instruction_text">Verbinden Sie sich mit dem WLAN und geben Sie den Code auf der Anmeldeseite ein.</textarea>
<div class="form-group"> </div>
<label>Anleitung Text</label>
<textarea name="instruction_text">Verbinden Sie sich mit dem WLAN und geben Sie den Code auf der Anmeldeseite ein.</textarea> <div class="form-group checkbox-group">
</div> <input type="checkbox" name="public_access" id="public_access">
<label for="public_access" style="margin: 0;">Öffentlicher Zugriff auf Code-Erstellung</label>
<div class="form-group checkbox-group"> </div>
<input type="checkbox" name="public_access" id="public_access">
<label for="public_access" style="margin: 0;">Öffentlicher Zugriff auf Code-Erstellung</label> <div class="section">
</div> <h3>Microsoft 365 Login (Optional)</h3>
<div class="form-group">
<div class="section"> <label>Client ID</label>
<h3>Microsoft 365 Login (Optional)</h3> <input type="text" name="m365_client_id">
<div class="form-group"> </div>
<label>Client ID</label> <div class="form-group">
<input type="text" name="m365_client_id"> <label>Client Secret</label>
</div> <input type="password" name="m365_client_secret">
<div class="form-group"> </div>
<label>Client Secret</label> <div class="form-group">
<input type="password" name="m365_client_secret"> <label>Tenant ID</label>
</div> <input type="text" name="m365_tenant_id">
<div class="form-group"> </div>
<label>Tenant ID</label> <div class="help-text">Leer lassen, wenn M365-Login nicht verwendet werden soll</div>
<input type="text" name="m365_tenant_id"> </div>
</div>
<div class="help-text">Leer lassen, wenn M365-Login nicht verwendet werden soll</div> <button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button>
</div> </form>
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right"></i></button> <?php elseif ($step === 4): ?>
</form> <form method="post">
<input type="hidden" name="step" value="5">
<?php elseif ($step === 4): ?> <h3>Schritt 4: Installation abschließen</h3>
<form method="post">
<input type="hidden" name="step" value="5"> <p style="margin-bottom: 20px; color: #666;">
<h3>Schritt 4: Installation abschließen</h3> Klicken Sie auf "Installation abschließen", um die Einrichtung zu beenden.
Die Datenbank und alle notwendigen Dateien werden erstellt.
<p style="margin-bottom: 20px; color: #666;"> </p>
Klicken Sie auf "Installation abschließen", um die Einrichtung zu beenden.
Die Datenbank und alle notwendigen Dateien werden erstellt. <button type="submit" class="btn btn-primary btn-lg btn-block">Installation abschließen</button>
</p> </form>
<?php endif; ?>
<button type="submit" class="btn btn-primary btn-lg btn-block">Installation abschließen</button> </div>
</form> </body>
<?php endif; ?>
</div>
</body>
</html> </html>

View file

@ -220,6 +220,28 @@ return [
// Settings // Settings
'settings_title' => 'Einstellungen', 'settings_title' => 'Einstellungen',
'settings_subtitle' => 'System-Konfiguration und Personalisierung', 'settings_subtitle' => 'System-Konfiguration und Personalisierung',
'settings_tab_branding' => 'Design',
'settings_branding_intro' => 'Farben und Formen der gesamten Oberfläche Frontend wie Administration.',
'settings_brand_accent' => 'Akzentfarbe (hell)',
'settings_brand_accent_hint' => 'Buttons, aktive Navigation, Links. Abgeleitete Töne werden automatisch berechnet.',
'settings_brand_accent_dark' => 'Akzentfarbe (Dark Mode)',
'settings_brand_accent_dark_hint' => 'Im Dark Mode meist eine hellere Variante der Grundfarbe.',
'settings_brand_gradient_from'=> 'Markenverlauf: Start',
'settings_brand_gradient_to' => 'Markenverlauf: Ende',
'settings_brand_gradient_hint'=> 'Für Logo-Kachel, Avatare und die Voucher-Karte.',
'settings_brand_radius' => 'Eckenradius',
'settings_brand_radius_sharp' => 'Kantig',
'settings_brand_radius_default'=> 'Standard',
'settings_brand_radius_round' => 'Rund',
'settings_brand_preview' => 'Vorschau',
'settings_brand_link' => 'Beispiel-Link',
'settings_image_url_placeholder' => 'https://… oder Datei hochladen',
'settings_image_remove' => 'Bild entfernen',
'settings_upload_hint' => 'PNG, JPG, WEBP, GIF oder SVG maximal 3 MB.',
'upload_error_generic' => 'Die Datei konnte nicht hochgeladen werden.',
'upload_error_size' => 'Die Datei ist zu groß (maximal 3 MB).',
'upload_error_type' => 'Dieser Dateityp wird nicht unterstützt.',
'upload_error_dir' => 'Der Ordner uploads/ ist nicht beschreibbar.',
'settings_tab_login' => 'Login-Seite', 'settings_tab_login' => 'Login-Seite',
'settings_login_intro' => 'Aussehen und Texte der Anmeldeseite. Leere Felder verwenden die Standardwerte.', 'settings_login_intro' => 'Aussehen und Texte der Anmeldeseite. Leere Felder verwenden die Standardwerte.',
'settings_login_panel' => 'Linke Bildspalte (Split-Screen) anzeigen', 'settings_login_panel' => 'Linke Bildspalte (Split-Screen) anzeigen',
@ -239,6 +261,214 @@ return [
'settings_login_overlay' => 'Abdunklung des Bildes (%)', 'settings_login_overlay' => 'Abdunklung des Bildes (%)',
'settings_login_overlay_hint' => '090 %. Höhere Werte machen den Text auf hellen Bildern besser lesbar.', 'settings_login_overlay_hint' => '090 %. Höhere Werte machen den Text auf hellen Bildern besser lesbar.',
'settings_login_preview' => 'Vorschau öffnen', 'settings_login_preview' => 'Vorschau öffnen',
'api_title' => 'API-Schlüssel',
'api_subtitle' => 'Zugänge für externe Systeme mit Scope und Rate-Limit.',
'api_new_key' => 'Neuer Schlüssel',
'api_new_key_hint' => 'Kopieren Sie ihn jetzt aus Sicherheitsgründen wird er nicht erneut angezeigt.',
'api_create_title' => 'Neuen API-Schlüssel erstellen',
'api_label_name' => 'Bezeichnung',
'api_name_placeholder' => 'z.B. Buchungssystem, Terminal Foyer',
'api_label_scope' => 'Berechtigung',
'api_scope_write' => 'Lesen + Erstellen',
'api_scope_read' => 'Nur Lesen',
'api_scope_write_short' => 'Lesen+Erstellen',
'api_scope_read_short' => 'nur Lesen',
'api_label_limit' => 'Limit (Anfr./min)',
'api_limit_title' => '0 = unbegrenzt',
'api_existing' => 'Vorhandene Schlüssel',
'api_none' => 'Noch keine API-Schlüssel angelegt.',
'api_col_prefix' => 'Präfix',
'api_col_scope' => 'Scope',
'api_col_limit' => 'Limit',
'api_col_last_used' => 'Zuletzt genutzt',
'api_col_created_by' => 'Erstellt von',
'api_state_active' => 'aktiv',
'api_state_blocked' => 'gesperrt',
'api_action_block' => 'Sperren',
'api_action_unblock' => 'Aktivieren',
'api_delete_confirm' => 'Schlüssel löschen?',
'api_usage' => 'Verwendung',
'api_usage_hint' => 'Authentifizierung per Header',
'api_openapi' => 'OpenAPI-Spezifikation (Import in Postman/Swagger):',
'api_created' => 'API-Schlüssel erstellt.',
'api_deleted' => 'API-Schlüssel gelöscht.',
'api_toggled' => 'Status geändert.',
'api_name_required' => 'Bitte eine Bezeichnung angeben.',
'api_created_once' => 'API-Schlüssel erstellt. Bitte JETZT kopieren er wird nur einmal angezeigt!',
'api_status_updated' => 'Status aktualisiert.',
'int_title' => 'Integration & Wartung',
'int_subtitle' => 'SSO, Webhooks, SMS-Versand und Aufbewahrungsfristen.',
'int_security' => 'Sicherheitsrichtlinie',
'int_security_hint' => 'Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.',
'int_enforce_2fa' => '2FA für Administratoren verpflichtend',
'int_daily_limit' => 'Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)',
'int_session_driver' => 'Session-Speicher',
'int_session_php' => 'PHP-Standard (Dateien)',
'int_session_db' => 'Datenbank (ermöglicht „überall abmelden")',
'int_captcha' => 'Captcha im öffentlichen Modus',
'int_captcha_off' => 'Aus',
'int_captcha_math' => 'Rechenaufgabe (ohne externen Dienst)',
'int_secret_set' => ' (gesetzt)',
'int_secret_placeholder' => '••••••• (leer = unverändert)',
'int_proxy' => 'Reverse-Proxy',
'int_proxy_hint' => 'IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus',
'int_proxy_hint2' => 'für Rate-Limit & Audit verwendet.',
'int_webhook' => 'Webhook-Benachrichtigungen',
'int_webhook_hint' => 'Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.',
'int_webhook_active' => 'Webhook aktiv',
'int_webhook_url' => 'Webhook-URL',
'int_webhook_test' => 'Test senden',
'int_sms' => 'SMS-Versand (Twilio)',
'int_sms_hint' => 'Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.',
'int_sms_active' => 'SMS-Versand aktiv',
'int_sms_from' => 'Absender (From)',
'int_sso' => 'Single Sign-On (OpenID Connect)',
'int_sso_hint' => 'Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI:',
'int_sso_active' => 'OIDC-Login aktiv',
'int_sso_button' => 'Button-Text',
'int_cleanup' => 'Datenhaltung & Cleanup (DSGVO)',
'int_cleanup_hint' => 'Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per',
'int_cleanup_hint2' => '(täglich empfohlen).',
'int_cleanup_last' => 'Letzter Lauf:',
'int_cleanup_expired' => 'Abgelaufene Voucher',
'int_cleanup_audit' => 'Audit-Log',
'int_cleanup_logins' => 'Login-Versuche',
'int_saved' => 'Einstellungen gespeichert.',
'int_webhook_sent' => 'Test-Webhook gesendet.',
'int_webhook_failed' => 'Test-Webhook fehlgeschlagen.',
'int_webhook_test_sent' => 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).',
'sec_token_invalid' => 'Ungültiges Sicherheits-Token',
'sec_setup_expired' => 'Setup abgelaufen, bitte erneut starten.',
'sec_code_invalid' => 'Code ungültig. Bitte erneut versuchen.',
'sec_enabled' => 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!',
'sec_sessions_closed' => 'Alle anderen Sitzungen wurden beendet.',
'sec_codes_new' => 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.',
'sec_disabled' => 'Zwei-Faktor-Authentifizierung wurde deaktiviert.',
'backup_choose_file' => 'Bitte eine Backup-Datei auswählen.',
'backup_invalid_file' => 'Ungültige oder fremde Backup-Datei.',
'sec_title' => 'Zwei-Faktor-Authentifizierung',
'sec_account' => 'Konto:',
'sec_required_hint' => 'Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.',
'sec_recovery_codes' => 'Recovery-Codes',
'sec_recovery_hint' => 'Bewahren Sie diese sicher auf. Jeder Code funktioniert einmal, falls Sie keinen Zugriff auf Ihre App haben.',
'sec_unavailable' => 'Nicht verfügbar',
'sec_m365_hint' => 'Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.',
'sec_active' => 'Aktiv',
'sec_inactive' => 'Inaktiv',
'sec_active_hint' => 'Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.',
'sec_codes_left' => 'Verbleibende Recovery-Codes:',
'sec_regen_codes' => 'Recovery-Codes neu erzeugen',
'sec_disable_confirm' => '2FA wirklich deaktivieren?',
'sec_disable' => '2FA deaktivieren',
'sec_step_1' => 'Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)',
'sec_step_2' => 'QR-Code scannen oder Secret manuell eingeben',
'sec_step_3' => 'Den angezeigten 6-stelligen Code unten eingeben',
'sec_code_label' => '6-stelliger Code',
'sec_enable' => '2FA aktivieren',
'sec_sessions' => 'Aktive Sitzungen:',
'sec_logout_others_confirm' => 'Alle anderen Sitzungen abmelden?',
'sec_logout_others' => 'Auf allen anderen Geräten abmelden',
'import_title' => 'Voucher-Import',
'import_subtitle' => 'Mehrere Vouchers auf einmal aus einer CSV-Liste erstellen.',
'import_card_title' => 'Mehrere Voucher erstellen',
'import_format_hint' => 'Eine Zeile pro Voucher:',
'import_format_hint2' => ' MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:',
'import_site' => 'Standort',
'import_file' => 'CSV-Datei (optional)',
'import_paste' => '… oder direkt einfügen',
'import_confirm' => 'Import jetzt starten?',
'import_submit' => 'Importieren',
'import_result' => 'Ergebnis',
'import_col_code' => 'Code / Fehler',
'import_created' => '{count} Voucher erstellt.',
'backup_title' => 'Backup & Restore',
'backup_subtitle' => 'Konfiguration und Daten sichern oder wiederherstellen.',
'backup_export' => 'Export',
'backup_export_hint' => 'Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem',
'backup_export_hint2' => 'dieser Installation verschlüsselt ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.',
'backup_export_btn' => 'Konfiguration exportieren',
'backup_import' => 'Import / Restore',
'backup_import_hint' => 'Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.',
'backup_opt_settings' => 'Einstellungen',
'backup_opt_sites' => 'Sites',
'backup_opt_templates' => 'Voucher-Profile',
'backup_import_confirm' => 'Import jetzt durchführen?',
'backup_imported' => 'Import abgeschlossen: {settings} Einstellungen, {sites} Sites, {templates} Profile.',
'rep_title' => 'Reporting',
'rep_subtitle' => 'Auswertungen nach Zeitraum, Site und Benutzer.',
'rep_period' => 'Zeitraum',
'rep_days' => 'Tage',
'rep_csv_daily' => 'CSV (täglich)',
'rep_csv_site' => 'CSV (pro Site)',
'rep_csv_user' => 'CSV (pro Nutzer)',
'rep_print' => 'Drucken/PDF',
'rep_total' => 'Vouchers gesamt',
'rep_in_period' => 'In {days} Tagen erstellt',
'rep_chart_title' => 'Erstellte Voucher ({days} Tage)',
'rep_per_site' => 'Pro Site',
'rep_top_users' => 'Top-Nutzer',
'rep_col_created' => 'Voucher erstellt',
'rep_no_data' => 'Keine Daten',
'label_total' => 'Gesamt',
'label_user' => 'Benutzer',
'js_confirm_delete_voucher' => 'Voucher wirklich löschen?',
'js_confirm_delete_user' => 'Benutzer wirklich löschen?',
'js_confirm_delete_site' => 'Möchten Sie diese Site wirklich löschen?',
'js_confirm_delete_template' => 'Profil wirklich löschen?',
'js_confirm_delete_token' => 'Token wirklich löschen?',
'js_error' => 'Fehler',
'js_enter_email' => 'Bitte E-Mail eingeben.',
'js_running' => 'Läuft...',
'js_run_now' => 'Jetzt ausführen',
'js_copied' => 'Kopiert!',
'js_code_copied' => 'Code kopiert!',
'js_click_to_copy' => 'Klicken zum Kopieren',
'settings_defaults_hint' => 'Diese Werte werden als Vorgabe im Voucher-Formular verwendet.',
'settings_cron_what' => 'Was macht der Cron-Job?',
'settings_smtp_from_name' => 'Absender Name',
'settings_tpl_voucher_mail' => 'Voucher E-Mail',
'settings_tpl_user_notify' => 'Benutzer-Benachrichtigung',
'settings_editor_hint' => 'Der Editor (TinyMCE, GPL-Variante) wird lokal aus',
'settings_editor_hint2' => 'geladen es werden keine externen Dienste aufgerufen.',
'settings_print_template' => 'HTML Template für Voucher-Druck',
'settings_leave_empty' => 'Leer = nicht ändern',
'sites_id_hint' => 'Zu finden in der UniFi Controller URL',
'sites_pw_unchanged' => 'Leer lassen = nicht ändern',
'templates_minutes_hint' => '480 = 8 Stunden',
'templates_desc_placeholder' => 'Kurze Beschreibung für Ihr Team',
'audit_all_users' => 'Alle Benutzer',
'label_expires_at' => 'Gültig bis:',
'label_site_colon' => 'Standort:',
'audit_action_voucher_created' => 'Voucher erstellt',
'audit_action_voucher_bulk' => 'Bulk Voucher',
'audit_action_user_login' => 'Login',
'audit_action_user_logout' => 'Logout',
'audit_action_user_created' => 'Benutzer erstellt',
'audit_action_user_updated' => 'Benutzer geändert',
'audit_action_user_deleted' => 'Benutzer gelöscht',
'audit_action_site_added' => 'Site hinzugefügt',
'audit_action_site_updated' => 'Site geändert',
'audit_action_site_deleted' => 'Site gelöscht',
'audit_action_settings_saved' => 'Einstellungen gespeichert',
'audit_action_password_reset' => 'Passwort-Reset',
'audit_action_template_created' => 'Profil erstellt',
'audit_action_template_updated' => 'Profil geändert',
'audit_action_template_deleted' => 'Profil gelöscht',
'audit_page_info' => 'Seite {page} von {pages} ({total} Einträge)',
'a11y_skip' => 'Zum Inhalt springen',
'a11y_theme' => 'Darstellung umschalten (hell/dunkel)',
'a11y_menu' => 'Navigation öffnen',
'a11y_language' => 'Sprache',
'a11y_notifications' => 'Benachrichtigungen',
'audit_system_anon' => 'System/Anonym',
'vouchers_resend' => 'Per E-Mail senden',
'label_minutes_short' => 'Min.',
'js_copy' => 'Kopieren',
'settings_placeholders' => 'Platzhalter:',
'settings_card_hint' => '{VOUCHER_CARD} rendert den Code als hervorgehobene Karte im Marken-Layout.',
'settings_qr_hint' => '{QR_CODE} fügt einen QR-Code mit dem Voucher-Code ein.',
'print_valid_until' => 'Gültig bis',
'print_devices' => 'Geräte',
'settings_tab_general' => 'Allgemein', 'settings_tab_general' => 'Allgemein',
'settings_tab_defaults' => 'Voucher-Standards', 'settings_tab_defaults' => 'Voucher-Standards',
'settings_tab_cron' => 'Cron-Sync', 'settings_tab_cron' => 'Cron-Sync',
@ -249,8 +479,8 @@ return [
'settings_tab_password' => 'Passwort', 'settings_tab_password' => 'Passwort',
'settings_saved' => 'Einstellungen erfolgreich gespeichert!', 'settings_saved' => 'Einstellungen erfolgreich gespeichert!',
'settings_app_title' => 'Anwendungs-Titel *', 'settings_app_title' => 'Anwendungs-Titel *',
'settings_logo_url' => 'Logo-URL', 'settings_logo_url' => 'Logo',
'settings_favicon_url' => 'Favicon-URL', 'settings_favicon_url' => 'Favicon',
'settings_favicon_hint' => 'Icon im Browser-Tab (.ico, .png, .svg)', 'settings_favicon_hint' => 'Icon im Browser-Tab (.ico, .png, .svg)',
'settings_instr_header' => 'Anleitung - Überschrift', 'settings_instr_header' => 'Anleitung - Überschrift',
'settings_instr_text' => 'Anleitung - Text', 'settings_instr_text' => 'Anleitung - Text',

View file

@ -220,6 +220,28 @@ return [
// Settings // Settings
'settings_title' => 'Settings', 'settings_title' => 'Settings',
'settings_subtitle' => 'System configuration and customization', 'settings_subtitle' => 'System configuration and customization',
'settings_tab_branding' => 'Design',
'settings_branding_intro' => 'Colours and shapes for the whole interface front end and administration.',
'settings_brand_accent' => 'Accent colour (light)',
'settings_brand_accent_hint' => 'Buttons, active navigation, links. Derived shades are calculated automatically.',
'settings_brand_accent_dark' => 'Accent colour (dark mode)',
'settings_brand_accent_dark_hint' => 'Usually a lighter variant of the base colour for dark mode.',
'settings_brand_gradient_from'=> 'Brand gradient: start',
'settings_brand_gradient_to' => 'Brand gradient: end',
'settings_brand_gradient_hint'=> 'Used for the logo tile, avatars and the voucher card.',
'settings_brand_radius' => 'Corner radius',
'settings_brand_radius_sharp' => 'Sharp',
'settings_brand_radius_default'=> 'Default',
'settings_brand_radius_round' => 'Round',
'settings_brand_preview' => 'Preview',
'settings_brand_link' => 'Example link',
'settings_image_url_placeholder' => 'https://… or upload a file',
'settings_image_remove' => 'Remove image',
'settings_upload_hint' => 'PNG, JPG, WEBP, GIF or SVG 3 MB maximum.',
'upload_error_generic' => 'The file could not be uploaded.',
'upload_error_size' => 'The file is too large (3 MB maximum).',
'upload_error_type' => 'This file type is not supported.',
'upload_error_dir' => 'The uploads/ directory is not writable.',
'settings_tab_login' => 'Login page', 'settings_tab_login' => 'Login page',
'settings_login_intro' => 'Appearance and wording of the sign-in page. Empty fields fall back to the defaults.', 'settings_login_intro' => 'Appearance and wording of the sign-in page. Empty fields fall back to the defaults.',
'settings_login_panel' => 'Show left image column (split screen)', 'settings_login_panel' => 'Show left image column (split screen)',
@ -239,6 +261,214 @@ return [
'settings_login_overlay' => 'Image dimming (%)', 'settings_login_overlay' => 'Image dimming (%)',
'settings_login_overlay_hint' => '090%. Higher values keep text readable on bright images.', 'settings_login_overlay_hint' => '090%. Higher values keep text readable on bright images.',
'settings_login_preview' => 'Open preview', 'settings_login_preview' => 'Open preview',
'api_title' => 'API keys',
'api_subtitle' => 'Access for external systems with scope and rate limit.',
'api_new_key' => 'New key',
'api_new_key_hint' => 'Copy it now for security reasons it will not be shown again.',
'api_create_title' => 'Create a new API key',
'api_label_name' => 'Label',
'api_name_placeholder' => 'e.g. booking system, lobby terminal',
'api_label_scope' => 'Permission',
'api_scope_write' => 'Read + create',
'api_scope_read' => 'Read only',
'api_scope_write_short' => 'Read+create',
'api_scope_read_short' => 'read only',
'api_label_limit' => 'Limit (req./min)',
'api_limit_title' => '0 = unlimited',
'api_existing' => 'Existing keys',
'api_none' => 'No API keys created yet.',
'api_col_prefix' => 'Prefix',
'api_col_scope' => 'Scope',
'api_col_limit' => 'Limit',
'api_col_last_used' => 'Last used',
'api_col_created_by' => 'Created by',
'api_state_active' => 'active',
'api_state_blocked' => 'blocked',
'api_action_block' => 'Block',
'api_action_unblock' => 'Activate',
'api_delete_confirm' => 'Delete key?',
'api_usage' => 'Usage',
'api_usage_hint' => 'Authenticate using header',
'api_openapi' => 'OpenAPI specification (import into Postman/Swagger):',
'api_created' => 'API key created.',
'api_deleted' => 'API key deleted.',
'api_toggled' => 'Status changed.',
'api_name_required' => 'Please enter a label.',
'api_created_once' => 'API key created. Copy it NOW it is shown only once!',
'api_status_updated' => 'Status updated.',
'int_title' => 'Integration & maintenance',
'int_subtitle' => 'SSO, webhooks, SMS delivery and retention periods.',
'int_security' => 'Security policy',
'int_security_hint' => 'Enforces two-factor authentication for all administrator accounts (local accounts). Admins without 2FA are sent to the setup on their next action.',
'int_enforce_2fa' => 'Two-factor authentication mandatory for administrators',
'int_daily_limit' => 'Daily voucher limit per non-admin user (0 = unlimited)',
'int_session_driver' => 'Session storage',
'int_session_php' => 'PHP default (files)',
'int_session_db' => 'Database (enables "sign out everywhere")',
'int_captcha' => 'Captcha in public mode',
'int_captcha_off' => 'Off',
'int_captcha_math' => 'Arithmetic question (no external service)',
'int_secret_set' => ' (set)',
'int_secret_placeholder' => '••••••• (empty = unchanged)',
'int_proxy' => 'Reverse proxy',
'int_proxy_hint' => 'IP addresses of trusted proxies (comma separated). Only then is the real client IP taken from',
'int_proxy_hint2' => 'for rate limiting and the audit log.',
'int_webhook' => 'Webhook notifications',
'int_webhook_hint' => 'Slack, Microsoft Teams or generic JSON webhook URL. Triggered when a voucher is created.',
'int_webhook_active' => 'Webhook active',
'int_webhook_url' => 'Webhook URL',
'int_webhook_test' => 'Send test',
'int_sms' => 'SMS delivery (Twilio)',
'int_sms_hint' => 'Optionally send voucher codes by SMS. Requires a Twilio account.',
'int_sms_active' => 'SMS delivery active',
'int_sms_from' => 'Sender (from)',
'int_sso' => 'Single sign-on (OpenID Connect)',
'int_sso_hint' => 'Generic OIDC provider (e.g. Keycloak, Authentik, Google, Auth0). Redirect URI:',
'int_sso_active' => 'OIDC login active',
'int_sso_button' => 'Button label',
'int_cleanup' => 'Data retention & cleanup (GDPR)',
'int_cleanup_hint' => 'Retention periods in days (0 = disabled). Executed via',
'int_cleanup_hint2' => '(daily recommended).',
'int_cleanup_last' => 'Last run:',
'int_cleanup_expired' => 'Expired vouchers',
'int_cleanup_audit' => 'Audit log',
'int_cleanup_logins' => 'Login attempts',
'int_saved' => 'Settings saved.',
'int_webhook_sent' => 'Test webhook sent.',
'int_webhook_failed' => 'Test webhook failed.',
'int_webhook_test_sent' => 'Test notification sent (provided the webhook is active and the URL valid).',
'sec_token_invalid' => 'Invalid security token',
'sec_setup_expired' => 'Setup expired, please start again.',
'sec_code_invalid' => 'Invalid code. Please try again.',
'sec_enabled' => 'Two-factor authentication is now active. Please store the recovery codes safely!',
'sec_sessions_closed' => 'All other sessions have been signed out.',
'sec_codes_new' => 'New recovery codes generated. The previous ones are now invalid.',
'sec_disabled' => 'Two-factor authentication has been disabled.',
'backup_choose_file' => 'Please choose a backup file.',
'backup_invalid_file' => 'Invalid or foreign backup file.',
'sec_title' => 'Two-factor authentication',
'sec_account' => 'Account:',
'sec_required_hint' => 'For security reasons two-factor authentication is mandatory for administrators. Please set it up now.',
'sec_recovery_codes' => 'Recovery codes',
'sec_recovery_hint' => 'Keep them somewhere safe. Each code works once if you lose access to your app.',
'sec_unavailable' => 'Not available',
'sec_m365_hint' => 'Your account signs in via Microsoft 365. Two-factor authentication is managed in your Microsoft account.',
'sec_active' => 'Active',
'sec_inactive' => 'Inactive',
'sec_active_hint' => 'Every sign-in additionally asks for a code from your authenticator app.',
'sec_codes_left' => 'Remaining recovery codes:',
'sec_regen_codes' => 'Generate new recovery codes',
'sec_disable_confirm' => 'Really disable two-factor authentication?',
'sec_disable' => 'Disable two-factor authentication',
'sec_step_1' => 'Open your authenticator app (Google Authenticator, Authy, Microsoft Authenticator …)',
'sec_step_2' => 'Scan the QR code or enter the secret manually',
'sec_step_3' => 'Enter the six-digit code shown below',
'sec_code_label' => 'Six-digit code',
'sec_enable' => 'Enable two-factor authentication',
'sec_sessions' => 'Active sessions:',
'sec_logout_others_confirm' => 'Sign out all other sessions?',
'sec_logout_others' => 'Sign out on all other devices',
'import_title' => 'Voucher import',
'import_subtitle' => 'Create several vouchers at once from a CSV list.',
'import_card_title' => 'Create several vouchers',
'import_format_hint' => 'One line per voucher:',
'import_format_hint2' => ' max. devices and minutes are optional (defaults apply). 200 lines maximum. Example:',
'import_site' => 'Site',
'import_file' => 'CSV file (optional)',
'import_paste' => '… or paste directly',
'import_confirm' => 'Start the import now?',
'import_submit' => 'Import',
'import_result' => 'Result',
'import_col_code' => 'Code / error',
'import_created' => '{count} vouchers created.',
'backup_title' => 'Backup & restore',
'backup_subtitle' => 'Back up or restore configuration and data.',
'backup_export' => 'Export',
'backup_export_hint' => 'Downloads settings, sites and voucher profiles as JSON. Site passwords stay encrypted with the',
'backup_export_hint2' => 'of this installation a restore on an installation with a different APP_KEY cannot decrypt them.',
'backup_export_btn' => 'Export configuration',
'backup_import' => 'Import / restore',
'backup_import_hint' => 'Existing sites are matched by name + site ID and updated, new ones are added. Profiles are only created if the name does not exist yet. The cron token is never overwritten.',
'backup_opt_settings' => 'Settings',
'backup_opt_sites' => 'Sites',
'backup_opt_templates' => 'Voucher profiles',
'backup_import_confirm' => 'Run the import now?',
'backup_imported' => 'Import finished: {settings} settings, {sites} sites, {templates} profiles.',
'rep_title' => 'Reporting',
'rep_subtitle' => 'Analytics by period, site and user.',
'rep_period' => 'Period',
'rep_days' => 'days',
'rep_csv_daily' => 'CSV (daily)',
'rep_csv_site' => 'CSV (per site)',
'rep_csv_user' => 'CSV (per user)',
'rep_print' => 'Print / PDF',
'rep_total' => 'Vouchers total',
'rep_in_period' => 'Created in {days} days',
'rep_chart_title' => 'Vouchers created ({days} days)',
'rep_per_site' => 'Per site',
'rep_top_users' => 'Top users',
'rep_col_created' => 'Vouchers created',
'rep_no_data' => 'No data',
'label_total' => 'Total',
'label_user' => 'User',
'js_confirm_delete_voucher' => 'Really delete this voucher?',
'js_confirm_delete_user' => 'Really delete this user?',
'js_confirm_delete_site' => 'Do you really want to delete this site?',
'js_confirm_delete_template' => 'Really delete this profile?',
'js_confirm_delete_token' => 'Really delete this token?',
'js_error' => 'Error',
'js_enter_email' => 'Please enter an email address.',
'js_running' => 'Running…',
'js_run_now' => 'Run now',
'js_copied' => 'Copied!',
'js_code_copied' => 'Code copied!',
'js_click_to_copy' => 'Click to copy',
'settings_defaults_hint' => 'These values are used as defaults in the voucher form.',
'settings_cron_what' => 'What does the cron job do?',
'settings_smtp_from_name' => 'Sender name',
'settings_tpl_voucher_mail' => 'Voucher email',
'settings_tpl_user_notify' => 'User notification',
'settings_editor_hint' => 'The editor (TinyMCE, GPL build) is served locally from',
'settings_editor_hint2' => ' no external services are called.',
'settings_print_template' => 'HTML template for voucher printing',
'settings_leave_empty' => 'Empty = leave unchanged',
'sites_id_hint' => 'Found in the UniFi controller URL',
'sites_pw_unchanged' => 'Leave empty = unchanged',
'templates_minutes_hint' => '480 = 8 hours',
'templates_desc_placeholder' => 'Short description for your team',
'audit_all_users' => 'All users',
'label_expires_at' => 'Valid until:',
'label_site_colon' => 'Site:',
'audit_action_voucher_created' => 'Voucher created',
'audit_action_voucher_bulk' => 'Bulk vouchers',
'audit_action_user_login' => 'Login',
'audit_action_user_logout' => 'Logout',
'audit_action_user_created' => 'User created',
'audit_action_user_updated' => 'User updated',
'audit_action_user_deleted' => 'User deleted',
'audit_action_site_added' => 'Site added',
'audit_action_site_updated' => 'Site updated',
'audit_action_site_deleted' => 'Site deleted',
'audit_action_settings_saved' => 'Settings saved',
'audit_action_password_reset' => 'Password reset',
'audit_action_template_created' => 'Profile created',
'audit_action_template_updated' => 'Profile updated',
'audit_action_template_deleted' => 'Profile deleted',
'audit_page_info' => 'Page {page} of {pages} ({total} entries)',
'a11y_skip' => 'Skip to content',
'a11y_theme' => 'Toggle appearance (light/dark)',
'a11y_menu' => 'Open navigation',
'a11y_language' => 'Language',
'a11y_notifications' => 'Notifications',
'audit_system_anon' => 'System/anonymous',
'vouchers_resend' => 'Send by email',
'label_minutes_short' => 'min',
'js_copy' => 'Copy',
'settings_placeholders' => 'Placeholders:',
'settings_card_hint' => '{VOUCHER_CARD} renders the code as a highlighted card in the branded layout.',
'settings_qr_hint' => '{QR_CODE} inserts a QR code containing the voucher code.',
'print_valid_until' => 'Valid until',
'print_devices' => 'devices',
'settings_tab_general' => 'General', 'settings_tab_general' => 'General',
'settings_tab_defaults' => 'Voucher Defaults', 'settings_tab_defaults' => 'Voucher Defaults',
'settings_tab_cron' => 'Cron Sync', 'settings_tab_cron' => 'Cron Sync',
@ -249,8 +479,8 @@ return [
'settings_tab_password' => 'Password', 'settings_tab_password' => 'Password',
'settings_saved' => 'Settings saved successfully!', 'settings_saved' => 'Settings saved successfully!',
'settings_app_title' => 'Application Title *', 'settings_app_title' => 'Application Title *',
'settings_logo_url' => 'Logo URL', 'settings_logo_url' => 'Logo',
'settings_favicon_url' => 'Favicon URL', 'settings_favicon_url' => 'Favicon',
'settings_favicon_hint' => 'Browser tab icon (.ico, .png, .svg)', 'settings_favicon_hint' => 'Browser tab icon (.ico, .png, .svg)',
'settings_instr_header' => 'Instructions - Headline', 'settings_instr_header' => 'Instructions - Headline',
'settings_instr_text' => 'Instructions - Text', 'settings_instr_text' => 'Instructions - Text',

View file

@ -6,6 +6,7 @@ ini_set('log_errors', 1);
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php'; require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php'; require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/I18n.php'; require_once __DIR__ . '/includes/I18n.php';
try { try {
@ -145,7 +146,7 @@ try {
. ';--login-to:' . htmlspecialchars($loginBgTo, ENT_QUOTES) . ';--login-to:' . htmlspecialchars($loginBgTo, ENT_QUOTES)
. ';--login-overlay:' . ($loginOverlay / 100); . ';--login-overlay:' . ($loginOverlay / 100);
if ($loginBgImage !== '') { if ($loginBgImage !== '') {
$visualStyle .= ";--login-image:url('" . htmlspecialchars($loginBgImage, ENT_QUOTES) . "')"; $visualStyle .= ";--login-image:url('" . htmlspecialchars(Ui::mediaUrl($loginBgImage), ENT_QUOTES) . "')";
} }
} catch (Exception $e) { } catch (Exception $e) {
@ -158,12 +159,7 @@ try {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('login_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title><?= __('login_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <?= Ui::head($db) ?>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
</head> </head>
<body class="auth-body<?= $showPanel ? '' : ' auth-body-single' ?>"> <body class="auth-body<?= $showPanel ? '' : ' auth-body-single' ?>">
@ -171,9 +167,9 @@ try {
<section class="auth-visual<?= $loginBgImage !== '' ? ' has-image' : '' ?>" style="<?= $visualStyle ?>"> <section class="auth-visual<?= $loginBgImage !== '' ? ' has-image' : '' ?>" style="<?= $visualStyle ?>">
<div class="auth-brand"> <div class="auth-brand">
<?php if ($loginLogo): ?> <?php if ($loginLogo): ?>
<img src="<?= htmlspecialchars($loginLogo) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="auth-brand-logo"> <img src="<?= htmlspecialchars(Ui::mediaUrl($loginLogo)) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="auth-brand-logo">
<?php else: ?> <?php else: ?>
<span class="brand-mark"><i class="fas fa-wifi"></i></span> <span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
<span><?= htmlspecialchars($loginBrand) ?></span> <span><?= htmlspecialchars($loginBrand) ?></span>
<?php endif; ?> <?php endif; ?>
</div> </div>
@ -183,7 +179,7 @@ try {
<?php if (!empty($loginFeatures)): ?> <?php if (!empty($loginFeatures)): ?>
<ul class="auth-features"> <ul class="auth-features">
<?php foreach ($loginFeatures as $feature): ?> <?php foreach ($loginFeatures as $feature): ?>
<li><span class="tick"><i class="fas fa-check"></i></span> <?= htmlspecialchars($feature) ?></li> <li><span class="tick"><i class="fas fa-check" aria-hidden="true"></i></span> <?= htmlspecialchars($feature) ?></li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
<?php endif; ?> <?php endif; ?>
@ -194,24 +190,24 @@ try {
<section class="auth-panel"> <section class="auth-panel">
<div class="auth-tools"> <div class="auth-tools">
<div class="lang-switcher"> <div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
<?php foreach (I18n::getAvailable() as $code => $label): ?> <?php foreach (I18n::getAvailable() as $code => $label): ?>
<button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>" <button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>"
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button> onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode"> <button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
<i class="fas fa-moon"></i> <i class="fas fa-moon" aria-hidden="true"></i>
</button> </button>
</div> </div>
<div class="login-container"> <div class="login-container">
<?php if (!$showPanel): ?> <?php if (!$showPanel): ?>
<?php if ($loginLogo): ?> <?php if ($loginLogo): ?>
<img src="<?= htmlspecialchars($loginLogo) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="logo"> <img src="<?= htmlspecialchars(Ui::mediaUrl($loginLogo)) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="logo">
<?php else: ?> <?php else: ?>
<div class="auth-mark"> <div class="auth-mark">
<span class="brand-mark"><i class="fas fa-wifi"></i></span> <span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
<span><?= htmlspecialchars($loginBrand) ?></span> <span><?= htmlspecialchars($loginBrand) ?></span>
</div> </div>
<?php endif; ?> <?php endif; ?>
@ -285,12 +281,12 @@ try {
<?php if (!$show2fa && $oidcEnabled): ?> <?php if (!$show2fa && $oidcEnabled): ?>
<div class="divider"><span><?= __('or') ?></span></div> <div class="divider"><span><?= __('or') ?></span></div>
<a href="<?= htmlspecialchars($oidcLoginUrl) ?>" class="btn btn-secondary btn-lg"> <a href="<?= htmlspecialchars($oidcLoginUrl) ?>" class="btn btn-secondary btn-lg">
<i class="fas fa-key"></i> <?= htmlspecialchars($oidcName) ?> <i class="fas fa-key" aria-hidden="true"></i> <?= htmlspecialchars($oidcName) ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if ($publicAccess): ?> <?php if ($publicAccess): ?>
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left"></i> <?= __('login_back') ?></a></div> <div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('login_back') ?></a></div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</section> </section>

View file

@ -1,189 +1,186 @@
<?php <?php
// Umfassendes Error Reporting // Umfassendes Error Reporting
error_reporting(E_ALL); error_reporting(E_ALL);
ini_set('display_errors', 0); ini_set('display_errors', 0);
ini_set('log_errors', 1); ini_set('log_errors', 1);
ini_set('log_errors', 1); ini_set('log_errors', 1);
// Versuche Dateien zu laden // Versuche Dateien zu laden
$loadErrors = []; $loadErrors = [];
try { try {
if (!file_exists(__DIR__ . '/config.php')) { if (!file_exists(__DIR__ . '/config.php')) {
throw new Exception('config.php nicht gefunden'); throw new Exception('config.php nicht gefunden');
} }
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
} catch (Exception $e) { } catch (Exception $e) {
$loadErrors[] = "Config: " . $e->getMessage(); $loadErrors[] = "Config: " . $e->getMessage();
} }
try { try {
if (!file_exists(__DIR__ . '/includes/Database.php')) { if (!file_exists(__DIR__ . '/includes/Database.php')) {
throw new Exception('includes/Database.php nicht gefunden'); throw new Exception('includes/Database.php nicht gefunden');
} }
require_once __DIR__ . '/includes/Database.php'; require_once __DIR__ . '/includes/Database.php';
} catch (Exception $e) { } catch (Exception $e) {
$loadErrors[] = "Database: " . $e->getMessage(); $loadErrors[] = "Database: " . $e->getMessage();
} }
try { try {
if (!file_exists(__DIR__ . '/includes/Auth.php')) { if (!file_exists(__DIR__ . '/includes/Auth.php')) {
throw new Exception('includes/Auth.php nicht gefunden'); throw new Exception('includes/Auth.php nicht gefunden');
} }
require_once __DIR__ . '/includes/Auth.php'; require_once __DIR__ . '/includes/Auth.php';
} catch (Exception $e) { require_once __DIR__ . '/includes/Ui.php';
$loadErrors[] = "Auth: " . $e->getMessage(); } catch (Exception $e) {
} $loadErrors[] = "Auth: " . $e->getMessage();
}
// Wenn Ladefehler aufgetreten sind, zeige sie an
if (!empty($loadErrors)) { // Wenn Ladefehler aufgetreten sind, zeige sie an
die('<h1>Fehler beim Laden der Dateien</h1><ul><li>' . implode('</li><li>', $loadErrors) . '</li></ul>'); if (!empty($loadErrors)) {
} die('<h1>Fehler beim Laden der Dateien</h1><ul><li>' . implode('</li><li>', $loadErrors) . '</li></ul>');
}
// Ab hier normal weiter
try { // Ab hier normal weiter
$auth = new Auth(); try {
} catch (Exception $e) { $auth = new Auth();
die('<h1>Fehler bei Auth-Initialisierung</h1><p>' . $e->getMessage() . '</p>'); } catch (Exception $e) {
} die('<h1>Fehler bei Auth-Initialisierung</h1><p>' . $e->getMessage() . '</p>');
}
// Wenn bereits eingeloggt, weiterleiten
if ($auth->isLoggedIn()) { // Wenn bereits eingeloggt, weiterleiten
header('Location: index.php'); if ($auth->isLoggedIn()) {
exit; header('Location: index.php');
} exit;
}
$error = '';
$success = ''; $error = '';
$success = '';
// Login-Verarbeitung
if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Login-Verarbeitung
try { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = $_POST['email'] ?? ''; try {
$password = $_POST['password'] ?? ''; $email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
if (empty($email) || empty($password)) {
$error = 'Bitte E-Mail und Passwort eingeben'; if (empty($email) || empty($password)) {
} elseif ($auth->login($email, $password)) { $error = 'Bitte E-Mail und Passwort eingeben';
header('Location: index.php'); } elseif ($auth->login($email, $password)) {
exit; header('Location: index.php');
} else { exit;
$error = 'Ungültige E-Mail oder Passwort'; } else {
} $error = 'Ungültige E-Mail oder Passwort';
} catch (Exception $e) { }
$error = 'Login-Fehler: ' . $e->getMessage(); } catch (Exception $e) {
} $error = 'Login-Fehler: ' . $e->getMessage();
} }
}
try {
$db = Database::getInstance(); try {
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $db = Database::getInstance();
$logoUrl = $db->getSetting('logo_url', ''); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$m365Enabled = !empty($db->getSetting('m365_client_id')) && $logoUrl = $db->getSetting('logo_url', '');
!empty($db->getSetting('m365_client_secret')) && $m365Enabled = !empty($db->getSetting('m365_client_id')) &&
!empty($db->getSetting('m365_tenant_id')); !empty($db->getSetting('m365_client_secret')) &&
$publicAccess = $db->getSetting('public_access', 0); !empty($db->getSetting('m365_tenant_id'));
$publicAccess = $db->getSetting('public_access', 0);
// M365 OAuth URL generieren falls aktiviert
$m365LoginUrl = ''; // M365 OAuth URL generieren falls aktiviert
if ($m365Enabled) { $m365LoginUrl = '';
$clientId = $db->getSetting('m365_client_id'); if ($m365Enabled) {
$tenantId = $db->getSetting('m365_tenant_id'); $clientId = $db->getSetting('m365_client_id');
$tenantId = $db->getSetting('m365_tenant_id');
// Dynamische Redirect URI basierend auf aktuellem Pfad
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; // Dynamische Redirect URI basierend auf aktuellem Pfad
$host = $_SERVER['HTTP_HOST']; $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$scriptPath = dirname($_SERVER['SCRIPT_NAME']); $host = $_SERVER['HTTP_HOST'];
$scriptPath = $scriptPath === '/' ? '' : $scriptPath; $scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php'; $scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
$params = [
'client_id' => $clientId, $params = [
'response_type' => 'code', 'client_id' => $clientId,
'redirect_uri' => $redirectUri, 'response_type' => 'code',
'response_mode' => 'query', 'redirect_uri' => $redirectUri,
'scope' => 'openid profile email User.Read', 'response_mode' => 'query',
'state' => bin2hex(random_bytes(16)) 'scope' => 'openid profile email User.Read',
]; 'state' => bin2hex(random_bytes(16))
];
$_SESSION['m365_state'] = $params['state'];
$_SESSION['m365_state'] = $params['state'];
$m365LoginUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?" . http_build_query($params);
} $m365LoginUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?" . http_build_query($params);
} catch (Exception $e) { }
die('<h1>Datenbankfehler</h1><p>' . $e->getMessage() . '</p>'); } catch (Exception $e) {
} die('<h1>Datenbankfehler</h1><p>' . $e->getMessage() . '</p>');
?> }
<!DOCTYPE html> ?>
<html lang="de"> <!DOCTYPE html>
<head> <html lang="de">
<meta charset="UTF-8"> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta charset="UTF-8">
<title>Login - <?= htmlspecialchars($appTitle) ?></title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com"> <title>Login - <?= htmlspecialchars($appTitle) ?></title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <?= Ui::head($db) ?>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"> <style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> /* Diagnose-Ausgabe am Seitenende */
<link rel="stylesheet" href="assets/global.css"> .debug-info {
<style> margin-top: 22px; padding: 14px;
/* Diagnose-Ausgabe am Seitenende */ background: var(--bg-subtle); border: 1px solid var(--border-color); border-radius: var(--r-md);
.debug-info { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); text-align: left;
margin-top: 22px; padding: 14px; }
background: var(--bg-subtle); border: 1px solid var(--border-color); border-radius: var(--r-md); </style>
font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); text-align: left; </head>
} <body class="app-body focus-page">
</style> <div class="focus-card card login-container">
</head> <?php if ($logoUrl): ?>
<body class="app-body focus-page"> <img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
<div class="focus-card card login-container"> <?php else: ?>
<?php if ($logoUrl): ?> <h1><?= htmlspecialchars($appTitle) ?></h1>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo"> <?php endif; ?>
<?php else: ?>
<h1><?= htmlspecialchars($appTitle) ?></h1> <p class="subtitle">Melden Sie sich an, um fortzufahren</p>
<?php endif; ?>
<?php if ($error): ?>
<p class="subtitle">Melden Sie sich an, um fortzufahren</p> <div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($error): ?>
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div> <?php if ($success): ?>
<?php endif; ?> <div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div> <form method="post" action="">
<?php endif; ?> <div class="form-group">
<label for="email">E-Mail</label>
<form method="post" action=""> <input type="email" id="email" name="email" required autofocus>
<div class="form-group"> </div>
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required autofocus> <div class="form-group">
</div> <label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
<div class="form-group"> </div>
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required> <button type="submit" class="btn btn-primary btn-lg btn-block">Anmelden</button>
</div> </form>
<button type="submit" class="btn btn-primary btn-lg btn-block">Anmelden</button> <?php if ($m365Enabled): ?>
</form> <div class="divider"><span>oder</span></div>
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft">
<?php if ($m365Enabled): ?> <i class="fab fa-microsoft" aria-hidden="true"></i> Mit Microsoft 365 anmelden
<div class="divider"><span>oder</span></div> </a>
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft"> <?php endif; ?>
<i class="fab fa-microsoft"></i> Mit Microsoft 365 anmelden
</a> <?php if ($publicAccess): ?>
<?php endif; ?> <div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> Zurück zur Code-Erstellung</a></div>
<?php endif; ?>
<?php if ($publicAccess): ?>
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left"></i> Zurück zur Code-Erstellung</a></div> <!-- Debug Info (kann nach erfolgreicher Einrichtung entfernt werden) -->
<?php endif; ?> <div class="debug-info">
<strong>System-Status:</strong><br>
<!-- Debug Info (kann nach erfolgreicher Einrichtung entfernt werden) --> PHP Version: <?= phpversion() ?><br>
<div class="debug-info"> Session Status: <?= session_status() === PHP_SESSION_ACTIVE ? 'Aktiv' : 'Inaktiv' ?><br>
<strong>System-Status:</strong><br> Eingeloggt: <?= $auth->isLoggedIn() ? 'Ja' : 'Nein' ?>
PHP Version: <?= phpversion() ?><br> </div>
Session Status: <?= session_status() === PHP_SESSION_ACTIVE ? 'Aktiv' : 'Inaktiv' ?><br> </div>
Eingeloggt: <?= $auth->isLoggedIn() ? 'Ja' : 'Nein' ?> </body>
</div>
</div>
</body>
</html> </html>

View file

@ -32,8 +32,8 @@ $redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>M365 Debug</title> <title>M365 Debug</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"> <?php require_once __DIR__ . '/includes/Ui.php'; ?>
<link rel="stylesheet" href="assets/global.css"> <?= Ui::head($db ?? null) ?>
<style> <style>
body { padding: 32px 20px; } body { padding: 32px 20px; }
.section { max-width: 820px; margin: 0 auto 18px; padding: 20px; background: var(--bg-card); .section { max-width: 820px; margin: 0 auto 18px; padding: 20px; background: var(--bg-card);

View file

@ -1,6 +1,11 @@
parameters: parameters:
level: 5 level: 5
# __() stammt aus der I18n-Klasse und wird global definiert
scanFiles:
- includes/I18n.php
paths: paths:
- includes/Totp.php - includes/Totp.php
- includes/Crypto.php - includes/Crypto.php
- includes/ApiKey.php - includes/ApiKey.php
- includes/Ui.php
- includes/Upload.php

View file

@ -6,6 +6,7 @@ ini_set('log_errors', 1);
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php'; require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php'; require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/I18n.php'; require_once __DIR__ . '/includes/I18n.php';
$auth = new Auth(); $auth = new Auth();
@ -69,12 +70,7 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('reset_new_pw') ?> <?= htmlspecialchars($appTitle) ?></title> <title><?= __('reset_new_pw') ?> <?= htmlspecialchars($appTitle) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <?= Ui::head($db) ?>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style> <style>
.pw-strength { height: 4px; border-radius: var(--r-pill); margin-top: 8px; background: var(--border-color); transition: width .3s, background-color .3s; } .pw-strength { height: 4px; border-radius: var(--r-pill); margin-top: 8px; background: var(--border-color); transition: width .3s, background-color .3s; }
.pw-strength.weak { background: var(--danger); width: 30%; } .pw-strength.weak { background: var(--danger); width: 30%; }
@ -85,9 +81,9 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
<body class="app-body focus-page"> <body class="app-body focus-page">
<div class="focus-card card"> <div class="focus-card card">
<?php if ($logoUrl): ?> <?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo"> <img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
<?php else: ?> <?php else: ?>
<div class="focus-icon" style="margin-bottom:14px;"><i class="fas fa-key"></i></div> <div class="focus-icon" style="margin-bottom:14px;"><i class="fas fa-key" aria-hidden="true"></i></div>
<?php endif; ?> <?php endif; ?>
<h1><?= __('reset_new_pw') ?></h1> <h1><?= __('reset_new_pw') ?></h1>

View file

@ -4,6 +4,8 @@ ini_set('display_errors', 0);
ini_set('log_errors', 1); ini_set('log_errors', 1);
// Diagnose-Seite nur fuer angemeldete Admins zugaenglich (verhindert Info-Leak) // Diagnose-Seite nur fuer angemeldete Admins zugaenglich (verhindert Info-Leak)
require_once __DIR__ . '/includes/Ui.php';
if (file_exists(__DIR__ . '/config.php')) { if (file_exists(__DIR__ . '/config.php')) {
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php'; require_once __DIR__ . '/includes/Database.php';
@ -16,8 +18,7 @@ if (file_exists(__DIR__ . '/config.php')) {
echo '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8">' echo '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">' . '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
. '<title>System-Test</title>' . '<title>System-Test</title>'
. '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">' . Ui::head(isset($db) ? $db : null)
. '<link rel="stylesheet" href="assets/global.css">'
. '<style>.focus-card h2{font-size:14px;margin:22px 0 8px;} .focus-card h2:first-of-type{margin-top:0;}' . '<style>.focus-card h2{font-size:14px;margin:22px 0 8px;} .focus-card h2:first-of-type{margin-top:0;}'
. 'pre{white-space:pre-wrap;}</style>' . 'pre{white-space:pre-wrap;}</style>'
. '</head><body class="app-body focus-page">' . '</head><body class="app-body focus-page">'

2
tests/.htaccess Normal file
View file

@ -0,0 +1,2 @@
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
Require all denied

93
tests/UiTest.php Normal file
View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../includes/Ui.php';
/**
* Sehr einfache Datenbank-Attrappe: liefert nur Einstellungen zurueck.
*/
class FakeSettings
{
/** @var array<string, string> */
private array $values;
/** @param array<string, string> $values */
public function __construct(array $values = [])
{
$this->values = $values;
}
public function getSetting(string $key, $default = null)
{
return $this->values[$key] ?? $default;
}
}
class UiTest extends TestCase
{
public function testAssetUrlCarriesVersionStamp(): void
{
$url = \Ui::asset('assets/global.css');
$this->assertStringStartsWith('assets/global.css?v=', $url);
$this->assertMatchesRegularExpression('/\?v=\d+$/', $url);
}
public function testAssetUrlRespectsBasePath(): void
{
$this->assertStringStartsWith('../assets/global.css?v=', \Ui::asset('assets/global.css', '../'));
}
public function testBrandingStyleIsEmptyForDefaults(): void
{
$db = new FakeSettings([
'brand_accent' => \Ui::DEFAULT_ACCENT,
'brand_accent_dark' => \Ui::DEFAULT_ACCENT_DARK,
'brand_gradient_from' => \Ui::DEFAULT_GRADIENT_FROM,
'brand_gradient_to' => \Ui::DEFAULT_GRADIENT_TO,
'brand_radius' => (string)\Ui::DEFAULT_RADIUS,
]);
$this->assertSame('', \Ui::brandingStyle($db));
$this->assertSame('', \Ui::brandingStyle(null));
}
public function testBrandingStyleUsesCustomColour(): void
{
$style = \Ui::brandingStyle(new FakeSettings(['brand_accent' => '#0F766E']));
$this->assertStringContainsString('--accent:#0f766e', $style);
$this->assertStringContainsString('[data-theme="dark"]', $style);
}
public function testBrandingStyleIgnoresInvalidColour(): void
{
$style = \Ui::brandingStyle(new FakeSettings(['brand_accent' => 'rot; background:url(x)']));
$this->assertSame('', $style, 'Ungueltige Farben duerfen keinen Override erzeugen');
}
public function testBrandingRadiusIsClamped(): void
{
$style = \Ui::brandingStyle(new FakeSettings(['brand_radius' => '999']));
$this->assertStringContainsString('--r-lg:28px', $style);
}
public function testMediaUrlKeepsAbsoluteAddresses(): void
{
$this->assertSame('https://cdn.example.com/logo.svg', \Ui::mediaUrl('https://cdn.example.com/logo.svg', '../'));
$this->assertSame('/logo.svg', \Ui::mediaUrl('/logo.svg', '../'));
$this->assertSame('', \Ui::mediaUrl('', '../'));
}
public function testMediaUrlPrefixesUploads(): void
{
$this->assertSame('../uploads/logo.png', \Ui::mediaUrl('uploads/logo.png', '../'));
}
}

72
tests/UploadTest.php Normal file
View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use RuntimeException;
require_once __DIR__ . '/../includes/Upload.php';
/**
* Der SVG-Filter ist die sicherheitskritische Stelle beim Bild-Upload:
* hochgeladene Grafiken werden aus der eigenen Domain ausgeliefert, aktive
* Inhalte darin waeren damit gespeichertes XSS.
*/
class UploadTest extends TestCase
{
private function sanitize(string $svg): string
{
$method = new ReflectionMethod(\Upload::class, 'sanitizeSvg');
$method->setAccessible(true);
return $method->invoke(null, $svg);
}
public function testRemovesScriptElement(): void
{
$clean = $this->sanitize('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect/></svg>');
$this->assertStringNotContainsString('<script', $clean);
$this->assertStringNotContainsString('alert(1)', $clean);
$this->assertStringContainsString('<rect/>', $clean);
}
public function testRemovesEventHandlers(): void
{
$clean = $this->sanitize('<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect onclick=\'steal()\'/></svg>');
$this->assertStringNotContainsString('onload', $clean);
$this->assertStringNotContainsString('onclick', $clean);
}
public function testRemovesJavascriptLinks(): void
{
$clean = $this->sanitize('<svg xmlns="http://www.w3.org/2000/svg"><a xlink:href="javascript:alert(1)">x</a></svg>');
$this->assertStringNotContainsString('javascript:', $clean);
}
public function testKeepsHarmlessMarkup(): void
{
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="4" fill="#0f766e"/></svg>';
$this->assertSame($svg, $this->sanitize($svg));
}
public function testRejectsNonSvgContent(): void
{
$this->expectException(RuntimeException::class);
$this->sanitize('GIF89a<html>');
}
public function testIsLocalOnlyAcceptsUploadPaths(): void
{
$this->assertTrue(\Upload::isLocal('uploads/abc.png'));
$this->assertFalse(\Upload::isLocal('https://example.com/logo.png'));
$this->assertFalse(\Upload::isLocal('uploads/../config.php'));
$this->assertFalse(\Upload::isLocal(''));
}
}

2
tools/.htaccess Normal file
View file

@ -0,0 +1,2 @@
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
Require all denied

42
tools/README.md Normal file
View file

@ -0,0 +1,42 @@
# Werkzeuge
## Demo-Instanz (`tools/demo/build.py`)
Baut aus dem Projekt eine lauffähige Kopie **ohne Datenbank**: `Database` und
`Auth` werden durch Stubs mit festen Beispieldaten ersetzt (`tools/demo/overlay/`).
Das Original bleibt unverändert.
```bash
python3 tools/demo/build.py /tmp/uvt-demo
php -S 127.0.0.1:8123 -t /tmp/uvt-demo
```
Nützlich für:
* **Screenshots** für die Dokumentation immer mit denselben Daten
* **Smoke-Test** jede Seite einmal rendern, ohne MySQL aufzusetzen
Demo-spezifische Zustände werden über Query-Parameter erreicht:
| Parameter | Wirkung |
|---|---|
| `?demo=result` / `?demo=bulk` | Voucher-Ergebnis bzw. Bulk-Liste auf `index.php` |
| `?demo=new` | frisch erzeugter Schlüssel auf `admin/api_keys.php` |
| `?anon=1` | nicht angemeldet (für die Login-Seite) |
| `?theme=dark` | erzwingt den Dark Mode |
| `?brand=custom` / `?brand=nopanel` | Beispiel-Branding der Login-Seite |
## Screenshots (`tools/screenshots.py`)
Rendert die Bilder aus `docs/screenshots/` neu. Voraussetzung ist ein
headless Chromium; der Pfad kommt aus `$CHROME_BIN` oder wird in den
üblichen Verzeichnissen gesucht.
```bash
python3 tools/demo/build.py /tmp/uvt-demo
php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py
```
Die Skripte sind Hilfsmittel für die Entwicklung im Betrieb werden sie nicht
benötigt und sind per `.htaccess` nicht über HTTP erreichbar.

108
tools/demo/build.py Executable file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Baut eine Demo-Instanz des Voucher-Tools ohne Datenbank.
Die Demo dient zwei Zwecken:
* Screenshots fuer die Dokumentation reproduzierbar erzeugen
* Smoke-Test: rendert jede Seite einmal ohne echte Datenbank
Ablauf: Projekt in ein Zielverzeichnis kopieren, das Overlay darueberlegen
(Stubs fuer Database/Auth) und ein paar klar benannte Demo-Zustaende in die
Kopie patchen. Das Original bleibt unveraendert.
python3 tools/demo/build.py /tmp/uvt-demo
php -S 127.0.0.1:8123 -t /tmp/uvt-demo
"""
import os
import shutil
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OVERLAY = os.path.join(ROOT, 'tools', 'demo', 'overlay')
# Nur auf oberster Ebene ueberspringen assets/vendor gehoert dazu!
SKIP_TOP = {'.git', '.github', 'tools', 'docs', 'tests', 'vendor', 'node_modules'}
def copy_project(target: str) -> None:
if os.path.exists(target):
shutil.rmtree(target)
def ignore(directory, names):
if os.path.abspath(directory) == ROOT:
return [n for n in names if n in SKIP_TOP]
return []
shutil.copytree(ROOT, target, ignore=ignore)
for name in os.listdir(OVERLAY):
src = os.path.join(OVERLAY, name)
dst = os.path.join(target, name)
if os.path.isdir(src):
shutil.copytree(src, dst, dirs_exist_ok=True)
else:
shutil.copy2(src, dst)
def patch(path: str, anchor: str, replacement: str) -> None:
"""Ersetzt einen Anker in der Demo-Kopie. Fehlt der Anker, bricht der
Build ab so faellt auf, wenn sich die Vorlage geaendert hat."""
with open(path, encoding='utf-8') as fh:
content = fh.read()
if anchor not in content:
raise SystemExit('Anker nicht gefunden in %s:\n%s' % (path, anchor[:80]))
with open(path, 'w', encoding='utf-8') as fh:
fh.write(content.replace(anchor, replacement, 1))
def apply_demo_states(target: str) -> None:
# Voucher-Ergebnis und Bulk-Ansicht ohne Controller-Zugriff zeigen
anchor = "$currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null;"
patch(os.path.join(target, 'index.php'), anchor, anchor + """
// --- Demo-Instanz: Zustaende ohne UniFi-Controller darstellen ---
if (($_GET['demo'] ?? '') === 'result') {
$voucherCreated = true;
$voucherCode = '4829-17364';
$voucherData = ['code' => '4829-17364', 'site_name' => 'Hauptstandort Nord', 'max_uses' => 2,
'expire_min' => 480, 'expiry_date' => '23.09.2026', 'expiry_time' => '08:00'];
}
if (($_GET['demo'] ?? '') === 'bulk') {
$bulkCreated = true;
$bulkVouchers = [];
foreach (['4829-17364', '5517-90422', '7731-64508', '2094-38177', '6640-52913'] as $code) {
$bulkVouchers[] = ['code' => $code, 'site_name' => 'Hauptstandort Nord', 'max_uses' => 2,
'expire_min' => 480, 'expiry_date' => '23.09.2026', 'expiry_time' => '08:00'];
}
}
""")
# Live-Voucher-Liste automatisch laden (sonst wartet sie auf Site-Auswahl)
patch(os.path.join(target, 'admin', 'vouchers.php'), "</script>\n</body>", """</script>
<script>
// Demo-Instanz: Site vorauswaehlen und Liste laden
document.addEventListener('DOMContentLoaded', function () {
const sel = document.getElementById('siteSelect');
if (sel) { sel.value = '1'; loadVouchers(false); }
});
</script>
</body>""")
# Frisch erzeugten API-Schluessel zeigen
patch(os.path.join(target, 'admin', 'api_keys.php'), '$keys = $db->fetchAll(',
"if (($_GET['demo'] ?? '') === 'new') { $newKey = 'uvt_3f9a2c7d41e8b60592af18cc4d7e0b3a95f2617c'; }\n$keys = $db->fetchAll(")
# Theme per Query-Parameter erzwingen (fuer Dark-Mode-Screenshots)
ui = os.path.join(target, 'includes', 'Ui.php')
patch(ui, 'var s=localStorage.getItem("theme");',
'var s=new URLSearchParams(location.search).get("theme")||localStorage.getItem("theme");')
def main() -> int:
target = sys.argv[1] if len(sys.argv) > 1 else '/tmp/uvt-demo'
copy_project(target)
apply_demo_states(target)
print('Demo-Instanz gebaut: %s' % target)
print('Start: php -S 127.0.0.1:8123 -t %s' % target)
return 0
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1400" viewBox="0 0 1200 1400">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#7dd3fc"/><stop offset="55%" stop-color="#38bdf8"/><stop offset="100%" stop-color="#0369a1"/>
</linearGradient>
<linearGradient id="water" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0c4a6e"/><stop offset="100%" stop-color="#082f49"/>
</linearGradient>
</defs>
<rect width="1200" height="1400" fill="url(#sky)"/>
<circle cx="900" cy="260" r="90" fill="#fef3c7" opacity=".85"/>
<path d="M0 780 L260 560 L430 780 Z" fill="#134e4a" opacity=".85"/>
<path d="M300 800 L560 520 L830 800 Z" fill="#115e59" opacity=".9"/>
<path d="M700 800 L980 600 L1200 800 Z" fill="#0f766e" opacity=".85"/>
<rect y="800" width="1200" height="600" fill="url(#water)"/>
<g fill="#ffffff" opacity=".16">
<rect x="120" y="880" width="360" height="6" rx="3"/>
<rect x="240" y="960" width="520" height="6" rx="3"/>
<rect x="60" y="1060" width="420" height="6" rx="3"/>
<rect x="520" y="1140" width="600" height="6" rx="3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="220" height="42" viewBox="0 0 220 42">
<g fill="none" stroke="#ffffff" stroke-width="2.4" stroke-linecap="round">
<path d="M8 30c4-10 10-15 13-15s9 5 13 15"/>
<path d="M14 30c2.5-6 5.5-9 7-9s4.5 3 7 9"/>
</g>
<circle cx="21" cy="30" r="2.6" fill="#ffffff"/>
<text x="46" y="21" font-family="Inter, sans-serif" font-size="16" font-weight="700" fill="#ffffff">HOTEL SEEBLICK</text>
<text x="46" y="34" font-family="Inter, sans-serif" font-size="10.5" letter-spacing="2" fill="rgba(255,255,255,.75)">GÄSTE-WLAN</text>
</svg>

After

Width:  |  Height:  |  Size: 588 B

View file

@ -0,0 +1,31 @@
<?php
/** Demo-Stub: angemeldeter Admin ohne echte Sitzungspruefung. */
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Totp.php';
class Auth {
private $user = ['id'=>1,'name'=>'Marie Sander','email'=>'marie.sander@example.com','is_admin'=>1,
'role'=>'admin','is_active'=>1,'password_hash'=>'x','totp_enabled'=>0,'auth_source'=>'local'];
public function __construct() { if (session_status() === PHP_SESSION_NONE) @session_start(); $_SESSION['user_id'] = 1; }
public function clientIp() { return '192.168.10.42'; }
public function login($e, $p) { return false; }
public function isTotpPending() { return isset($_GET['totp']); }
public function verifyTotpLogin($c) { return false; }
public function enableTotp($u, $s) { return true; }
public function disableTotp($u) { return true; }
public function generateBackupCodes($n = 8) { return ['A1B2-C3D4','E5F6-G7H8','J9K0-L1M2','N3P4-Q5R6']; }
public function regenerateBackupCodes($u) { return $this->generateBackupCodes(); }
public function backupCodesRemaining($u) { return 6; }
public function writeAuditLog(...$a) { return true; }
public function logout() {}
public function isLoggedIn() { return !isset($_GET['anon']); }
public function isAdmin() { return $this->isLoggedIn(); }
public function getCurrentUser() { return $this->isLoggedIn() ? $this->user : null; }
public function hasAccessToSite($id) { return true; }
public function validateCsrfToken($t) { return true; }
public function getCsrfToken() { return 'demo-csrf-token'; }
public function registerUser(...$a) { return true; }
public function requireAdmin() {}
public function requireLogin() {}
public function activeSessionCount() { return 3; }
public function logoutOtherSessions() { return 2; }
}

View file

@ -0,0 +1,163 @@
<?php
/** Demo-Stub: liefert feste Beispieldaten statt echter DB-Zugriffe. */
class Database {
private static $instance = null;
private $settings = [
'app_title' => 'UniFi Voucher System',
'logo_url' => '', 'favicon_url' => '',
'public_access' => '1', 'smtp_enabled' => '1', 'sms_enabled' => '1',
'm365_enabled' => '1', 'oidc_enabled' => '0', 'oidc_name' => 'Keycloak',
'default_expire_minutes' => '480', 'default_max_uses' => '2', 'max_uses_limit' => '10',
'instruction_header' => 'So verbinden Sie sich',
'instruction_text' => 'WLAN „Gast-WiFi" wählen, Code eingeben und bestätigen. Bei Fragen hilft der Empfang gerne weiter.',
'last_cron_sync' => '2026-09-22 14:35:00',
'session_driver' => 'db', 'captcha_mode' => 'math',
'enforce_2fa_admins' => '1', 'user_daily_voucher_limit' => '25',
'webhook_enabled' => '1', 'webhook_url' => 'https://hooks.slack.com/services/T024/B0197/XYZ',
'twilio_sid' => 'AC8f2c1d94e77b40a2', 'twilio_from' => '+4915112345678',
'trusted_proxy' => '10.0.0.1, 172.18.0.1',
'cleanup_expired_days' => '90', 'cleanup_audit_days' => '365', 'cleanup_login_days' => '30',
'last_cleanup' => '2026-09-22 03:00:00',
];
public function __construct()
{
// Screenshot-Varianten: ?brand=custom|gradient|nopanel
$brand = $_GET['brand'] ?? '';
if ($brand === 'custom') {
$this->settings = array_merge($this->settings, [
'app_title' => 'Hotel Seeblick',
'login_brand_name' => 'Hotel Seeblick',
'login_logo_url' => '/demo-assets/logo.svg',
'login_claim_title' => 'Willkommen im Hotel Seeblick.',
'login_claim_text' => 'Gäste-WLAN für Zimmer, Tagungsräume und Restaurant Zugangscodes direkt an der Rezeption erstellen.',
'login_features' => "Code direkt beim Check-in ausdrucken\nTagungsgäste per E-Mail versorgen\nAuswertung je Haus und Etage",
'login_footer' => '© 2026 Hotel Seeblick GmbH · Datenschutz · Impressum',
'login_bg_image' => '/demo-assets/background.svg',
'login_bg_overlay' => '45',
'brand_accent' => '#0f766e',
'brand_accent_dark' => '#2dd4bf',
'brand_gradient_from' => '#0f766e',
'brand_gradient_to' => '#0ea5e9',
]);
} elseif ($brand === 'nopanel') {
$this->settings = array_merge($this->settings, [
'login_panel_enabled' => '0',
'login_brand_name' => 'Stadtwerke Nordheim',
]);
}
}
public static function getInstance() { return self::$instance ??= new self(); }
public function getConnection() { return null; }
public function getSetting($key, $default = null) { return $this->settings[$key] ?? $default; }
public function setSetting($key, $value) { $this->settings[$key] = $value; }
public function execute($sql, $params = []) { return 1; }
public function query($sql, $params = []) { return new DemoStmt(); }
public function fetchOne($sql, $params = []) { $r = $this->fetchAll($sql, $params); return $r[0] ?? false; }
public function fetchAll($sql, $params = []) {
$s = preg_replace('/\s+/', ' ', strtolower($sql));
if (str_contains($s, 'count(*) as count from sites')) return [['count'=>4]];
if (str_contains($s, 'count(*) as count from users')) return [['count'=>12]];
if (str_contains($s, 'count(*) as count from vouchers where date(created_at)=curdate()')) return [['count'=>18]];
if (str_contains($s, 'count(*) as count from vouchers where date(created_at)=')) {
$day = $params[0] ?? '';
$map = [6,9,5,12,8,4,7,14,11,16,9,6,13,18];
$idx = abs((int)((strtotime($day) - strtotime('today')) / 86400));
return [['count' => $map[13 - min($idx, 13)]]];
}
if (str_contains($s, 'from vouchers where site_id=?') && str_contains($s, 'count(*) as total')) {
$per = [1=>['total'=>128,'valid'=>74,'used'=>39,'expired'=>15],
2=>['total'=>63,'valid'=>41,'used'=>18,'expired'=>4],
3=>['total'=>22,'valid'=>14,'used'=>6,'expired'=>2],
4=>['total'=>0,'valid'=>0,'used'=>0,'expired'=>0]];
return [$per[(int)($params[0] ?? 1)] ?? $per[4]];
}
if (str_contains($s, 'count(*) as total') && str_contains($s, 'from vouchers')) {
return [['total'=>213,'valid'=>129,'used'=>63,'expired'=>21]];
}
if (str_contains($s, 'from sites')) {
return [
['id'=>1,'name'=>'Hauptstandort Nord','site_id'=>'default','unifi_controller_url'=>'https://unifi.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>1,'created_at'=>'2026-01-12 09:00:00'],
['id'=>2,'name'=>'Campus West','site_id'=>'campus-west','unifi_controller_url'=>'https://unifi.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>1,'created_at'=>'2026-02-03 11:30:00'],
['id'=>3,'name'=>'Showroom Berlin','site_id'=>'showroom','unifi_controller_url'=>'https://unifi-b.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>1,'created_at'=>'2026-04-21 15:10:00'],
['id'=>4,'name'=>'Lager Süd','site_id'=>'lager','unifi_controller_url'=>'https://unifi-s.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>0,'created_at'=>'2026-06-08 08:45:00'],
];
}
if (str_contains($s, 'count(v.id) as voucher_count') || str_contains($s, 'from users u left join vouchers')) {
return [
['name'=>'Marie Sander','email'=>'marie.sander@example.com','voucher_count'=>48,'c'=>48],
['name'=>'Jonas Weber','email'=>'j.weber@example.com','voucher_count'=>31,'c'=>31],
['name'=>'Alina Brandt','email'=>'alina.brandt@example.com','voucher_count'=>27,'c'=>27],
['name'=>'Tim Kurz','email'=>'tim.kurz@example.com','voucher_count'=>19,'c'=>19],
['name'=>'Sara Vogt','email'=>'sara.vogt@example.com','voucher_count'=>12,'c'=>12],
];
}
if (str_contains($s, 'from vouchers')) {
if (str_contains($s, 'group by date')) {
$out = []; $vals = [6,9,5,12,8,4,7,14,11,16,9,6,13,18];
foreach ($vals as $i => $c) { $out[] = ['date'=>date('d.m', strtotime('-'.(13-$i).' days')), 'count'=>$c]; }
return $out;
}
$codes = [['H7K2-M9QF',1,'Hauptstandort Nord','valid'],['P3RT-8ZXC',2,'Campus West','used'],
['QW4E-7TYU',1,'Hauptstandort Nord','valid'],['LM2N-5BVD',3,'Showroom Berlin','expired'],
['ZX9C-3PLO',2,'Campus West','valid'],['FG6H-1JKL',1,'Hauptstandort Nord','used']];
$names = ['Besuch Agentur Nordlicht','Workshop Raum 2','Empfang Tagesgast','Messe-Stand','Handwerker Haustechnik','Bewerbungsgespräch'];
$out = [];
foreach ($codes as $i => [$c,$sid,$sname,$st]) {
$out[] = ['id'=>$i+1,'voucher_code'=>$c,'site_id'=>$sid,'site_name'=>$sname,'user_name'=>'Marie Sander',
'status'=>$st,'created_at'=>date('Y-m-d H:i:s', strtotime("-{$i} hours")),
'voucher_name'=>$names[$i],'unifi_voucher_id'=>'65f1a'.$i,
'expires_at'=>date('Y-m-d H:i:s', strtotime('+'.(8-$i).' hours')),
'max_uses'=>[2,5,1,3,2,1][$i],'used_count'=>[2,1,0,3,0,1][$i],'expire_minutes'=>480];
}
return $out;
}
if (str_contains($s, 'from users')) {
return [
['id'=>1,'name'=>'Marie Sander','email'=>'marie.sander@example.com','is_admin'=>1,'is_active'=>1,'created_at'=>'2026-01-10 09:00:00','last_login'=>'2026-09-22 08:12:00','totp_enabled'=>1,'auth_source'=>'local','voucher_count'=>48,'password_hash'=>'x'],
['id'=>2,'name'=>'Jonas Weber','email'=>'j.weber@example.com','is_admin'=>0,'is_active'=>1,'created_at'=>'2026-03-02 13:20:00','last_login'=>'2026-09-21 17:40:00','totp_enabled'=>0,'auth_source'=>'m365','voucher_count'=>31,'password_hash'=>''],
['id'=>3,'name'=>'Alina Brandt','email'=>'alina.brandt@example.com','is_admin'=>0,'is_active'=>1,'created_at'=>'2026-05-18 10:05:00','last_login'=>'2026-09-20 09:31:00','totp_enabled'=>1,'auth_source'=>'local','voucher_count'=>27,'password_hash'=>'x'],
];
}
if (str_contains($s, 'from api_keys')) {
return [
['id'=>1,'name'=>'Buchungssystem','key_prefix'=>'3f9a2c','scope'=>'write','rate_limit'=>60,'is_active'=>1,'last_used_at'=>'2026-09-22 09:12:00','creator'=>'Marie Sander','created_at'=>'2026-06-01 10:00:00'],
['id'=>2,'name'=>'Terminal Foyer','key_prefix'=>'b71e04','scope'=>'read','rate_limit'=>0,'is_active'=>1,'last_used_at'=>'2026-09-21 17:40:00','creator'=>'Marie Sander','created_at'=>'2026-07-14 16:20:00'],
['id'=>3,'name'=>'Altes Kassensystem','key_prefix'=>'c0d5f8','scope'=>'write','rate_limit'=>30,'is_active'=>0,'last_used_at'=>null,'creator'=>'Jonas Weber','created_at'=>'2026-02-09 08:05:00'],
];
}
if (str_contains($s, 'from audit_log')) {
if (str_contains($s, 'count(')) return [['total'=>1284,'c'=>1284,'count'=>1284]];
if (str_contains($s, 'distinct action')) {
return [['action'=>'voucher_created'],['action'=>'user_login'],['action'=>'settings_saved'],['action'=>'user_created'],['action'=>'template_updated']];
}
$rows = [['2026-09-22 14:32:00','Marie Sander','voucher_created','Hauptstandort Nord · H7K2-M9QF','192.168.10.42'],
['2026-09-22 13:58:00','Jonas Weber','user_login','Microsoft 365','83.112.4.17'],
['2026-09-22 11:04:00','Marie Sander','settings_saved','Tab: E-Mail (SMTP)','192.168.10.42'],
['2026-09-21 17:40:00','Alina Brandt','user_created','tim.kurz@example.com','192.168.10.88'],
['2026-09-21 09:12:00','Marie Sander','template_updated','Tagesgast','192.168.10.42'],
['2026-09-20 16:03:00','Jonas Weber','voucher_bulk','5 Vouchers · Campus West','83.112.4.17']];
$out = [];
foreach ($rows as $i => [$d,$u,$a,$det,$ip]) {
$out[] = ['id'=>$i+1,'created_at'=>$d,'user_name'=>$u,'user_email'=>'demo@example.com','action'=>$a,'details'=>$det,'ip_address'=>$ip,'entity_type'=>'voucher','entity_id'=>$i+1];
}
return $out;
}
if (str_contains($s, 'templates')) {
return [
['id'=>1,'name'=>'Tagesgast','description'=>'Standardprofil für Besucher am Empfang','max_uses'=>2,'expire_minutes'=>480,'qos_rate_max_down'=>20000,'qos_rate_max_up'=>5000,'qos_usage_quota'=>0,'is_active'=>1,'is_default'=>1,'created_at'=>'2026-01-15 10:00:00'],
['id'=>2,'name'=>'Konferenz','description'=>'Mehrtägige Veranstaltungen','max_uses'=>5,'expire_minutes'=>4320,'qos_rate_max_down'=>50000,'qos_rate_max_up'=>10000,'qos_usage_quota'=>0,'is_active'=>1,'is_default'=>0,'created_at'=>'2026-02-20 11:30:00'],
['id'=>3,'name'=>'Handwerker','description'=>'Kurzzugang für Dienstleister','max_uses'=>1,'expire_minutes'=>240,'qos_rate_max_down'=>10000,'qos_rate_max_up'=>2000,'qos_usage_quota'=>1024,'is_active'=>1,'is_default'=>0,'created_at'=>'2026-03-08 09:15:00'],
];
}
return [];
}
}
class DemoStmt {
public function fetch($m = null) { return false; }
public function fetchAll($m = null) { return []; }
public function rowCount() { return 1; }
public function execute($p = []) { return true; }
}

Some files were not shown because too many files have changed in this diff Show more