Compare commits

..

4 commits

Author SHA1 Message Date
Claude
5496f49fd5
README-Screenshots neu erstellt – alle einheitlich im 16:10-Format
Alle 12 Screenshots (Login, Voucher-Formular/-Ergebnis, Bulk, Dashboard
hell/dunkel, API-Keys, 2FA, Integrationen, Updater idle/verfügbar,
Wartungsmodus) mit 2880x1800 px (1440x900 @2x) aufgenommen.

https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
2026-06-09 20:13:34 +00:00
Claude
1a2f86ed3d
Merge origin/main: Review-Branch mit v2.4.0-Features zusammenführen
Konfliktauflösung kombiniert beide Seiten:
- Auth: Secure-Cookie-Flag + DB-Session-Handler (main)
- UniFiController: createVouchers (n-Parameter, 1 API-Call) + QoS-Optionen (main)
- index.php: IP-Rate-Limit/PRG/Sticky-Forms + CAPTCHA/SMS/QoS/Tageslimit (main)
- users.php: 2FA-Reset (main) auf POST+PRG umgestellt wie übrige Aktionen
- forgot_password: Session-Throttle (main) + IP-Throttle kombiniert
- Eigene Migrationen wegen Nummernkollision auf 0005/0006 umbenannt

https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
2026-06-09 20:03:32 +00:00
Claude
968afbb212
SSL-Verifizierung als Opt-in: pro Site (UniFi) und für SMTP
- UniFiController: CURLOPT_SSL_VERIFYPEER/-HOST über neues Site-Feld
  ssl_verify steuerbar (Default aus, da UniFi meist self-signed);
  testConnection() und alle Aufrufer angepasst
- sites: Checkbox in Anlegen/Bearbeiten-Modal, Spalte via Migration 0003
  (Alt-Installationen tolerant über '?? 0')
- Mailer: stream_context verify_peer/verify_peer_name über neues Setting
  smtp_verify_ssl (Checkbox im SMTP-Tab)
- Sprach-Keys de/en ergänzt

https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
2026-06-09 19:46:19 +00:00
Claude
6e19958a37
Security-, Bugfix- und UX-Überarbeitung auf Basis des Code-Reviews
Sicherheit:
- Bulk-Erstellung serverseitig auf eingeloggte Nutzer beschränkt;
  expire_minutes wird validiert (anonym: nur Default/Template-Werte,
  eingeloggt: max. 1 Jahr)
- IP-basiertes Rate-Limit über neue Tabelle request_throttle
  (Voucher-Erstellung + Passwort-Reset-Anfragen), Session-Fallback
  für Alt-Installationen; Migration 0002
- session_regenerate_id() nach Login, Secure-Cookie-Flag bei HTTPS
- Admin-/Aktiv-Status wird pro Request live aus der DB geprüft
  (Rechteentzug & Deaktivierung wirken sofort); Schutz vor
  Selbst-Degradierung im Benutzer-Edit
- Alle state-ändernden Admin-Aktionen von GET auf POST umgestellt
  (kein CSRF-Token mehr in URLs)
- login_simple.php (Legacy, Debug-Leak) entfernt; cron_test.php nur
  noch für Admins; .htaccess auf Apache-2.4-Syntax inkl. cron_test.php
- M365 Client Secret wird nicht mehr ins Formular zurückgegeben
- Updater: Zip-Slip-/Pfad-Traversal-Schutz, Backup vor dem Anwenden
  mit automatischem Rollback bei Fehlern, AuditLogger-Bug behoben
- cron_sync: Token-Vergleich mit hash_equals; login_attempts-Pruning
- CSV-Export gegen Excel-Formula-Injection abgesichert

Bugfixes:
- M365-Login: Fallback auf userPrincipalName, wenn Graph kein 'mail'
  liefert (Nutzer ohne Exchange-Postfach konnten sich nie anmelden)
- PRG-Pattern überall: F5 erzeugt keine Duplikat-Voucher und
  wiederholt keine Admin-Aktionen (Session-Flash-Messages)
- QR-Code nicht mehr invertiert (schwarz auf weiß, scanbar)
- Bulk-Erstellung nutzt den UniFi 'n'-Parameter: 1 API-Call statt
  n× Login + Voucherlisten-Abruf; exaktes Code-Matching per
  create_time statt "global neuester Voucher"
- Mailer: doppelte Zeilenumbrüche behoben, AUTH nur mit Credentials,
  SMTP-Dot-Stuffing, CLI-sicherer EHLO-Host
- forgot_password: System-URL-Auto-Detect (Reset-Link war sonst
  relativ/kaputt) + Rate-Limit
- Audit-Log-Labels an tatsächliche Action-Keys angepasst;
  Voucher-Erstellung (einzeln & bulk) wird jetzt auditiert
- Site-Edit testet die Verbindung auch ohne Passwortänderung

UX/UI:
- Alert-/Badge-Styles zentral in global.css mit Dark-Mode-Variablen
  (vorher 7× dupliziert mit hart codierten Hellfarben)
- Sticky-Formulare + Tab-Erhalt nach Validierungsfehlern (Bulk),
  Settings kehren nach dem Speichern zum aktiven Tab zurück
- Gültigkeit menschenlesbar (z.B. "8 Stunden" statt "480 Minuten")
- Voucher-Name-Default "Gast/Guest" im öffentlichen Modus
- Favicon auch auf Login-/öffentlichen Seiten
- Verbindungstest-Button pro Site-Karte (Health-Check)
- i18n-Pass: Confirm-Dialoge, Toasts, Fehl-/Erfolgsmeldungen in de/en
- Sprachumschalter ohne fetch+reload (kein Re-Submit-Dialog)
- A11y: Esc schließt Modals, aria-live für Toasts, aria-labels auf
  Icon-Buttons; APP_KEY-Warnbanner im Dashboard
- Dashboard-Sync: set_time_limit passend zur Site-Anzahl;
  Voucher-Sync mit Map statt SELECT pro Voucher

Tooling:
- GitHub-Actions-Workflow: PHP-Lint aller Dateien + de/en-Key-Parität

https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
2026-06-09 19:43:13 +00:00
139 changed files with 3600 additions and 8660 deletions

15
.gitattributes vendored
View file

@ -1,15 +0,0 @@
# Zeilenenden nicht anfassen einige Dateien liegen bewusst mit CRLF vor.
* -text
# Dateien, die nicht ins Release-ZIP gehoeren.
# `git archive` (siehe .github/workflows/release.yml) wertet export-ignore aus.
/.gitattributes export-ignore
/.gitignore export-ignore
/.dockerignore export-ignore
/.github export-ignore
/docs export-ignore
/tests export-ignore
/tools export-ignore
/phpunit.xml.dist export-ignore
/phpstan.neon export-ignore
/composer.lock export-ignore

View file

@ -29,40 +29,9 @@ jobs:
php -l "$f" php -l "$f"
done done
- name: Validate language files - name: Validate JSON language/migration assets
run: | run: |
php -r ' 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";'
$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

30
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: Lint
on:
push:
pull_request:
jobs:
php-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: PHP Syntax-Check (alle Dateien)
run: |
set -e
fail=0
while IFS= read -r f; do
php -l "$f" > /dev/null || fail=1
done < <(git ls-files '*.php')
exit $fail
- name: Sprachdateien-Paritaet (de/en)
run: |
php -r '
$de = require "lang/de.php"; $en = require "lang/en.php";
$missing = array_merge(array_diff(array_keys($de), array_keys($en)), array_diff(array_keys($en), array_keys($de)));
if ($missing) { fwrite(STDERR, "Fehlende Keys: " . implode(", ", $missing) . "\n"); exit(1); }
echo "OK: " . count($de) . " Keys synchron\n";
'

View file

@ -1,152 +0,0 @@
name: Release-Paket
# Bei jedem Merge nach main entsteht ein installierbares ZIP und landet als
# Vorab-Release "latest-main" im Repository. Wird ein Tag v* gepusht, wird
# daraus ein regulaeres Release mit derselben Mechanik.
on:
push:
branches: [ main ]
tags: [ 'v*' ]
workflow_dispatch:
jobs:
package:
name: ZIP bauen und veröffentlichen
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Version und Dateinamen bestimmen
id: meta
run: |
set -eu
VERSION="$(tr -d ' \r\n' < VERSION)"
SHORT_SHA="$(git rev-parse --short HEAD)"
BUILD_DATE="$(date -u +%Y-%m-%d)"
if [ "${GITHUB_REF_TYPE:-branch}" = "tag" ]; then
TAG="${GITHUB_REF_NAME}"
NAME="unifi-voucher-tool-${TAG}"
TITLE="Version ${TAG}"
PRERELEASE="false"
else
TAG="latest-main"
NAME="unifi-voucher-tool-${VERSION}+${BUILD_DATE}.${SHORT_SHA}"
TITLE="Aktueller Stand von main ${VERSION} (${SHORT_SHA})"
PRERELEASE="true"
fi
{
echo "version=${VERSION}"
echo "short_sha=${SHORT_SHA}"
echo "build_date=${BUILD_DATE}"
echo "tag=${TAG}"
echo "name=${NAME}"
echo "title=${TITLE}"
echo "prerelease=${PRERELEASE}"
} >> "$GITHUB_OUTPUT"
- name: ZIP erzeugen
run: |
set -eu
mkdir -p dist
# git archive wertet die export-ignore-Regeln aus .gitattributes aus,
# docs/, tests/, tools/ und CI-Dateien bleiben also draußen.
git archive --format=zip -9 \
--prefix="unifi-voucher-tool/" \
-o "dist/${{ steps.meta.outputs.name }}.zip" HEAD
cd dist
sha256sum "${{ steps.meta.outputs.name }}.zip" > "${{ steps.meta.outputs.name }}.zip.sha256"
ls -lh
- name: Inhalt kurz prüfen
run: |
set -eu
# Ein paar Dateien muessen enthalten sein, sonst ist das Paket kaputt.
for required in \
unifi-voucher-tool/index.php \
unifi-voucher-tool/install.php \
unifi-voucher-tool/database.sql \
unifi-voucher-tool/assets/global.css \
unifi-voucher-tool/assets/vendor/inter/inter.css \
unifi-voucher-tool/includes/Ui.php
do
if ! unzip -l "dist/${{ steps.meta.outputs.name }}.zip" | grep -q "$required"; then
echo "Fehlt im Paket: $required" >&2
exit 1
fi
done
echo "Paket vollständig."
- name: Als Build-Artefakt sichern
uses: actions/upload-artifact@v3
continue-on-error: true # Artefakt-Speicher ist optional
with:
name: ${{ steps.meta.outputs.name }}
path: dist/*
retention-days: 30
- name: Release anlegen bzw. auffrischen
env:
# Forgejo stellt den Token automatisch bereit; FORGEJO_TOKEN
# (persönlicher Token) dient als Ausweichweg.
TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.FORGEJO_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ steps.meta.outputs.tag }}
NAME: ${{ steps.meta.outputs.name }}
TITLE: ${{ steps.meta.outputs.title }}
PRERELEASE: ${{ steps.meta.outputs.prerelease }}
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
BUILD_DATE: ${{ steps.meta.outputs.build_date }}
run: |
set -eu
if [ -z "${TOKEN:-}" ]; then
echo "Kein Token vorhanden Release wird übersprungen." >&2
exit 0
fi
AUTH="Authorization: token ${TOKEN}"
# Rollendes Vorab-Release durch ein frisches ersetzen, damit der
# Download-Link stabil bleibt und auf den aktuellen Stand zeigt.
if [ "$TAG" = "latest-main" ]; then
OLD_ID="$(curl -sf -H "$AUTH" "${API}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)"
if [ -n "${OLD_ID:-}" ]; then
curl -sf -X DELETE -H "$AUTH" "${API}/releases/${OLD_ID}" || true
curl -sf -X DELETE -H "$AUTH" "${API}/tags/${TAG}" || true
fi
fi
# Release-Text bewusst ohne Anfuehrungszeichen und Backslashes,
# damit er ohne jq direkt in den JSON-Body passt (\n bleibt literal).
BODY="Automatisch gebaut aus Commit ${SHORT_SHA}.\n\n"
BODY="${BODY}| | |\n|---|---|\n"
BODY="${BODY}| Version | ${VERSION} |\n"
BODY="${BODY}| Commit | ${SHORT_SHA} |\n"
BODY="${BODY}| Gebaut am | ${BUILD_DATE} |\n\n"
BODY="${BODY}**Neuinstallation:** ZIP entpacken, Dateien auf den Webserver legen, install.php aufrufen.\n\n"
BODY="${BODY}**Update einer bestehenden Installation:** config.php, uploads/ und updater/storage/ nicht ueberschreiben "
BODY="${BODY}- oder gleich den eingebauten Updater unter Administration, System-Update verwenden.\n\n"
BODY="${BODY}Pruefsumme: siehe beigelegte .sha256-Datei."
RELEASE_ID="$(curl -sf -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"${TITLE}\",\"body\":\"${BODY}\",\"draft\":false,\"prerelease\":${PRERELEASE}}" \
"${API}/releases" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)"
if [ -z "${RELEASE_ID:-}" ]; then
echo "Release konnte nicht angelegt werden." >&2
exit 1
fi
for file in "dist/${NAME}.zip" "dist/${NAME}.zip.sha256"; do
curl -sf -X POST -H "$AUTH" \
-F "attachment=@${file}" \
"${API}/releases/${RELEASE_ID}/assets?name=$(basename "$file")" > /dev/null
echo "Angehängt: $(basename "$file")"
done
echo "Release ${TITLE} steht bereit: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}"

View file

@ -1,26 +0,0 @@
# ---------------------------------------------------------------------------
# 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,14 +16,11 @@ 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-Verzeichnisse beschreibbar machen # Laufzeit-Verzeichnis des Updaters beschreibbar machen
RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \ RUN mkdir -p /var/www/html/updater/storage \
&& 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

344
Readme.md
View file

@ -4,15 +4,12 @@
**Webbasiertes WLAN-Voucher-Management für UniFi OS** mit Multi-Site-Support, Benutzerverwaltung, Microsoft-365-Login und integriertem Auto-Updater. **Webbasiertes WLAN-Voucher-Management für UniFi OS** mit Multi-Site-Support, Benutzerverwaltung, Microsoft-365-Login und integriertem Auto-Updater.
Entwickelt von **[Loheide.eu](https://loheide.eu)**
![PHP](https://img.shields.io/badge/PHP-7.4%2B-777BB4?logo=php&logoColor=white) ![PHP](https://img.shields.io/badge/PHP-7.4%2B-777BB4?logo=php&logoColor=white)
![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.8.0-blueviolet) ![Version](https://img.shields.io/badge/Version-2.4.0-blueviolet)
![Tests](https://img.shields.io/badge/Tests-PHPUnit%20%2B%20PHPStan-brightgreen) ![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg)
[![Repository](https://img.shields.io/badge/Code-git.loheide.cloud-4b3ec4)](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool)
</div> </div>
@ -28,8 +25,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
## ✨ Features ## ✨ Features
- 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand - 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand
- 🖥️ **Display-Seiten (Kiosk)** öffentliche Seite je Site, an der Gäste sich mit einem Klick selbst einen Zugang holen
- 🎨 **Jede Display-Seite eigenständig gestaltbar** Logo, Hintergrundbild, Akzentfarbe, helle oder dunkle Karte
- 📦 **Bulk-Erstellung** bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout - 📦 **Bulk-Erstellung** bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout
- 🧩 **Voucher-Profile/Templates** vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl - 🧩 **Voucher-Profile/Templates** vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl
- 🏢 **Multi-Site-Support** beliebig viele UniFi-Standorte zentral verwalten - 🏢 **Multi-Site-Support** beliebig viele UniFi-Standorte zentral verwalten
@ -50,13 +45,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
- 💾 **Config-Backup & -Restore** (JSON Export/Import) - 💾 **Config-Backup & -Restore** (JSON Export/Import)
- 🐳 **Docker** Dockerfile + docker-compose (MariaDB) - 🐳 **Docker** Dockerfile + docker-compose (MariaDB)
- 🌍 **Ö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)
- 🏷️ **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
@ -71,52 +59,31 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
## 📸 Screenshots ## 📸 Screenshots
> Alle Screenshots stammen aus der Oberfläche in Version 2.5.0 (neues Design-System).
### Anmeldung & Voucher-Erstellung ### Anmeldung & Voucher-Erstellung
<div align="center"> <div align="center">
<img src="docs/screenshots/login.png" alt="Anmeldung" width="48%"> <img src="docs/screenshots/login.png" alt="Login mit Microsoft 365" width="32%">
<img src="docs/screenshots/login-branding.png" alt="Anmeldung mit eigenem Branding" width="48%"> <img src="docs/screenshots/voucher-form.png" alt="Voucher erstellen" width="32%">
<img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis" width="32%">
</div> </div>
<div align="center"> ### Bulk-Erstellung & Dark Mode
<img src="docs/screenshots/voucher-form.png" alt="Voucher erstellen" width="48%">
<img src="docs/screenshots/settings-login.png" alt="Einstellungen der Login-Seite" width="48%">
</div>
### Display-Seite für Gäste
<div align="center"> <div align="center">
<img src="docs/screenshots/kiosk-display.png" alt="Display-Seite im Ruhezustand" width="48%">
<img src="docs/screenshots/kiosk-branded.png" alt="Display-Seite mit eigenem Bild und Farben" width="48%">
</div>
<div align="center">
<img src="docs/screenshots/kiosk-code.png" alt="Ausgegebener Zugangscode auf dem Display" width="48%">
<img src="docs/screenshots/kiosks-form.png" alt="Display-Seite einrichten" width="48%">
</div>
<div align="center">
<img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis mit QR-Code" width="48%">
<img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%"> <img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%">
</div>
### Administration
<div align="center">
<img src="docs/screenshots/admin-dashboard.png" alt="Dashboard" width="48%">
<img src="docs/screenshots/admin-dashboard-dark.png" alt="Dashboard im Dark Mode" width="48%"> <img src="docs/screenshots/admin-dashboard-dark.png" alt="Dashboard im Dark Mode" width="48%">
</div> </div>
### Administration & Updater
<div align="center"> <div align="center">
<img src="docs/screenshots/vouchers.png" alt="Live-Voucher-Verwaltung" width="48%"> <img src="docs/screenshots/admin-dashboard.png" alt="Dashboard" width="48%">
<img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%"> <img src="docs/screenshots/updater-available.png" alt="Auto-Updater" width="48%">
</div> </div>
<div align="center"> <div align="center">
<img src="docs/screenshots/settings-branding.png" alt="Markenfarben einstellen" width="48%"> <img src="docs/screenshots/updater.png" alt="Updater Ausgangszustand" width="48%">
<img src="docs/screenshots/mobile-vouchers.png" alt="Ansicht auf dem Smartphone" width="22%"> <img src="docs/screenshots/maintenance.png" alt="Wartungsmodus" width="48%">
</div> </div>
### REST-API, 2FA & Integrationen ### REST-API, 2FA & Integrationen
@ -130,17 +97,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
<img src="docs/screenshots/two-factor.png" alt="Zwei-Faktor-Authentifizierung" width="60%"> <img src="docs/screenshots/two-factor.png" alt="Zwei-Faktor-Authentifizierung" width="60%">
</div> </div>
### Updater & Wartungsmodus
<div align="center">
<img src="docs/screenshots/updater.png" alt="Updater Ausgangszustand" width="48%">
<img src="docs/screenshots/updater-available.png" alt="Auto-Updater mit verfügbarem Update" width="48%">
</div>
<div align="center">
<img src="docs/screenshots/maintenance.png" alt="Wartungsmodus" width="60%">
</div>
--- ---
## 📋 Anforderungen ## 📋 Anforderungen
@ -157,8 +113,8 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
## 🚀 Installation ## 🚀 Installation
```bash ```bash
git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git git clone https://github.com/friloo/unifi-voucher-tool.git
cd Unifi-Voucher-Tool cd unifi-voucher-tool
``` ```
1. Dateien auf den Webserver hochladen 1. Dateien auf den Webserver hochladen
@ -228,64 +184,6 @@ Während eines Updates wird die Anwendung kurz in den **Wartungsmodus** versetzt
--- ---
## 🖥️ Display-Seiten für Gäste
Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine **öffentliche Seite**
anlegen, die auf einem Bildschirm oder Tablet läuft. Gäste tippen auf einen
Knopf und bekommen sofort einen eigenen Zugangscode ohne Anmeldung, ohne
Personal am Tresen.
**Anlegen:** Administration → **Display-Seiten** → *Display-Seite anlegen*
<div align="center">
<img src="docs/screenshots/kiosks-admin.png" alt="Verwaltung der Display-Seiten" width="80%">
</div>
| Einstellung | Wirkung |
|---|---|
| Site | für welchen Standort die Codes erzeugt werden |
| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) |
| Überschrift / Text | was auf dem Bildschirm steht |
| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) |
| Wartezeit | Abstand zwischen zwei Codes an diesem Display |
| Anzeigedauer | danach springt der Bildschirm automatisch zurück |
Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich
kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und
jederzeit erneuern der alte Link ist dann sofort ungültig. Den Link nicht
öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen.
Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite
zeigt. Gäste können sie damit **am eigenen Handy** öffnen praktisch bei
Bildschirmen ohne Touch.
### Jede Seite eigenständig gestalten
Jede Display-Seite bringt ihr eigenes Erscheinungsbild mit das Hotel am
Empfang sieht anders aus als der Tagungsraum nebenan:
| Einstellung | Wirkung |
|---|---|
| Logo | eigenes Logo auf der Karte (leer = Logo aus den Einstellungen) |
| Hintergrundbild | formatfüllend hinter der Karte, z. B. ein Foto des Hauses |
| Abdunklung | 090 % dunkle Ebene über dem Bild, damit die Karte lesbar bleibt |
| Akzentfarbe | färbt den Knopf dieser Seite (leer = Farbe aus dem Design-Tab) |
| Karte | hell oder dunkel auf Fotos wirkt die dunkle Karte meist ruhiger |
Logo und Hintergrund lassen sich direkt hochladen (PNG, JPG, WEBP, GIF, SVG bis
3 MB) oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden
die hochgeladenen Dateien mit.
Die ausgegebenen Codes erscheinen normal in *Live Vouchers*, im *Reporting* und
im *Audit-Log* (Aktion „Voucher am Display geholt"), sodass jederzeit
nachvollziehbar bleibt, woher ein Zugang stammt.
> Display-Seiten funktionieren unabhängig vom globalen öffentlichen Modus der
> geheime Link ist der Zugang. Webhook-Benachrichtigungen werden für diese Codes
> bewusst **nicht** ausgelöst, sonst wäre der Slack-Kanal voll.
---
## ⚙️ Konfiguration ## ⚙️ Konfiguration
### `config.php` ### `config.php`
@ -322,80 +220,6 @@ Den Token finden Sie unter **Administration → Einstellungen → Cron**.
--- ---
## 🎨 Design-System
Frontend, Login-Seiten, Installer, Updater und der gesamte Admin-Bereich nutzen ein
gemeinsames Stylesheet: **`assets/global.css`**.
- **Design-Tokens** (`:root` bzw. `[data-theme="dark"]`) für Flächen, Text, Linien,
Markenfarbe, Statusfarben, Radien, Schatten und Layout-Maße
- **Komponenten** darauf aufgebaut: Buttons, Formularfelder, Cards, Tabellen, Badges,
Alerts, Tabs, Pagination, Modals, Toasts, Statistik-Kacheln, Sidebar/Topbar
- **Dark Mode** ausschließlich über Tokens keine `!important`-Overrides mehr
- **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback
### Markenfarben ohne Code
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
:root {
--accent: #0f766e; /* Primärfarbe (Buttons, aktive Navigation) */
--brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%);
--r-lg: 14px; /* Eckenradius für Cards */
}
```
### Bilder hochladen
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
Unter **Administration → Einstellungen → Login-Seite** lässt sich die Anmeldeseite
ohne Code-Änderung an das eigene Haus anpassen. Leere Felder verwenden jeweils den
Standardwert eine frische Installation sieht also unverändert aus.
| Einstellung | Wirkung |
|---|---|
| Linke Bildspalte anzeigen | Split-Screen an/aus. Aus = zentrierte Anmeldekarte |
| Firmenname | Name neben dem Logo bzw. in der Fußzeile (leer = Anwendungstitel) |
| Logo (URL) | Eigenes Logo in der Bildspalte (leer = allgemeines Logo) |
| Überschrift / Beschreibungstext | Claim in der Bildspalte |
| Stichpunkte | Liste mit Haken ein Stichpunkt pro Zeile, leer = keine Liste |
| Fußzeile | z. B. `© 2026 Muster GmbH · Datenschutz · Impressum` |
| Hintergrundbild (URL) | Formatfüllendes Bild der Bildspalte |
| Verlauf Start-/Endfarbe | Farbverlauf, wenn kein Bild gesetzt ist |
| Abdunklung (%) | Dunkle Ebene über dem Bild, damit der Text lesbar bleibt |
Über **„Vorschau öffnen"** lässt sich die Login-Seite als angemeldeter Administrator
ansehen (`login.php?preview=1`), ohne sich abzumelden.
---
## 🛡️ Sicherheit ## 🛡️ Sicherheit
Das Tool ist auf einen sicheren Standardbetrieb ausgelegt: Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
@ -411,30 +235,13 @@ 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) |
Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern Empfohlene zusätzliche Härtung am Server:
(`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:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst ```apache
> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image # .htaccess sensible Dateien sperren (wird vom Installer erzeugt)
> erledigt das bereits. <FilesMatch "^(config\.php|database\.sql|install\.php|test\.php|m365_debug\.php|.*\.md)$">
Require all denied
Für **Nginx** entspricht das: </FilesMatch>
```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
@ -557,108 +364,6 @@ 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 # 29 Tests (Crypto, TOTP, API-Keys, Upload, Ui)
vendor/bin/phpstan analyse # Level 5
```
Die Versionsnummer steht in der Datei **`VERSION`** im Projektstamm. Sie wird
im Admin-Bereich unten in der Seitenleiste angezeigt und benennt das
Release-Paket für eine neue Version also dort (und im Badge oben) anheben.
Die Pipeline (`.github/workflows/ci.yml`) führt zusätzlich einen
Syntax-Check über alle PHP-Dateien aus und prüft, ob `lang/de.php` und
`lang/en.php` dieselben Schlüssel enthalten und jeder im Code verwendete
Schlüssel existiert. Dieselben Schritte lassen sich lokal ausführen.
---
## 📦 Repository & Mitwirken
Der Quellcode liegt auf der eigenen Forgejo-Instanz **nicht** auf GitHub:
**<https://git.loheide.cloud/friloo/Unifi-Voucher-Tool>**
```bash
# HTTPS
git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git
# SSH (Port 2222)
git clone ssh://git@git.loheide.cloud:2222/friloo/Unifi-Voucher-Tool.git
```
### Fertige Pakete
Jeder Merge nach `main` erzeugt automatisch ein installierbares ZIP
(`.github/workflows/release.yml`) und hängt es an das rollende Vorab-Release
**`latest-main`**:
**<https://git.loheide.cloud/friloo/Unifi-Voucher-Tool/releases>**
Das Paket enthält nur die Laufzeit-Dateien `docs/`, `tests/`, `tools/` und die
CI-Konfiguration bleiben draußen (rund 1,4 MB). Wird ein Tag `v*` gepusht,
entsteht daraus ein reguläres Release mit derselben Mechanik.
> Beim **Update einer bestehenden Installation** `config.php`, `uploads/` und
> `updater/storage/` nicht überschreiben oder gleich den eingebauten Updater
> verwenden, der genau diese Pfade schützt.
Voraussetzung ist ein registrierter **Forgejo-Actions-Runner**; ohne Runner
bleiben die Workflows in der Warteschlange stehen. Compose-Datei und Anleitung
dafür liegen in [`tools/runner/`](tools/runner/README.md).
### Mitwirken
Fehlerberichte und Änderungsvorschläge laufen über die **Issues** und **Pull
Requests** dort. Für die Kommandozeile eignet sich [`tea`](https://gitea.com/gitea/tea),
die Gitea-/Forgejo-CLI:
```bash
tea pr create # Pull Request öffnen
tea issues ls # offene Tickets ansehen
```
> Die Workflows unter `.github/workflows/` werden von Forgejo Actions
> mitgelesen; das Badge oben ist bewusst statisch, solange kein Runner
> registriert ist. `docker-publish.yml` veröffentlicht nach `ghcr.io` und
> stammt noch aus der GitHub-Zeit für den Forgejo-Betrieb entweder auf die
> eigene Registry umstellen oder entfernen.
Der **Auto-Updater** ist davon unabhängig: er zieht seine Pakete über
`update.loheide.eu` (Channels `stable` und `development`) und nicht direkt aus
dem Git-Hoster.
---
## 🗺️ Roadmap ## 🗺️ Roadmap
- [x] Voucher-Templates (vordefinierte Laufzeiten) - [x] Voucher-Templates (vordefinierte Laufzeiten)
@ -673,18 +378,11 @@ dem Git-Hoster.
- [x] Docker-Container - [x] Docker-Container
- [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] Branding über die Oberfläche (Farben, Logo, Login-Seite)
- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
- [x] Vollständige englische Übersetzung des Admin-Bereichs
- [x] Display-Seiten: Selbstbedienung für Gäste am Bildschirm
--- ---
<div align="center"> <div align="center">
**Version 2.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** **Version 2.4.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
Entwickelt von **[Loheide.eu](https://loheide.eu)**
</div> </div>

View file

@ -1 +0,0 @@
2.8.0

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_created_once'); $success = 'API-Schlüssel erstellt. Bitte JETZT kopieren er wird nur einmal angezeigt!';
} }
} }
} }
@ -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 = __('api_status_updated'); $success = 'Status aktualisiert.';
} }
} }
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_deleted'); $success = 'API-Schlüssel gelöscht.';
} }
$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,80 +66,94 @@ $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_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title>API-Schlüssel <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?> <?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <style>
<div> .card { background: var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); }
<h1 class="page-title"><?= __('api_title') ?></h1> .card h2 { font-size:16px; margin-bottom:16px; color:var(--text-primary); }
<p class="page-subtitle"><?= __('api_subtitle') ?></p> table { width:100%; border-collapse:collapse; }
</div> th,td { text-align:left; padding:11px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
</div> th { color:var(--text-muted); font-weight:600; }
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
.btn { padding:10px 16px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
.btn-primary { background:var(--accent,#667eea); color:#fff; }
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
.keybox { background:#0f1117; color:#7CFFB2; font-family:monospace; padding:14px; border-radius:8px; word-break:break-all; font-size:15px; margin-top:10px; }
.badge { padding:3px 9px; border-radius:6px; font-size:12px; font-weight:600; }
.b-on { background:#e3f6ea; color:#2a7; } .b-off { background:#fdeaea; color:#c33; }
.a-link { color:var(--accent,#667eea); text-decoration:none; margin-right:10px; font-size:13px; }
.muted { color:var(--text-muted); font-size:13px; }
</style>
</head>
<body>
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔑 API-Schlüssel</h1>
<?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 ($newKey): ?> <?php if ($newKey): ?>
<div class="card"> <div class="card">
<h2><?= __('api_new_key') ?></h2> <h2>Neuer Schlüssel</h2>
<p class="muted"><?= __('api_new_key_hint') ?></p> <p class="muted">Kopieren Sie ihn jetzt aus Sicherheitsgründen wird er nicht erneut angezeigt.</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><?= __('api_create_title') ?></h2> <h2>Neuen API-Schlüssel erstellen</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;"><?= __('api_label_name') ?></label> <label class="muted" style="display:block;margin-bottom:6px;">Bezeichnung</label>
<input class="input" type="text" name="name" placeholder="<?= __('api_name_placeholder') ?>" required> <input class="input" type="text" name="name" placeholder="z.B. Buchungssystem, Terminal Foyer" 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;"><?= __('api_label_scope') ?></label> <label class="muted" style="display:block;margin-bottom:6px;">Berechtigung</label>
<select class="input" name="scope"> <select class="input" name="scope">
<option value="write"><?= __('api_scope_write') ?></option> <option value="write">Lesen + Erstellen</option>
<option value="read"><?= __('api_scope_read') ?></option> <option value="read">Nur Lesen</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;"><?= __('api_label_limit') ?></label> <label class="muted" style="display:block;margin-bottom:6px;">Limit (Anfr./min)</label>
<input class="input" type="number" name="rate_limit" min="0" value="0" title="<?= __('api_limit_title') ?>"> <input class="input" type="number" name="rate_limit" min="0" value="0" title="0 = unbegrenzt">
</div> </div>
<button class="btn btn-primary" type="submit" name="create_key"><?= __('btn_create') ?></button> <button class="btn btn-primary" type="submit" name="create_key">Erstellen</button>
</form> </form>
</div> </div>
<div class="card"> <div class="card">
<h2><?= __('api_existing') ?></h2> <h2>Vorhandene Schlüssel</h2>
<?php if (empty($keys)): ?> <?php if (empty($keys)): ?>
<p class="muted"><?= __('api_none') ?></p> <p class="muted">Noch keine API-Schlüssel angelegt.</p>
<?php else: ?> <?php else: ?>
<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 data-label="<?= __('label_name') ?>"><?= htmlspecialchars($k['name']) ?></td> <td><?= htmlspecialchars($k['name']) ?></td>
<td data-label="<?= __('api_col_prefix') ?>"><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td> <td><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
<td data-label="<?= __('api_col_scope') ?>"><?= ($k['scope'] ?? 'write') === 'read' ? __('api_scope_read_short') : __('api_scope_write_short') ?></td> <td><?= ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?></td>
<td data-label="<?= __('api_col_limit') ?>"><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td> <td><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></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><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? 'aktiv' : 'gesperrt' ?></span></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"><?= $k['last_used_at'] ? htmlspecialchars($k['last_used_at']) : '' ?></td>
<td class="muted" data-label="<?= __('api_col_created_by') ?>"><?= htmlspecialchars($k['creator'] ?? '') ?></td> <td class="muted"><?= 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'] ? __('api_action_block') : __('api_action_unblock') ?></a> <a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? 'Sperren' : 'Aktivieren' ?></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> <a class="a-link" style="color:#e25555;" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('Schlüssel löschen?');">Löschen</a>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</table> </table>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="card"> <div class="card">
<h2><?= __('api_usage') ?></h2> <h2>Verwendung</h2>
<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> <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>
<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,10 +162,10 @@ 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;"><?= __('api_openapi') ?> <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p> <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>
</div> </div>
</main> </div><!-- /main-content -->
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
</body> </body>
</html> </html>

View file

@ -45,15 +45,24 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY
$currentPage = 'audit_log'; $currentPage = 'audit_log';
$adminBase = ''; $adminBase = '';
// Aktionsnamen uebersetzt anzeigen; unbekannte Aktionen bleiben technisch. // WICHTIG: Die Keys muessen den tatsaechlich via writeAuditLog() geschriebenen
$actionLabels = []; // Action-Namen entsprechen (user_create, site_edit, ...), sonst erscheinen
foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created', // die Eintraege als rohe Keys.
'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted', $actionLabels = [
'settings_saved', 'password_reset', 'template_created', 'template_updated', 'voucher_create' => '🎫 Voucher erstellt',
'template_deleted', 'voucher_kiosk', 'kiosk_created', 'kiosk_updated', 'voucher_bulk' => '🎫 Bulk Voucher',
'kiosk_deleted'] as $action) { 'user_login' => '🔐 Login',
$actionLabels[$action] = __('audit_action_' . $action); 'user_create' => '👤 Benutzer erstellt',
} 'user_edit' => '👤 Benutzer geändert',
'user_delete' => '👤 Benutzer gelöscht',
'site_create' => '🌐 Site hinzugefügt',
'site_edit' => '🌐 Site geändert',
'site_delete' => '🌐 Site gelöscht',
'password_reset' => '🔑 Passwort-Reset',
'update_installed' => '🔄 Update installiert',
'update_failed' => '🔄 Update fehlgeschlagen',
'migrations_run' => '🗄️ Migrationen ausgeführt',
];
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>"> <html lang="<?= I18n::getLanguage() ?>">
@ -62,17 +71,46 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?></title> <title><?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.page-header { margin-bottom: 25px; }
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.card { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 20px; }
.card-header { padding: 18px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 11px 15px; background: var(--bg-table-head); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.table td { padding: 12px 15px; border-bottom: 1px solid var(--border-color); font-size: 13px; color: var(--text-primary); }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: var(--bg-hover); }
.filter-bar { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
.filter-bar select { padding: 9px 12px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 13px; background: var(--bg-input); color: var(--text-primary); }
.filter-bar select:focus { outline: none; border-color: var(--accent); }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-small { padding: 6px 12px; font-size: 12px; }
.ip-cell { font-family: monospace; font-size: 12px; color: var(--text-muted); }
.details-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); }
.pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 25px; border-top: 1px solid var(--border-color); flex-wrap: wrap; gap: 10px; }
.page-info { font-size: 13px; color: var(--text-muted); }
.page-btns { display: flex; gap: 5px; flex-wrap: wrap; }
.page-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-decoration: none; transition: all 0.2s; }
.page-btn:hover { border-color: var(--accent); color: var(--accent); }
.page-btn.active { background: var(--accent); color: white; border-color: var(--accent); }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
.empty-state i { font-size: 42px; margin-bottom: 15px; display: block; opacity: 0.3; }
.action-chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; background: var(--bg-hover); color: var(--text-secondary); }
</style>
</head>
<div class="page-header"> <div class="page-header">
<div>
<h1 class="page-title"><?= __('audit_title') ?></h1> <h1 class="page-title"><?= __('audit_title') ?></h1>
<p class="page-subtitle"><?= __('audit_subtitle') ?></p> <p style="color: var(--text-muted); font-size: 14px;"><?= __('audit_subtitle') ?></p>
</div>
</div> </div>
<!-- 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" aria-hidden="true"></i> <?= __('audit_filter') ?></span></div> <div class="card-header"><span class="card-title"><i class="fas fa-filter"></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>
@ -89,7 +127,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<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=""><?= __('audit_all_users') ?></option> <option value="">Alle Benutzer</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']) ?>
@ -97,8 +135,8 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search" aria-hidden="true"></i> Filtern</button> <button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search"></i> Filtern</button>
<a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times" aria-hidden="true"></i> Zurücksetzen</a> <a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times"></i> Zurücksetzen</a>
</form> </form>
</div> </div>
</div> </div>
@ -106,15 +144,14 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<!-- 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" aria-hidden="true"></i> <?= __('audit_title') ?></span> <span class="card-title"><i class="fas fa-history"></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" aria-hidden="true"></i><p><?= __('audit_none') ?></p></div> <div class="empty-state"><i class="fas fa-history"></i><p><?= __('audit_none') ?></p></div>
<?php else: ?> <?php else: ?>
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<div class="table-container"> <table class="table">
<table class="table table-stack">
<thead> <thead>
<tr> <tr>
<th><?= __('audit_time') ?></th> <th><?= __('audit_time') ?></th>
@ -128,55 +165,54 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<tbody> <tbody>
<?php foreach ($logs as $log): ?> <?php foreach ($logs as $log): ?>
<tr> <tr>
<td data-label="<?= __('audit_time') ?>" style="white-space:nowrap;color:var(--text-muted);"> <td 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 data-label="<?= __('audit_action') ?>"> <td>
<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 data-label="<?= __('audit_user') ?>"> <td>
<?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);"><?= __('audit_system_anon') ?></em> <em style="color:var(--text-muted);">System/Anonym</em>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td data-label="<?= __('audit_entity') ?>" style="color:var(--text-secondary);"> <td 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" data-label="<?= __('audit_details') ?>" title="<?= htmlspecialchars($log['details'] ?? '') ?>"> <td class="details-cell" title="<?= htmlspecialchars($log['details'] ?? '') ?>">
<?= htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, '…')) ?> <?= htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, '…')) ?>
</td> </td>
<td class="ip-cell" data-label="<?= __('audit_ip') ?>"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td> <td class="ip-cell"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<?php if ($pages > 1): ?> <?php if ($pages > 1): ?>
<div class="pagination"> <div class="pagination">
<span class="page-info"><?= str_replace(['{page}', '{pages}', '{total}'], [(string)$page, (string)$pages, number_format((int)$total, 0, ',', '.')], __('audit_page_info')) ?></span> <span class="page-info">Seite <?= $page ?> von <?= $pages ?> (<?= $total ?> Einträge)</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" aria-hidden="true"></i></a> <a href="<?= $baseUrl ?>&page=<?= $page - 1 ?>" class="page-btn"><i class="fas fa-chevron-left"></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" aria-hidden="true"></i></a> <a href="<?= $baseUrl ?>&page=<?= $page + 1 ?>" class="page-btn"><i class="fas fa-chevron-right"></i></a>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
@ -185,7 +221,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<?php endif; ?> <?php endif; ?>
</div> </div>
</main> </div><!-- main-content -->
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
</body> </body>
</html> </html>

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 = __('backup_choose_file'); $error = 'Bitte eine Backup-Datei auswählen.';
} 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 = __('backup_invalid_file'); $error = 'Ungültige oder fremde Backup-Datei.';
} else { } else {
$importSites = isset($_POST['import_sites']); $importSites = isset($_POST['import_sites']);
$importTemplates = isset($_POST['import_templates']); $importTemplates = isset($_POST['import_templates']);
@ -95,9 +95,7 @@ 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 = str_replace(['{settings}', '{sites}', '{templates}'], $success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile.";
[(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();
} }
@ -114,38 +112,48 @@ $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_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title>Backup & Restore <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?> <?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <style>
<div> .card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
<h1 class="page-title"><?= __('backup_title') ?></h1> .card h2 { font-size:16px; margin-bottom:12px; color:var(--text-primary); }
<p class="page-subtitle"><?= __('backup_subtitle') ?></p> .muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
</div> .btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
</div> .btn-primary { background:var(--accent,#667eea); color:#fff; }
.btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
label.chk { display:flex; align-items:center; gap:9px; margin:8px 0; color:var(--text-primary); font-size:14px; }
input[type=file] { margin:10px 0; color:var(--text-primary); }
</style>
</head>
<body>
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">💾 Backup &amp; Restore</h1>
<?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; ?>
<div class="card"> <div class="card">
<h2><?= __('backup_export') ?></h2> <h2>Export</h2>
<p class="muted"><?= __('backup_export_hint') ?> <code>APP_KEY</code> <?= __('backup_export_hint2') ?></p> <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>
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>"><?= __('backup_export_btn') ?></a> <a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>">Konfiguration exportieren</a>
</div> </div>
<div class="card"> <div class="card">
<h2><?= __('backup_import') ?></h2> <h2>Import / Restore</h2>
<p class="muted"><?= __('backup_import_hint') ?></p> <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>
<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> <?= __('backup_opt_settings') ?></label> <label class="chk"><input type="checkbox" name="import_settings" checked> Einstellungen</label>
<label class="chk"><input type="checkbox" name="import_sites" checked> <?= __('backup_opt_sites') ?></label> <label class="chk"><input type="checkbox" name="import_sites" checked> Sites</label>
<label class="chk"><input type="checkbox" name="import_templates" checked> <?= __('backup_opt_templates') ?></label> <label class="chk"><input type="checkbox" name="import_templates" checked> Voucher-Profile</label>
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('<?= __('backup_import_confirm') ?>');"><?= __('import_submit') ?></button> <button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('Import jetzt durchführen?');">Importieren</button>
</form> </form>
</div> </div>
</main> </div><!-- /main-content -->
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
</body> </body>
</html> </html>

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 = str_replace('{count}', (string)$created, __('import_created')); $success = "$created Voucher erstellt.";
} catch (Exception $e) { } catch (Exception $e) {
$error = $e->getMessage(); $error = $e->getMessage();
} }
@ -93,38 +93,48 @@ $adminBase = '';
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSV-Import <?= htmlspecialchars($appTitle) ?></title> <title>CSV-Import <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?> <?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <style>
<div> .card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:20px; box-shadow:0 4px 14px var(--shadow); max-width:760px; }
<h1 class="page-title"><?= __('import_title') ?></h1> .card h2 { font-size:15px; margin-bottom:12px; color:var(--text-primary); }
<p class="page-subtitle"><?= __('import_subtitle') ?></p> .muted { color:var(--text-muted); font-size:13px; margin-bottom:12px; }
</div> label { display:block; font-size:13px; color:var(--text-secondary); margin:12px 0 6px; }
</div> .input,textarea,select { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; font-family:inherit; }
textarea { min-height:140px; font-family:monospace; }
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; font-size:14px; cursor:pointer; background:var(--accent,#667eea); color:#fff; }
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:760px; }
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:8px; font-size:13px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
</style>
</head>
<body>
<h1 style="font-size:24px;margin-bottom:18px;color:var(--text-primary);">📥 Voucher-Import (CSV)</h1>
<?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; ?>
<div class="card"> <div class="card">
<h2><?= __('import_card_title') ?></h2> <h2>Mehrere Voucher erstellen</h2>
<p class="muted"><?= __('import_format_hint') ?> <code>Name,MaxGeräte,Minuten</code> <?= __('import_format_hint2') ?><br> <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>
<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><?= __('import_site') ?></label> <label>Standort</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><?= __('import_file') ?></label> <label>CSV-Datei (optional)</label>
<input class="input" type="file" name="csv" accept=".csv,text/csv"> <input class="input" type="file" name="csv" accept=".csv,text/csv">
<label><?= __('import_paste') ?></label> <label> oder direkt einfügen</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_confirm') ?>');"><?= __('import_submit') ?></button> <button class="btn" type="submit" name="do_import" style="margin-top:14px;" onclick="return confirm('Import jetzt starten?');">Importieren</button>
</form> </form>
</div> </div>
<?php if (!empty($results)): ?> <?php if (!empty($results)): ?>
<div class="card"> <div class="card">
<h2><?= __('import_result') ?></h2> <h2>Ergebnis</h2>
<table><tr><th><?= __('label_name') ?></th><th><?= __('import_col_code') ?></th><th><?= __('label_status') ?></th></tr> <table><tr><th>Name</th><th>Code / Fehler</th><th>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; ?>
@ -132,7 +142,7 @@ $adminBase = '';
</div> </div>
<?php endif; ?> <?php endif; ?>
</main> </div><!-- /main-content -->
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
</body> </body>
</html> </html>

View file

@ -6,7 +6,6 @@ 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';
@ -27,9 +26,12 @@ if (isset($_GET['ajax_stats'])) {
$syncErrors = []; $syncErrors = [];
if ($syncFirst) { if ($syncFirst) {
// Mehrere Sites werden sequentiell synchronisiert (je bis zu ~15s
// bei Timeout) PHP-Default von 30s reicht dann nicht.
@set_time_limit(30 + count($sites) * 20);
foreach ($sites as $site) { foreach ($sites as $site) {
try { try {
$ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id']); $ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id'], $site['ssl_verify'] ?? 0);
$ctrl->syncVouchersToDatabase($db, $site['id']); $ctrl->syncVouchersToDatabase($db, $site['id']);
} catch (Exception $e) { } catch (Exception $e) {
$syncErrors[$site['id']] = $e->getMessage(); $syncErrors[$site['id']] = $e->getMessage();
@ -91,8 +93,78 @@ $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>
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.page-header { margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
.live-badge { display: inline-flex; align-items: center; gap: 8px; background: #d4edda; color: #155724; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 500; }
.live-badge .dot { width: 8px; height: 8px; background: #28a745; border-radius: 50%; animation: pulse 2s infinite; }
.live-badge.loading { background: #fff3cd; color: #856404; }
.live-badge.loading .dot { background: #ffc107; }
.live-badge.error { background: #f8d7da; color: #721c24; }
.live-badge.error .dot { background: var(--danger); animation: none; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.5} }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; margin-bottom: 30px; }
.stat-card { background: var(--bg-card); padding: 22px; border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); }
.stat-card.live { border-color: var(--success); border-width: 2px; }
.stat-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.stat-card-title { color: var(--text-muted); font-size: 13px; font-weight: 500; }
.stat-card-icon { width: 38px; height: 38px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 17px; }
.stat-card-value { font-size: 30px; font-weight: 700; color: var(--text-primary); }
.stat-card-value.valid { color: var(--success); }
.stat-card-value.used { color: var(--warning); }
.stat-card-value.expired { color: var(--danger); }
.stat-card-sub { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); margin-bottom: 28px; overflow: hidden; }
.card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.card-title { font-size: 17px; font-weight: 600; color: var(--text-primary); }
.card-body { padding: 22px; }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 11px 14px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
.table td { padding: 13px 14px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
.table tr:last-child td { border-bottom: none; }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-success { background: var(--success); color: white; }
.btn-success:hover { opacity: .9; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.chart-container { position: relative; height: 280px; }
.site-status { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 14px; }
.site-status-card { background: var(--bg-card); border: 2px solid var(--border-color); border-radius: 12px; padding: 18px; transition: border-color .2s; }
.site-status-card:hover { border-color: var(--accent); }
.site-status-card.error { border-color: var(--danger); }
.site-status-name { font-weight: 600; color: var(--text-primary); font-size: 15px; }
.site-voucher-count { background: var(--accent); color: white; padding: 3px 11px; border-radius: 20px; font-size: 13px; font-weight: 600; }
.site-voucher-count.zero { background: var(--text-muted); }
.site-status-stats { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 12px; }
.site-stat { text-align: center; padding: 9px; background: var(--bg-hover); border-radius: 8px; }
.site-stat-value { font-size: 18px; font-weight: 700; }
.site-stat-value.valid { color: var(--success); }
.site-stat-value.used { color: var(--warning); }
.site-stat-value.expired { color: var(--danger); }
.site-stat-label { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.site-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.top-users-list { list-style: none; }
.top-users-list li { display: flex; justify-content: space-between; align-items: center; padding: 11px 0; border-bottom: 1px solid var(--border-color); }
.top-users-list li:last-child { border-bottom: none; }
.user-info-row { display: flex; align-items: center; gap: 10px; }
.user-avatar-sm { width: 30px; height: 30px; border-radius: 50%; background: linear-gradient(135deg,#667eea,#764ba2); display: flex; align-items: center; justify-content: center; color: white; font-weight: 700; font-size: 13px; }
.user-count { background: var(--bg-hover); padding: 3px 10px; border-radius: 12px; font-weight: 600; color: var(--text-primary); font-size: 13px; }
.refresh-indicator { display: flex; align-items: center; gap: 10px; }
.last-update { color: var(--text-muted); font-size: 12px; }
.empty-state { text-align: center; padding: 50px 20px; color: var(--text-muted); }
.empty-state i { font-size: 40px; margin-bottom: 15px; opacity: .3; display: block; }
@media(max-width:768px){ .main-content{ margin-left:0!important; } .stats-grid{ grid-template-columns:1fr 1fr; } }
</style>
<?php if (!Crypto::hasKey()): ?>
<div class="alert alert-error">
<i class="fas fa-exclamation-triangle"></i>
<span><?= __('crypto_warning') ?></span>
</div>
<?php endif; ?>
<div class="page-header"> <div class="page-header">
<div> <div>
@ -104,8 +176,8 @@ $currentPage = 'dashboard';
<span class="dot"></span> <span class="dot"></span>
<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-success btn-sm" id="refreshBtn">
<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?> <i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>
</button> </button>
</div> </div>
</div> </div>
@ -114,43 +186,43 @@ $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" aria-hidden="true"></i></div> <div class="stat-card-icon" style="background:#e3f2fd;color:#1976d2;"><i class="fas fa-map-marker-alt"></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" aria-hidden="true"></i></div> <div class="stat-card-icon" style="background:#f3e5f5;color:#7b1fa2;"><i class="fas fa-users"></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" aria-hidden="true"></i></div> <div class="stat-card-icon" style="background:#e8f5e9;color:#388e3c;"><i class="fas fa-check-circle"></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>
</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_used') ?></div> <div class="stat-card-title">🟡 <?= __('dashboard_used') ?></div>
<div class="stat-card-icon warning"><i class="fas fa-user-check" aria-hidden="true"></i></div> <div class="stat-card-icon" style="background:#fff3e0;color:#f57c00;"><i class="fas fa-user-check"></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" aria-hidden="true"></i></div> <div class="stat-card-icon" style="background:#ffebee;color:#d32f2f;"><i class="fas fa-times-circle"></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" aria-hidden="true"></i></div> <div class="stat-card-icon" style="background:var(--bg-hover);color:var(--text-muted);"><i class="fas fa-ticket-alt"></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>
@ -158,16 +230,16 @@ $currentPage = 'dashboard';
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2 class="card-title"><?= __('dashboard_vouchers_per_site') ?></h2> <h2 class="card-title">🔴 <?= __('dashboard_vouchers_per_site') ?></h2>
<span class="last-update" id="lastUpdate"><?= $lastCronSync ? date('d.m.Y H:i', strtotime($lastCronSync)) : __('never') ?></span> <span class="last-update" id="lastUpdate"><?= $lastCronSync ? date('d.m.Y H:i', strtotime($lastCronSync)) : __('never') ?></span>
</div> </div>
<div class="card-body"> <div class="card-body" style="padding:18px;">
<div class="site-status" id="siteStatusContainer"> <div class="site-status" id="siteStatusContainer">
<?php foreach ($sites as $site): <?php foreach ($sites as $site):
$ss = $siteStats[$site['id']] ?? ['total'=>0,'valid'=>0,'used'=>0,'expired'=>0]; $ss = $siteStats[$site['id']] ?? ['total'=>0,'valid'=>0,'used'=>0,'expired'=>0];
?> ?>
<div class="site-status-card" id="site-<?= $site['id'] ?>"> <div class="site-status-card" id="site-<?= $site['id'] ?>">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div class="site-status-name"><?= htmlspecialchars($site['name']) ?></div> <div class="site-status-name"><?= htmlspecialchars($site['name']) ?></div>
<span class="site-voucher-count <?= (int)$ss['total']===0 ? 'zero' : '' ?>" id="site-total-<?= $site['id'] ?>"> <span class="site-voucher-count <?= (int)$ss['total']===0 ? 'zero' : '' ?>" id="site-total-<?= $site['id'] ?>">
<?= (int)$ss['total'] ?> <?= (int)$ss['total'] ?>
@ -182,10 +254,10 @@ $currentPage = 'dashboard';
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($sites)): ?> <?php if (empty($sites)): ?>
<div class="empty-state" style="grid-column:1/-1;"> <div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted);">
<div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div> <i class="fas fa-map-marker-alt" style="font-size:32px;margin-bottom:15px;opacity:.3;display:block;"></i>
<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" aria-hidden="true"></i> <?= __('sites_add') ?></a> <a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus"></i> <?= __('sites_add') ?></a>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
@ -193,18 +265,18 @@ $currentPage = 'dashboard';
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"><h2 class="card-title"><?= __('dashboard_trend') ?></h2></div> <div class="card-header"><h2 class="card-title">📊 <?= __('dashboard_trend') ?></h2></div>
<div class="card-body"> <div class="card-body">
<div class="chart-container"><canvas id="voucherChart"></canvas></div> <div class="chart-container"><canvas id="voucherChart"></canvas></div>
</div> </div>
</div> </div>
<div class="two-col-grid"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;" class="two-col-grid">
<div class="card"> <div class="card" style="margin-bottom:0;">
<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" style="padding:18px 22px;">
<?php if (empty($topUsers)): ?> <?php if (empty($topUsers)): ?>
<div class="empty-state"><i class="fas fa-users" aria-hidden="true"></i><p><?= __('dashboard_no_data') ?></p></div> <div class="empty-state"><i class="fas fa-users"></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,22 +296,21 @@ $currentPage = 'dashboard';
</div> </div>
</div> </div>
<div class="card"> <div class="card" style="margin-bottom:0;">
<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-header"><h2 class="card-title">📋 <?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-external-link-alt"></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" aria-hidden="true"></i><p><?= __('dashboard_no_vouchers') ?></p></div> <div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('dashboard_no_vouchers') ?></p></div>
<?php else: ?> <?php else: ?>
<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 data-label="<?= __('label_created') ?>" style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td> <td style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
<td data-label="<?= __('label_code') ?>"><code><?= htmlspecialchars($v['voucher_code']) ?></code></td> <td><code style="font-size:12px;background:var(--bg-hover);padding:3px 6px;border-radius:4px;"><?= htmlspecialchars($v['voucher_code']) ?></code></td>
<td data-label="<?= __('label_site') ?>"><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td> <td><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
<td data-label="<?= __('label_status') ?>"> <td>
<?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>
@ -247,26 +318,17 @@ $currentPage = 'dashboard';
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
</div> </div>
</main> </div><!-- /main-content -->
<div id="toast-container" role="status" aria-live="polite"></div> <div id="toast-container"></div>
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
<script> <script>
const rootStyles = getComputedStyle(document.documentElement);
const accentColor = rootStyles.getPropertyValue('--accent').trim();
const surfaceColor = rootStyles.getPropertyValue('--bg-card').trim();
const gridColor = rootStyles.getPropertyValue('--border-color').trim();
const mutedColor = rootStyles.getPropertyValue('--text-muted').trim();
const ctx = document.getElementById('voucherChart').getContext('2d'); const ctx = document.getElementById('voucherChart').getContext('2d');
const accentFill = ctx.createLinearGradient(0, 0, 0, 280);
accentFill.addColorStop(0, accentColor + '33');
accentFill.addColorStop(1, accentColor + '00');
const chartData = <?= json_encode($chartData) ?>; const chartData = <?= json_encode($chartData) ?>;
new Chart(ctx, { new Chart(ctx, {
type: 'line', type: 'line',
@ -275,20 +337,17 @@ new Chart(ctx, {
datasets: [{ datasets: [{
label: 'Vouchers', label: 'Vouchers',
data: chartData.map(d => d.count), data: chartData.map(d => d.count),
borderColor: accentColor, borderColor: '#667eea',
backgroundColor: accentFill, backgroundColor: 'rgba(102,126,234,0.1)',
tension: 0.4, fill: true, tension: 0.4, fill: true,
pointBackgroundColor: accentColor, pointBorderColor: surfaceColor, pointBackgroundColor: '#667eea', pointBorderColor: '#fff',
pointBorderWidth: 2, pointRadius: 4, pointHoverRadius: 6 pointBorderWidth: 2, pointRadius: 5, pointHoverRadius: 7
}] }]
}, },
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } }, plugins: { legend: { display: false } },
scales: { scales: { y: { beginAtZero: true, ticks: { stepSize: 1, color: getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim() } }, x: { ticks: { color: getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim() } } }
y: { beginAtZero: true, border: { display: false }, grid: { color: gridColor, drawTicks: false }, ticks: { stepSize: 1, color: mutedColor, padding: 10, font: { size: 11 } } },
x: { border: { display: false }, grid: { display: false }, ticks: { color: mutedColor, padding: 8, font: { size: 11 } } }
}
} }
}); });
@ -302,7 +361,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" aria-hidden="true"></i>'; refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></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());
@ -340,8 +399,13 @@ async function refreshData(mode='db') {
} }
isLoading = false; isLoading = false;
refreshBtn.disabled = false; refreshBtn.disabled = false;
refreshBtn.innerHTML = '<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>'; refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>';
} }
// Responsive two-column grid
const twoCol = document.querySelector('.two-col-grid');
function checkGrid() { if (twoCol) twoCol.style.gridTemplateColumns = window.innerWidth < 900 ? '1fr' : '1fr 1fr'; }
checkGrid(); window.addEventListener('resize', checkGrid);
</script> </script>
</body> </body>
</html> </html>

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 = __('settings_saved'); $success = 'Einstellungen gespeichert.';
} }
} }
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 = __('int_webhook_test_sent'); $success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).';
} }
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1'; $enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
@ -92,14 +92,24 @@ $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><?= __('int_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title>Integration & Wartung <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?> <?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <style>
<div> .card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
<h1 class="page-title"><?= __('int_title') ?></h1> .card h2 { font-size:16px; margin-bottom:6px; color:var(--text-primary); }
<p class="page-subtitle"><?= __('int_subtitle') ?></p> .muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
</div> label { display:block; font-size:14px; color:var(--text-secondary); margin:14px 0 6px; }
</div> .input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
.row { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
.chk { display:flex; align-items:center; gap:9px; margin-top:14px; color:var(--text-primary); font-size:14px; }
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
.btn-primary { background:var(--accent,#667eea); color:#fff; } .btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
</style>
</head>
<body>
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔧 Integration &amp; Wartung</h1>
<?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; ?>
@ -108,64 +118,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><?= __('int_security') ?></h2> <h2>Sicherheitsrichtlinie</h2>
<p class="muted"><?= __('int_security_hint') ?></p> <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>
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> <?= __('int_enforce_2fa') ?></label> <label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label>
<label><?= __('int_daily_limit') ?></label> <label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</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><?= __('int_session_driver') ?></label> <label>Session-Speicher</label>
<select class="input" name="session_driver" style="max-width:340px;"> <select class="input" name="session_driver" style="max-width:240px;">
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>><?= __('int_session_php') ?></option> <option value="php" <?= $sessionDriver==='php'?'selected':'' ?>>PHP-Standard (Dateien)</option>
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>><?= __('int_session_db') ?></option> <option value="db" <?= $sessionDriver==='db'?'selected':'' ?>>Datenbank (ermöglicht „überall abmelden")</option>
</select> </select>
<label><?= __('int_captcha') ?></label> <label>Captcha im öffentlichen Modus</label>
<select class="input" name="captcha_mode" style="max-width:340px;"> <select class="input" name="captcha_mode" style="max-width:240px;">
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>><?= __('int_captcha_off') ?></option> <option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option>
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>><?= __('int_captcha_math') ?></option> <option value="math" <?= $captchaMode==='math'?'selected':'' ?>>Rechenaufgabe (ohne externen Dienst)</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 ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? __('int_secret_placeholder') : '' ?>"></div> <div><label>hCaptcha Secret<?= $captchaSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2><?= __('int_proxy') ?></h2> <h2>Reverse-Proxy</h2>
<p class="muted"><?= __('int_proxy_hint') ?> <code>X-Forwarded-For</code> <?= __('int_proxy_hint2') ?></p> <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>
<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><?= __('int_webhook') ?></h2> <h2>Webhook-Benachrichtigungen</h2>
<p class="muted"><?= __('int_webhook_hint') ?></p> <p class="muted">Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.</p>
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> <?= __('int_webhook_active') ?></label> <label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> Webhook aktiv</label>
<label><?= __('int_webhook_url') ?></label> <label>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) ?>"><?= __('int_webhook_test') ?></a> <a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>">Test senden</a>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2><?= __('int_sms') ?></h2> <h2>SMS-Versand (Twilio)</h2>
<p class="muted"><?= __('int_sms_hint') ?></p> <p class="muted">Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.</p>
<label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> <?= __('int_sms_active') ?></label> <label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> SMS-Versand aktiv</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 ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? __('int_secret_placeholder') : '' ?>"></div> <div><label>Auth Token<?= $twilioTokenSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
<div><label><?= __('int_sms_from') ?></label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div> <div><label>Absender (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><?= __('int_sso') ?></h2> <h2>Single Sign-On (OpenID Connect)</h2>
<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> <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>
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> <?= __('int_sso_active') ?></label> <label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> OIDC-Login aktiv</label>
<div class="row3" style="margin-top:10px;"> <div class="row3" style="margin-top:10px;">
<div><label><?= __('int_sso_button') ?></label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div> <div><label>Button-Text</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 ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? __('int_secret_placeholder') : '' ?>"></div> <div><label>Client Secret<?= $oidcSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></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,21 +184,21 @@ $adminBase = '';
</div> </div>
<div class="card"> <div class="card">
<h2><?= __('int_cleanup') ?></h2> <h2>Datenhaltung & Cleanup (DSGVO)</h2>
<p class="muted"><?= __('int_cleanup_hint') ?> <code>cron_cleanup.php</code> <?= __('int_cleanup_hint2') ?> <p class="muted">Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per <code>cron_cleanup.php</code> (täglich empfohlen).
<?php if ($lastCleanup): ?><br><?= __('int_cleanup_last') ?> <?= htmlspecialchars($lastCleanup) ?><?php endif; ?> <?php if ($lastCleanup): ?><br>Letzter Lauf: <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
</p> </p>
<div class="row"> <div class="row">
<div><label><?= __('int_cleanup_expired') ?></label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div> <div><label>Abgelaufene Voucher</label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
<div><label><?= __('int_cleanup_audit') ?></label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div> <div><label>Audit-Log</label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
<div><label><?= __('int_cleanup_logins') ?></label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div> <div><label>Login-Versuche</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"><?= __('btn_save') ?></button> <button class="btn btn-primary" type="submit" name="save">Speichern</button>
</form> </form>
</main> </div><!-- /main-content -->
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
</body> </body>
</html> </html>

View file

@ -1,500 +0,0 @@
<?php
/**
* Verwaltung der öffentlichen Display-Seiten ("Kiosk").
*
* Jeder Kiosk gehört zu einer Site, hat einen geheimen Link und gibt über
* kiosk.php Zugangscodes aus ohne Anmeldung, aber mit Tageslimit und
* Wartezeit zwischen zwei Codes.
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Ui.php';
require_once __DIR__ . '/../includes/Kiosk.php';
require_once __DIR__ . '/../includes/Upload.php';
$auth = new Auth();
$auth->requireAdmin();
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$error = '';
$success = '';
/** Formularwerte einsammeln für Anlegen und Bearbeiten identisch. */
function kioskInput(): array
{
return [
'site_id' => (int)($_POST['site_id'] ?? 0),
'template_id' => (int)($_POST['template_id'] ?? 0) ?: null,
'name' => trim((string)($_POST['name'] ?? '')),
'headline' => trim((string)($_POST['headline'] ?? '')),
'subline' => trim((string)($_POST['subline'] ?? '')),
'daily_limit' => max(0, (int)($_POST['daily_limit'] ?? Kiosk::DEFAULT_DAILY_LIMIT)),
'cooldown_seconds' => max(0, min(3600, (int)($_POST['cooldown_seconds'] ?? Kiosk::DEFAULT_COOLDOWN))),
'display_seconds' => max(10, min(600, (int)($_POST['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS))),
'is_active' => isset($_POST['is_active']) ? 1 : 0,
'bg_overlay' => max(0, min(90, (int)($_POST['bg_overlay'] ?? 45))),
'accent_color' => self_accent($_POST['accent_color'] ?? ''),
'card_style' => ($_POST['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light',
];
}
/** Nur echte Hex-Farben durchlassen der Wert landet in einem style-Attribut. */
function self_accent($value): ?string
{
$value = strtolower(trim((string)$value));
return preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_kiosk'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
try {
$in = kioskInput();
if ($in['name'] === '') throw new Exception(__('error_name_req'));
if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
$logo = Upload::resolveField('logo_url', '', 'image');
$bg = Upload::resolveField('background_url', '', 'image');
$db->execute(
"INSERT INTO kiosks (site_id, template_id, name, token, headline, subline,
logo_url, background_url, bg_overlay, accent_color, card_style,
daily_limit, cooldown_seconds, display_seconds, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
[$in['site_id'], $in['template_id'], $in['name'], Kiosk::newToken(),
$in['headline'], $in['subline'], $logo, $bg, $in['bg_overlay'],
$in['accent_color'], $in['card_style'], $in['daily_limit'],
$in['cooldown_seconds'], $in['display_seconds'], $_SESSION['user_id']]
);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_created', 'kiosk', null, $in['name']);
$success = __('kiosks_added');
} catch (Exception $e) {
$error = $e->getMessage();
}
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_kiosk'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
try {
$id = (int)($_POST['kiosk_id'] ?? 0);
$in = kioskInput();
if ($in['name'] === '') throw new Exception(__('error_name_req'));
if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
$current = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [$id]) ?: [];
$logo = Upload::resolveField('logo_url', (string)($current['logo_url'] ?? ''), 'image');
$bg = Upload::resolveField('background_url', (string)($current['background_url'] ?? ''), 'image');
$db->execute(
"UPDATE kiosks SET site_id=?, template_id=?, name=?, headline=?, subline=?,
logo_url=?, background_url=?, bg_overlay=?, accent_color=?, card_style=?,
daily_limit=?, cooldown_seconds=?, display_seconds=?, is_active=?
WHERE id=?",
[$in['site_id'], $in['template_id'], $in['name'], $in['headline'], $in['subline'],
$logo, $bg, $in['bg_overlay'], $in['accent_color'], $in['card_style'],
$in['daily_limit'], $in['cooldown_seconds'], $in['display_seconds'], $in['is_active'], $id]
);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, $in['name']);
$success = __('kiosks_updated');
} catch (Exception $e) {
$error = $e->getMessage();
}
}
}
// Neuen Link erzeugen der alte gilt damit sofort nicht mehr.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['renew_token'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
$id = (int)($_POST['kiosk_id'] ?? 0);
$db->execute("UPDATE kiosks SET token = ? WHERE id = ?", [Kiosk::newToken(), $id]);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, 'Link erneuert');
$success = __('kiosks_token_renewed');
}
}
if (isset($_GET['delete'], $_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$old = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
if ($old) {
Upload::delete((string)($old['logo_url'] ?? ''));
Upload::delete((string)($old['background_url'] ?? ''));
}
$db->execute("DELETE FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_deleted', 'kiosk', (int)$_GET['delete'], '');
$success = __('kiosks_deleted');
} else {
$error = __('error_csrf');
}
}
$sites = $db->fetchAll("SELECT id, name FROM sites WHERE is_active = 1 ORDER BY name");
$templates = $db->fetchAll("SELECT id, name, max_uses, expire_minutes FROM voucher_templates WHERE is_active = 1 ORDER BY name");
$kiosks = $db->fetchAll(
"SELECT k.*, s.name AS site_name, t.name AS template_name,
(SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id) AS total_vouchers,
(SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id AND DATE(v.created_at) = CURDATE()) AS today_vouchers
FROM kiosks k
INNER JOIN sites s ON s.id = k.site_id
LEFT JOIN voucher_templates t ON t.id = k.template_id
ORDER BY k.is_active DESC, k.name"
);
$csrf = $auth->getCsrfToken();
$currentPage = 'kiosks';
$adminBase = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('kiosks_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('kiosks_title') ?></h1>
<p class="page-subtitle"><?= __('kiosks_subtitle') ?></p>
</div>
<button onclick="openAddModal()" class="btn btn-primary">
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('kiosks_add') ?>
</button>
</div>
<?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 (empty($sites)): ?>
<div class="empty-card">
<div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
<p><?= __('kiosks_no_sites') ?></p>
<a href="sites.php" class="btn btn-primary" style="margin-top:16px;">
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?>
</a>
</div>
<?php elseif (empty($kiosks)): ?>
<div class="empty-card">
<div class="empty-icon"><i class="fas fa-display" aria-hidden="true"></i></div>
<p><?= __('kiosks_empty') ?></p>
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top:16px;">
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('kiosks_add') ?>
</button>
</div>
<?php else: ?>
<div class="sites-grid">
<?php foreach ($kiosks as $k): ?>
<?php $url = Kiosk::publicUrl($k['token']); ?>
<div class="site-card">
<div class="site-card-header">
<div>
<div class="site-name"><?= htmlspecialchars($k['name']) ?></div>
<div class="site-id-label"><?= htmlspecialchars($k['site_name']) ?></div>
</div>
<span class="badge <?= $k['is_active'] ? 'badge-success' : 'badge-neutral' ?>">
<?= $k['is_active'] ? __('status_active') : __('status_inactive') ?>
</span>
</div>
<div class="site-info">
<div class="site-info-item">
<i class="fas fa-layer-group" aria-hidden="true"></i>
<?= $k['template_name'] ? htmlspecialchars($k['template_name']) : __('kiosks_no_template') ?>
</div>
<div class="site-info-item">
<i class="fas fa-gauge-high" aria-hidden="true"></i>
<?= (int)$k['today_vouchers'] ?><?= (int)$k['daily_limit'] > 0 ? ' / ' . (int)$k['daily_limit'] : '' ?>
<?= __('kiosks_today') ?>
</div>
<div class="site-info-item">
<i class="fas fa-ticket" aria-hidden="true"></i>
<?= (int)$k['total_vouchers'] ?> <?= __('kiosks_total') ?>
</div>
</div>
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('kiosks_link') ?></label>
<div class="kiosk-link-row">
<input type="text" class="input" readonly value="<?= htmlspecialchars($url) ?>"
id="link-<?= (int)$k['id'] ?>" onclick="this.select()">
<button class="btn btn-secondary" type="button"
onclick="copyToClipboard('<?= htmlspecialchars($url, ENT_QUOTES) ?>', '<?= __('js_copied') ?>')"
title="<?= __('js_copy') ?>" aria-label="<?= __('js_copy') ?>">
<i class="fas fa-copy" aria-hidden="true"></i>
</button>
</div>
<div class="site-actions">
<a class="btn btn-secondary btn-sm" href="<?= htmlspecialchars($url) ?>" target="_blank" rel="noopener">
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> <?= __('kiosks_open') ?>
</a>
<button class="btn btn-secondary btn-sm" type="button"
onclick="showQr('<?= htmlspecialchars($url, ENT_QUOTES) ?>', '<?= htmlspecialchars($k['name'], ENT_QUOTES) ?>')">
<i class="fas fa-qrcode" aria-hidden="true"></i> <?= __('kiosks_qr') ?>
</button>
<button class="btn btn-secondary btn-sm" type="button"
onclick='openEditModal(<?= json_encode([
"id" => (int)$k["id"], "site_id" => (int)$k["site_id"],
"template_id" => (int)$k["template_id"], "name" => $k["name"],
"headline" => $k["headline"], "subline" => $k["subline"],
"logo_url" => $k["logo_url"], "background_url" => $k["background_url"],
"bg_overlay" => (int)$k["bg_overlay"], "accent_color" => $k["accent_color"],
"card_style" => $k["card_style"],
"daily_limit" => (int)$k["daily_limit"],
"cooldown_seconds" => (int)$k["cooldown_seconds"],
"display_seconds" => (int)$k["display_seconds"],
"is_active" => (int)$k["is_active"],
], JSON_HEX_APOS | JSON_HEX_QUOT) ?>)'>
<i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?>
</button>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= __('kiosks_renew_confirm') ?>');">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="kiosk_id" value="<?= (int)$k['id'] ?>">
<button class="btn btn-secondary btn-sm" type="submit" name="renew_token">
<i class="fas fa-rotate" aria-hidden="true"></i> <?= __('kiosks_renew') ?>
</button>
</form>
<a class="btn btn-danger-soft btn-sm"
href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"
onclick="return confirm('<?= __('kiosks_delete_confirm') ?>');"
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
<i class="fas fa-trash" aria-hidden="true"></i>
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Anlegen / Bearbeiten -->
<div class="modal" id="kioskModal">
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="kioskModalTitle">
<div class="modal-header">
<h2 class="modal-title" id="kioskModalTitle"><?= __('kiosks_add') ?></h2>
<button class="modal-close" type="button" onclick="closeModal()" aria-label="<?= __('btn_cancel') ?>">&times;</button>
</div>
<form method="post" enctype="multipart/form-data">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="kiosk_id" id="kiosk_id" value="">
<div class="form-group">
<label for="name"><?= __('kiosks_name') ?></label>
<input type="text" id="name" name="name" required placeholder="<?= __('kiosks_name_placeholder') ?>">
<div class="help-text"><?= __('kiosks_name_hint') ?></div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="site_id"><?= __('label_site') ?></label>
<select id="site_id" name="site_id" required>
<?php foreach ($sites as $s): ?>
<option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="template_id"><?= __('kiosks_template') ?></label>
<select id="template_id" name="template_id">
<option value="0"><?= __('kiosks_no_template') ?></option>
<?php foreach ($templates as $t): ?>
<option value="<?= (int)$t['id'] ?>">
<?= htmlspecialchars($t['name']) ?> <?= (int)$t['max_uses'] ?> <?= __('label_devices') ?>,
<?= (int)$t['expire_minutes'] ?> <?= __('minutes_short') ?>
</option>
<?php endforeach; ?>
</select>
<div class="help-text"><?= __('kiosks_template_hint') ?></div>
</div>
</div>
<hr class="section-divider">
<div class="form-group">
<label for="headline"><?= __('kiosks_headline') ?></label>
<input type="text" id="headline" name="headline" placeholder="<?= htmlspecialchars(__('kiosk_default_headline')) ?>">
</div>
<div class="form-group">
<label for="subline"><?= __('kiosks_subline') ?></label>
<textarea id="subline" name="subline" rows="2" placeholder="<?= htmlspecialchars(__('kiosk_default_subline')) ?>"></textarea>
</div>
<hr class="section-divider">
<h3 style="font-size:14px;margin-bottom:14px;"><?= __('kiosks_appearance') ?></h3>
<?= Ui::imageField('logo_url', __('kiosks_logo'), '', __('kiosks_logo_hint')) ?>
<?= Ui::imageField('background_url', __('kiosks_background'), '', __('kiosks_background_hint')) ?>
<div class="form-grid">
<div class="form-group">
<label for="bg_overlay"><?= __('kiosks_overlay') ?></label>
<input type="number" id="bg_overlay" name="bg_overlay" min="0" max="90" value="45">
<div class="help-text"><?= __('kiosks_overlay_hint') ?></div>
</div>
<div class="form-group">
<label for="accent_color"><?= __('kiosks_accent') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="accent_color" value="<?= Ui::DEFAULT_ACCENT ?>">
<input type="text" id="accent_color" name="accent_color" placeholder="<?= __('kiosks_accent_default') ?>">
</div>
<div class="help-text"><?= __('kiosks_accent_hint') ?></div>
</div>
<div class="form-group">
<label for="card_style"><?= __('kiosks_card_style') ?></label>
<select id="card_style" name="card_style">
<option value="light"><?= __('kiosks_card_light') ?></option>
<option value="dark"><?= __('kiosks_card_dark') ?></option>
</select>
<div class="help-text"><?= __('kiosks_card_hint') ?></div>
</div>
</div>
<hr class="section-divider">
<div class="form-grid">
<div class="form-group">
<label for="daily_limit"><?= __('kiosks_daily_limit') ?></label>
<input type="number" id="daily_limit" name="daily_limit" min="0" value="<?= Kiosk::DEFAULT_DAILY_LIMIT ?>">
<div class="help-text"><?= __('kiosks_daily_limit_hint') ?></div>
</div>
<div class="form-group">
<label for="cooldown_seconds"><?= __('kiosks_cooldown') ?></label>
<input type="number" id="cooldown_seconds" name="cooldown_seconds" min="0" max="3600" value="<?= Kiosk::DEFAULT_COOLDOWN ?>">
<div class="help-text"><?= __('kiosks_cooldown_hint') ?></div>
</div>
<div class="form-group">
<label for="display_seconds"><?= __('kiosks_display') ?></label>
<input type="number" id="display_seconds" name="display_seconds" min="10" max="600" value="<?= Kiosk::DEFAULT_DISPLAY_SECONDS ?>">
<div class="help-text"><?= __('kiosks_display_hint') ?></div>
</div>
</div>
<div class="checkbox-group" id="activeRow" style="display:none;">
<input type="checkbox" id="is_active" name="is_active" checked>
<label for="is_active"><?= __('kiosks_active') ?></label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal()"><?= __('btn_cancel') ?></button>
<button type="submit" name="add_kiosk" id="submitAdd" class="btn btn-primary">
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
</button>
<button type="submit" name="edit_kiosk" id="submitEdit" class="btn btn-primary" style="display:none;">
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
</button>
</div>
</form>
</div>
</div>
<!-- QR-Code des Links -->
<div class="modal" id="qrModal">
<div class="modal-content" style="max-width:420px;" role="dialog" aria-modal="true">
<div class="modal-header">
<h2 class="modal-title" id="qrTitle"><?= __('kiosks_qr') ?></h2>
<button class="modal-close" type="button" onclick="closeQr()" aria-label="<?= __('btn_cancel') ?>">&times;</button>
</div>
<div class="modal-body" style="text-align:center;">
<div id="qrTarget" style="display:inline-block;padding:14px;background:#fff;border-radius:12px;line-height:0;"></div>
<p class="help-text" style="margin-top:14px;"><?= __('kiosks_qr_hint') ?></p>
</div>
</div>
</div>
</main>
<div id="toast-container" role="status" aria-live="polite"></div>
<script src="../assets/global.js"></script>
<script>
function openAddModal() {
document.getElementById('kioskModalTitle').textContent = <?= json_encode(__('kiosks_add')) ?>;
document.querySelector('#kioskModal form').reset();
document.getElementById('kiosk_id').value = '';
setImageField('logo_url', '');
setImageField('background_url', '');
document.getElementById('submitAdd').style.display = '';
document.getElementById('submitEdit').style.display = 'none';
document.getElementById('activeRow').style.display = 'none';
document.getElementById('kioskModal').classList.add('active');
}
function openEditModal(data) {
document.getElementById('kioskModalTitle').textContent = <?= json_encode(__('kiosks_edit')) ?>;
document.getElementById('kiosk_id').value = data.id;
document.getElementById('name').value = data.name || '';
document.getElementById('site_id').value = data.site_id;
document.getElementById('template_id').value = data.template_id || 0;
document.getElementById('headline').value = data.headline || '';
document.getElementById('subline').value = data.subline || '';
document.getElementById('bg_overlay').value = data.bg_overlay;
document.getElementById('accent_color').value = data.accent_color || '';
document.getElementById('card_style').value = data.card_style || 'light';
setImageField('logo_url', data.logo_url || '');
setImageField('background_url', data.background_url || '');
document.getElementById('daily_limit').value = data.daily_limit;
document.getElementById('cooldown_seconds').value = data.cooldown_seconds;
document.getElementById('display_seconds').value = data.display_seconds;
document.getElementById('is_active').checked = data.is_active === 1;
document.getElementById('submitAdd').style.display = 'none';
document.getElementById('submitEdit').style.display = '';
document.getElementById('activeRow').style.display = '';
document.getElementById('kioskModal').classList.add('active');
}
function closeModal() { document.getElementById('kioskModal').classList.remove('active'); }
/**
* Bildfeld im Modal auf den Wert des Kiosks setzen: Vorschau, URL-Feld und
* der Entfernen-Schalter hängen am selben Namen.
*/
function setImageField(name, value) {
const wrapper = document.querySelector('[name="' + name + '"]').closest('.form-group');
const text = wrapper.querySelector('input[type="text"]');
const preview = wrapper.querySelector('.image-preview');
const remove = wrapper.querySelector('input[type="checkbox"]');
if (text) text.value = value;
if (remove) remove.checked = false;
if (preview) {
const src = value && !/^https?:|^\//.test(value) ? '../' + value : value;
preview.innerHTML = value
? '<img src="' + src + '" alt="">'
: '<i class="fas fa-image" aria-hidden="true"></i>';
}
}
function showQr(url, name) {
var target = document.getElementById('qrTarget');
target.innerHTML = '';
document.getElementById('qrTitle').textContent = name;
new QRCode(target, { text: url, width: 260, height: 260, colorDark: '#101625', colorLight: '#ffffff' });
document.getElementById('qrModal').classList.add('active');
}
function closeQr() { document.getElementById('qrModal').classList.remove('active'); }
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeModal(); closeQr(); }
});
document.querySelectorAll('.modal').forEach(function (m) {
m.addEventListener('click', function (e) { if (e.target === m) m.classList.remove('active'); });
});
</script>
</body>
</html>

View file

@ -6,7 +6,6 @@ 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();
@ -96,46 +95,56 @@ $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><?= __('rep_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title>Reporting <?= htmlspecialchars($appTitle) ?></title>
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?> <?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header"> <style>
<div> .card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:22px; margin-bottom:20px; box-shadow:0 4px 14px var(--shadow); }
<h1 class="page-title"><?= __('rep_title') ?></h1> .card h2 { font-size:15px; margin-bottom:14px; color:var(--text-primary); }
<p class="page-subtitle"><?= __('rep_subtitle') ?></p> .grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px; }
</div> .stat { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:20px; }
</div> .stat .n { font-size:28px; font-weight:700; color:var(--text-primary); } .stat .l { color:var(--text-muted); font-size:12.5px; margin-top:3px; }
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:10px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); } th { color:var(--text-muted); }
.btn { padding:9px 15px; border:none; border-radius:8px; font-weight:600; font-size:13px; text-decoration:none; display:inline-block; cursor:pointer; }
.btn-s { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:18px; }
select.input { padding:9px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); }
@media print { .sidebar,.header,.toolbar,.no-print { display:none !important; } .main-content { margin:0 !important; } }
</style>
</head>
<body>
<h1 style="font-size:24px;margin-bottom:18px;color:var(--text-primary);">📊 Reporting</h1>
<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;"><?= __('rep_period') ?></label> <label style="color:var(--text-muted);font-size:13px;">Zeitraum:</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 ?> <?= __('rep_days') ?></option> <option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> Tage</option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</form> </form>
<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-s" href="?export=daily&days=<?= $days ?>">⬇️ CSV (täglich)</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-s" href="?export=per_site">⬇️ CSV (pro Site)</a>
<a class="btn btn-secondary" href="?export=per_user"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_user') ?></a> <a class="btn btn-s" href="?export=per_user">⬇️ CSV (pro Nutzer)</a>
<button class="btn btn-secondary" onclick="window.print()"><i class="fas fa-print" aria-hidden="true"></i> <?= __('rep_print') ?></button> <button class="btn btn-s" onclick="window.print()">🖨️ Drucken/PDF</button>
</div> </div>
<div class="grid4"> <div class="grid4">
<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['total'] ?></div><div class="l">Vouchers gesamt</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['valid'] ?></div><div class="l">Gültig</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"><?= (int)$totals['used'] ?></div><div class="l">Verwendet</div></div>
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l"><?= str_replace('{days}', (string)$days, __('rep_in_period')) ?></div></div> <div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l">In <?= $days ?> Tagen erstellt</div></div>
</div> </div>
<div class="card"> <div class="card">
<h2><?= str_replace('{days}', (string)$days, __('rep_chart_title')) ?></h2> <h2>Erstellte Voucher (<?= $days ?> Tage)</h2>
<canvas id="chart" height="90"></canvas> <canvas id="chart" height="90"></canvas>
</div> </div>
<div class="card"> <div class="card">
<h2><?= __('rep_per_site') ?></h2> <h2>Pro Site</h2>
<table><tr><th><?= __('label_site') ?></th><th><?= __('label_total') ?></th><th><?= __('status_valid') ?></th><th><?= __('status_used') ?></th><th><?= __('status_expired') ?></th></tr> <table><tr><th>Site</th><th>Gesamt</th><th>Gültig</th><th>Verwendet</th><th>Abgelaufen</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; ?>
@ -143,32 +152,22 @@ $adminBase = '';
</div> </div>
<div class="card"> <div class="card">
<h2><?= __('rep_top_users') ?></h2> <h2>Top-Nutzer</h2>
<table><tr><th><?= __('label_user') ?></th><th><?= __('rep_col_created') ?></th></tr> <table><tr><th>Benutzer</th><th>Voucher erstellt</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);"><?= __('rep_no_data') ?></td></tr><?php endif; ?> <?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);">Keine Daten</td></tr><?php endif; ?>
</table> </table>
</div> </div>
</main> </div><!-- /main-content -->
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
<script> <script>
const styles = getComputedStyle(document.documentElement);
const accent = styles.getPropertyValue('--accent').trim();
const grid = styles.getPropertyValue('--border-color').trim();
const muted = styles.getPropertyValue('--text-muted').trim();
new Chart(document.getElementById('chart'), { new Chart(document.getElementById('chart'), {
type:'line', type:'line',
data:{ labels: <?= json_encode($chartLabels) ?>, datasets:[{ label:'Voucher', data: <?= json_encode($chartData) ?>, borderColor: accent, backgroundColor: accent + '22', fill:true, tension:.35, pointRadius:0, pointHoverRadius:4, borderWidth:2 }] }, data:{ labels: <?= json_encode($chartLabels) ?>, datasets:[{ label:'Voucher', data: <?= json_encode($chartData) ?>, borderColor:'#667eea', backgroundColor:'rgba(102,126,234,.15)', fill:true, tension:.3 }] },
options:{ options:{ plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true,ticks:{precision:0}}} }
plugins:{legend:{display:false}},
scales:{
y:{ beginAtZero:true, border:{display:false}, grid:{color:grid, drawTicks:false}, ticks:{precision:0, color:muted, padding:10, font:{size:11}} },
x:{ border:{display:false}, grid:{display:false}, ticks:{color:muted, padding:8, font:{size:11}} }
}
}
}); });
</script> </script>
</body> </body>

View file

@ -6,10 +6,6 @@ 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();
@ -28,20 +24,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 = __('sec_token_invalid'); $error = 'Ungültiges Sicherheits-Token';
} 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 = __('sec_setup_expired'); $error = 'Setup abgelaufen, bitte erneut starten.';
} elseif (!Totp::verify($secret, $code)) { } elseif (!Totp::verify($secret, $code)) {
$error = __('sec_code_invalid'); $error = 'Code ungültig. Bitte erneut versuchen.';
} 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 = __('sec_enabled'); $success = 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!';
} }
} }
} }
@ -49,32 +45,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 = __('sec_token_invalid'); $error = 'Ungültiges Sicherheits-Token';
} else { } else {
$auth->logoutOtherSessions(); $auth->logoutOtherSessions();
$success = __('sec_sessions_closed'); $success = 'Alle anderen Sitzungen wurden beendet.';
} }
} }
// 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 = __('sec_token_invalid'); $error = 'Ungültiges Sicherheits-Token';
} 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 = __('sec_codes_new'); $success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.';
} }
} }
// 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 = __('sec_token_invalid'); $error = 'Ungültiges Sicherheits-Token';
} else { } else {
$auth->disableTotp($user['id']); $auth->disableTotp($user['id']);
$totpEnabled = false; $totpEnabled = false;
$success = __('sec_disabled'); $success = 'Zwei-Faktor-Authentifizierung wurde deaktiviert.';
} }
} }
@ -91,36 +87,54 @@ $dbSessions = $db->getSetting('session_driver', 'php') === 'db';
$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0; $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>"> <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><?= __('sec_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title>Zwei-Faktor-Authentifizierung <?= htmlspecialchars($appTitle) ?></title>
<?php if (!$totpEnabled && $hasPassword): ?> <?php if (!$totpEnabled && $hasPassword): ?>
<?= 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-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<?php endif; ?> <?php endif; ?>
<?= Ui::head($db, '../') ?> <style>
* { margin:0; padding:0; box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
body { background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px; }
.card { background:#fff; border-radius:18px; box-shadow:0 20px 60px rgba(0,0,0,.3); max-width:480px; width:100%; padding:36px; }
h1 { font-size:22px; color:#333; margin-bottom:6px; }
.sub { color:#777; font-size:14px; margin-bottom:24px; }
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
.status { display:inline-flex; align-items:center; gap:8px; padding:6px 12px; border-radius:8px; font-size:13px; font-weight:600; margin-bottom:20px; }
.on { background:#e3f6ea; color:#2a7; } .off { background:#fdeaea; color:#c33; }
.qr { display:flex; justify-content:center; margin:18px 0; }
.secret { font-family:monospace; background:#f5f6fa; padding:10px; border-radius:8px; text-align:center; letter-spacing:2px; word-break:break-all; font-size:14px; margin-bottom:18px; }
ol { margin:0 0 18px 18px; color:#555; font-size:14px; line-height:1.7; }
label { display:block; font-size:14px; color:#555; margin-bottom:8px; font-weight:500; }
input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-radius:10px; font-size:18px; letter-spacing:6px; text-align:center; }
.btn { width:100%; padding:14px; border:none; border-radius:10px; font-size:15px; font-weight:600; cursor:pointer; margin-top:14px; }
.btn-primary { background:#667eea; color:#fff; } .btn-danger { background:#e25555; color:#fff; }
.back { display:block; text-align:center; margin-top:20px; color:#667eea; text-decoration:none; font-size:14px; }
.codes-box { background:#fff8e6; border:1px solid #ffe3a3; border-radius:10px; padding:16px; margin-bottom:18px; }
.codes-box strong { font-size:15px; } .codes-box p { color:#8a6d2f; font-size:13px; margin:6px 0 12px; }
.codes { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.codes span { font-family:monospace; background:#fff; border:1px solid #ffe3a3; border-radius:6px; padding:8px; text-align:center; letter-spacing:2px; font-size:14px; }
</style>
</head> </head>
<body class="app-body focus-page"> <body>
<div class="focus-card card"> <div class="card">
<div class="focus-head"> <h1>🔐 Zwei-Faktor-Authentifizierung</h1>
<span class="focus-icon"><i class="fas fa-shield-halved" aria-hidden="true"></i></span> <p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p>
<div>
<h1><?= __('sec_title') ?></h1>
<p class="sub"><?= __('sec_account') ?> <?= htmlspecialchars($user['email']) ?></p>
</div>
</div>
<?php if ($setupRequired && !$totpEnabled): ?> <?php if ($setupRequired && !$totpEnabled): ?>
<div class="alert alert-error"><?= __('sec_required_hint') ?></div> <div class="alert alert-error">Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.</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" aria-hidden="true"></i> <?= __('sec_recovery_codes') ?></strong> <strong>🔑 Recovery-Codes</strong>
<p><?= __('sec_recovery_hint') ?></p> <p>Bewahren Sie diese sicher auf. Jeder Code funktioniert <em>einmal</em>, falls Sie keinen Zugriff auf Ihre App haben.</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>
@ -128,54 +142,53 @@ $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" aria-hidden="true"></i> <?= __('sec_unavailable') ?></div> <div class="status off"> Nicht verfügbar</div>
<p class="sub"><?= __('sec_m365_hint') ?></p> <p class="sub">Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.</p>
<?php elseif ($totpEnabled): ?> <?php elseif ($totpEnabled): ?>
<div class="status on"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('sec_active') ?></div> <div class="status on"> Aktiv</div>
<p class="sub"><?= __('sec_active_hint') ?><br> <p class="sub">Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.<br>
<?= __('sec_codes_left') ?> <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p> Verbleibende Recovery-Codes: <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"><?= __('sec_regen_codes') ?></button> <button type="submit" name="regen_codes" class="btn btn-secondary" style="background:#eef0ff;color:#5a63d6;width:100%;">Recovery-Codes neu erzeugen</button>
</form> </form>
<form method="post" onsubmit="return confirm('<?= __('sec_disable_confirm') ?>');"> <form method="post" onsubmit="return confirm('2FA wirklich deaktivieren?');">
<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"><?= __('sec_disable') ?></button> <button type="submit" name="disable_totp" class="btn btn-danger">2FA deaktivieren</button>
</form> </form>
<?php else: ?> <?php else: ?>
<div class="status off"><i class="fas fa-circle-minus" aria-hidden="true"></i> <?= __('sec_inactive') ?></div> <div class="status off"> Inaktiv</div>
<ol> <ol>
<li><?= __('sec_step_1') ?></li> <li>Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator )</li>
<li><?= __('sec_step_2') ?></li> <li>QR-Code scannen <em>oder</em> Secret manuell eingeben</li>
<li><?= __('sec_step_3') ?></li> <li>Den angezeigten 6-stelligen Code unten eingeben</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"><?= __('sec_code_label') ?></label> <label for="code">6-stelliger Code</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" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required>
<button type="submit" name="enable_totp" class="btn btn-primary btn-lg btn-block" style="margin-top:14px;"><?= __('sec_enable') ?></button> <button type="submit" name="enable_totp" class="btn btn-primary">2FA aktivieren</button>
</form> </form>
<script> <script>
new QRCode(document.getElementById('qrcode'), { new QRCode(document.getElementById('qrcode'), {
text: <?= json_encode($otpUri) ?>, width: 168, height: 168, text: <?= json_encode($otpUri) ?>, width: 180, height: 180,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M correctLevel: QRCode.CorrectLevel.M
}); });
</script> </script>
<?php endif; ?> <?php endif; ?>
<?php if ($dbSessions): ?> <?php if ($dbSessions): ?>
<hr> <hr style="margin:20px 0;border:none;border-top:1px solid #eee;">
<p class="sub"><?= __('sec_sessions') ?> <strong><?= (int)$activeSessions ?></strong></p> <p class="sub">Aktive Sitzungen: <strong><?= (int)$activeSessions ?></strong></p>
<form method="post" onsubmit="return confirm('<?= __('sec_logout_others_confirm') ?>');"> <form method="post" onsubmit="return confirm('Alle anderen Sitzungen abmelden?');">
<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"><?= __('sec_logout_others') ?></button> <button type="submit" name="logout_others" class="btn" style="background:#eef0ff;color:#5a63d6;width:100%;">Auf allen anderen Geräten abmelden</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<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> <a class="back" href="../index.php"> Zurück</a>
</div> </div>
</body> </body>
</html> </html>

View file

@ -8,8 +8,7 @@ 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/Helpers.php';
require_once __DIR__ . '/../includes/Upload.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
@ -53,37 +52,13 @@ 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'] = Upload::resolveField('logo_url', (string)$db->getSetting('logo_url', ''), 'image'); $settings['logo_url'] = trim($_POST['logo_url'] ?? '');
$settings['favicon_url'] = Upload::resolveField('favicon_url', (string)$db->getSetting('favicon_url', ''), 'favicon'); $settings['favicon_url'] = trim($_POST['favicon_url'] ?? '');
$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') {
$settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0';
$settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? '');
$settings['login_logo_url'] = Upload::resolveField('login_logo_url', (string)$db->getSetting('login_logo_url', ''), 'image');
$settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? '');
$settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? '');
$settings['login_features'] = trim($_POST['login_features'] ?? '');
$settings['login_footer'] = trim($_POST['login_footer'] ?? '');
$settings['login_bg_image'] = Upload::resolveField('login_bg_image', (string)$db->getSetting('login_bg_image', ''), 'image');
$settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? '');
$settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? '');
$overlay = (int)($_POST['login_bg_overlay'] ?? 40);
$settings['login_bg_overlay'] = (string)max(0, min(90, $overlay));
}
if ($formType === 'defaults') { if ($formType === 'defaults') {
$expMin = (int)($_POST['default_expire_minutes'] ?? 480); $expMin = (int)($_POST['default_expire_minutes'] ?? 480);
$defDev = (int)($_POST['default_max_uses'] ?? 1); $defDev = (int)($_POST['default_max_uses'] ?? 1);
@ -98,7 +73,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if ($formType === 'm365') { if ($formType === 'm365') {
$settings['m365_client_id'] = trim($_POST['m365_client_id'] ?? ''); $settings['m365_client_id'] = trim($_POST['m365_client_id'] ?? '');
$settings['m365_client_secret'] = trim($_POST['m365_client_secret'] ?? ''); // Secret nur aktualisieren, wenn eines eingegeben wurde es wird
// (wie das SMTP-Passwort) nicht mehr ins Formular zurueckgegeben.
if (!empty($_POST['m365_client_secret'])) {
$settings['m365_client_secret'] = trim($_POST['m365_client_secret']);
}
$settings['m365_tenant_id'] = trim($_POST['m365_tenant_id'] ?? ''); $settings['m365_tenant_id'] = trim($_POST['m365_tenant_id'] ?? '');
} }
@ -111,6 +90,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
$settings['smtp_password'] = trim($_POST['smtp_password']); $settings['smtp_password'] = trim($_POST['smtp_password']);
} }
$settings['smtp_encryption'] = trim($_POST['smtp_encryption'] ?? 'tls'); $settings['smtp_encryption'] = trim($_POST['smtp_encryption'] ?? 'tls');
$settings['smtp_verify_ssl'] = isset($_POST['smtp_verify_ssl']) ? '1' : '0';
$settings['smtp_from_email'] = trim($_POST['smtp_from_email'] ?? ''); $settings['smtp_from_email'] = trim($_POST['smtp_from_email'] ?? '');
$settings['smtp_from_name'] = trim($_POST['smtp_from_name'] ?? ''); $settings['smtp_from_name'] = trim($_POST['smtp_from_name'] ?? '');
} }
@ -124,6 +104,7 @@ 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'] ?? '';
} }
@ -131,9 +112,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
$db->setSetting($key, $value); $db->setSetting($key, $value);
} }
$success = __('settings_saved'); // PRG + Tab-Anker: F5 speichert nicht erneut, und der Nutzer landet
} catch (RuntimeException $e) { // wieder auf dem Tab, in dem er gespeichert hat.
$error = $e->getMessage(); $tabAnchors = [
'general' => 'general', 'defaults' => 'defaults', 'm365' => 'm365',
'smtp' => 'smtp', 'templates' => 'templates_email', 'system' => 'system',
];
flashSet(__('settings_saved'));
header('Location: settings.php#' . ($tabAnchors[$formType] ?? 'general'));
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = 'Fehler: ' . $e->getMessage(); $error = 'Fehler: ' . $e->getMessage();
} }
@ -146,7 +133,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['generate_cron_token']
$error = __('error_csrf'); $error = __('error_csrf');
} else { } else {
$db->setSetting('cron_token', bin2hex(random_bytes(32))); $db->setSetting('cron_token', bin2hex(random_bytes(32)));
$success = 'Neuer Cron-Token wurde generiert!'; flashSet(__('cron_token_generated'));
header('Location: settings.php#cron');
exit;
} }
} }
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token'])) {
@ -154,7 +143,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token']))
$error = __('error_csrf'); $error = __('error_csrf');
} else { } else {
$db->setSetting('cron_token', ''); $db->setSetting('cron_token', '');
$success = 'Cron-Token wurde gelöscht!'; flashSet(__('cron_token_deleted'));
header('Location: settings.php#cron');
exit;
} }
} }
@ -166,23 +157,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['change_password'])) {
try { try {
$user = $auth->getCurrentUser(); $user = $auth->getCurrentUser();
if (!password_verify($_POST['current_password'], $user['password_hash'])) { if (!password_verify($_POST['current_password'], $user['password_hash'])) {
throw new Exception('Aktuelles Passwort ist falsch'); throw new Exception(__('error_pw_current'));
} }
if (strlen($_POST['new_password']) < 8) { if (strlen($_POST['new_password']) < 8) {
throw new Exception(__('settings_pw_minlength')); throw new Exception(__('settings_pw_minlength'));
} }
if ($_POST['new_password'] !== $_POST['confirm_password']) { if ($_POST['new_password'] !== $_POST['confirm_password']) {
throw new Exception('Passwörter stimmen nicht überein'); throw new Exception(__('error_pw_mismatch'));
} }
$db->query("UPDATE users SET password_hash = ? WHERE id = ?", $db->query("UPDATE users SET password_hash = ? WHERE id = ?",
[password_hash($_POST['new_password'], PASSWORD_DEFAULT), $user['id']]); [password_hash($_POST['new_password'], PASSWORD_DEFAULT), $user['id']]);
$success = __('settings_pw_changed'); flashSet(__('settings_pw_changed'));
header('Location: settings.php#password');
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = $e->getMessage(); $error = $e->getMessage();
} }
} }
} }
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST']; $host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME'], 2); $scriptPath = dirname($_SERVER['SCRIPT_NAME'], 2);
@ -208,30 +205,16 @@ $cs = [
'smtp_username' => $db->getSetting('smtp_username', ''), 'smtp_username' => $db->getSetting('smtp_username', ''),
'smtp_password' => $db->getSetting('smtp_password', ''), 'smtp_password' => $db->getSetting('smtp_password', ''),
'smtp_encryption' => $db->getSetting('smtp_encryption', 'tls'), 'smtp_encryption' => $db->getSetting('smtp_encryption', 'tls'),
'smtp_verify_ssl' => $db->getSetting('smtp_verify_ssl', '0'),
'smtp_from_email' => $db->getSetting('smtp_from_email', ''), 'smtp_from_email' => $db->getSetting('smtp_from_email', ''),
'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\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_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_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}"),
'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()), 'tinymce_api_key' => $db->getSetting('tinymce_api_key', ''),
'brand_accent' => $db->getSetting('brand_accent', '') ?: Ui::DEFAULT_ACCENT, '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_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_brand_name' => $db->getSetting('login_brand_name', ''),
'login_logo_url' => $db->getSetting('login_logo_url', ''),
'login_claim_title' => $db->getSetting('login_claim_title', ''),
'login_claim_text' => $db->getSetting('login_claim_text', ''),
'login_features' => $db->getSetting('login_features', ''),
'login_footer' => $db->getSetting('login_footer', ''),
'login_bg_image' => $db->getSetting('login_bg_image', ''),
'login_bg_from' => $db->getSetting('login_bg_from', '#3b2f8f'),
'login_bg_to' => $db->getSetting('login_bg_to', '#6d5ce7'),
'login_bg_overlay' => $db->getSetting('login_bg_overlay', '40'),
'cron_token' => $db->getSetting('cron_token', ''), 'cron_token' => $db->getSetting('cron_token', ''),
'last_cron_sync' => $db->getSetting('last_cron_sync', ''), 'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
]; ];
@ -246,63 +229,98 @@ $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>
<!-- TinyMCE wird lokal ausgeliefert (GPL-Variante) keine externen Aufrufe. --> <?php if (!empty($cs['tinymce_api_key'])): ?>
<?= Ui::script('assets/vendor/tinymce/tinymce.min.js', '../') ?> <script src="https://cdn.tiny.cloud/1/<?= htmlspecialchars($cs['tinymce_api_key']) ?>/tinymce/6/tinymce.min.js"></script>
<script>window.TINYMCE_BASE_URL = '../assets/vendor/tinymce';</script> <?php else: ?>
<script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js"></script>
<?php endif; ?>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.page-header { margin-bottom: 30px; }
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.tab-container { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; }
.tab-navigation { display: flex; background: var(--bg-table-head); border-bottom: 2px solid var(--border-color); overflow-x: auto; position: sticky; top: 70px; z-index: 50; }
.tab-button { padding: 15px 20px; background: transparent; border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-secondary); transition: all 0.3s; white-space: nowrap; display: flex; align-items: center; gap: 7px; }
.tab-button:hover { background: rgba(102,126,234,0.1); color: var(--accent); }
.tab-button.active { color: var(--accent); border-bottom-color: var(--accent); background: var(--bg-card); }
.tab-content { display: none; padding: 30px; animation: fadeIn 0.2s; }
.tab-content.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
input[type="text"], input[type="url"], input[type="password"], input[type="number"], input[type="email"], select, textarea { width: 100%; padding: 11px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; transition: border-color 0.2s; font-family: inherit; background: var(--bg-input); color: var(--text-primary); }
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
textarea { resize: vertical; min-height: 100px; }
.checkbox-group { display: flex; align-items: center; gap: 10px; }
.checkbox-group input { width: auto; accent-color: var(--accent); }
.help-text { font-size: 12px; color: var(--text-muted); margin-top: 5px; }
.info-box { background: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
.info-box h4 { color: #0066cc; margin-bottom: 8px; font-size: 14px; }
.info-box p { color: #004d99; font-size: 13px; line-height: 1.5; }
.section-divider { border: none; border-top: 2px solid var(--border-color); margin: 28px 0; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
.placeholder-info { background: #fff9e6; border: 1px solid #ffe066; border-radius: 8px; padding: 14px; margin: 14px 0; }
.placeholder-info h4 { color: #996600; margin-bottom: 8px; font-size: 14px; }
.placeholder-info code { background: #fff; padding: 2px 6px; border-radius: 3px; font-size: 12px; color: #d63384; }
.placeholder-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: #dc3545; color: white; }
.btn-small { padding: 6px 12px; font-size: 13px; }
.token-box { background: var(--bg-hover); padding: 14px; border-radius: 6px; font-family: monospace; word-break: break-all; color: var(--text-primary); }
code { background: var(--code-bg); color: var(--accent); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
</style>
</head>
<div class="page-header"> <div class="page-header">
<div>
<h1 class="page-title"><?= __('settings_title') ?></h1> <h1 class="page-title"><?= __('settings_title') ?></h1>
<p class="page-subtitle"><?= __('settings_subtitle') ?></p> <p style="color: var(--text-muted); font-size: 14px;"><?= __('settings_subtitle') ?></p>
</div>
</div> </div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i><span><?= htmlspecialchars($error) ?></span></div> <div class="alert alert-error"><i class="fas fa-exclamation-circle"></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" aria-hidden="true"></i><span><?= htmlspecialchars($success) ?></span></div> <div class="alert alert-success"><i class="fas fa-check-circle"></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<?= $activeTab === 'general' ? ' active' : '' ?>" data-tab="general"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></button> <button class="tab-button active" data-tab="general"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></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="defaults"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></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<?= $activeTab === 'general' ? ' active' : '' ?>"> <div id="tab-general" class="tab-content active">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></h2>
<form method="post" enctype="multipart/form-data"> <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="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">
<?= Ui::imageField('logo_url', __('settings_logo_url'), $cs['logo_url'], __('settings_upload_hint')) ?> <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>
<?= Ui::imageField('favicon_url', __('settings_favicon_url'), $cs['favicon_url'], __('settings_favicon_hint'), 'image/x-icon,image/png,image/svg+xml') ?> <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>
</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" aria-hidden="true"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
</form> </form>
</div> </div>
<!-- Voucher-Standards --> <!-- Voucher-Standards -->
<div id="tab-defaults" class="tab-content<?= $activeTab === 'defaults' ? ' active' : '' ?>"> <div id="tab-defaults" class="tab-content">
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></h2> <h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></h2>
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_defaults_hint') ?></p> <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;">Diese Werte werden als Vorgabe im Voucher-Formular verwendet.</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">
@ -324,184 +342,37 @@ $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" aria-hidden="true"></i> Gültigkeits-Referenz</h4> <h4><i class="fas fa-info-circle"></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" aria-hidden="true"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary" style="margin-top: 16px;"><i class="fas fa-save"></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>
</div>
<!-- Login-Seite -->
<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" aria-hidden="true"></i> <?= __('settings_tab_login') ?></h2>
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_login_intro') ?></p>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="login">
<div class="checkbox-group" style="margin-bottom: 20px;">
<input type="checkbox" name="login_panel_enabled" id="login_panel_enabled" <?= $cs['login_panel_enabled'] === '1' ? 'checked' : '' ?>>
<label for="login_panel_enabled" style="margin:0;"><?= __('settings_login_panel') ?></label>
</div>
<div class="form-grid">
<div class="form-group">
<label><?= __('settings_login_brand') ?></label>
<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>
<?= Ui::imageField('login_logo_url', __('settings_login_logo'), $cs['login_logo_url'], __('settings_login_logo_hint')) ?>
</div>
<hr class="section-divider">
<div class="form-group">
<label><?= __('settings_login_claim_title') ?></label>
<input type="text" name="login_claim_title" value="<?= htmlspecialchars($cs['login_claim_title']) ?>" placeholder="<?= htmlspecialchars(__('auth_claim_title')) ?>">
</div>
<div class="form-group">
<label><?= __('settings_login_claim_text') ?></label>
<textarea name="login_claim_text" rows="3" placeholder="<?= htmlspecialchars(__('auth_claim_text')) ?>"><?= htmlspecialchars($cs['login_claim_text']) ?></textarea>
</div>
<div class="form-group">
<label><?= __('settings_login_features') ?></label>
<textarea name="login_features" rows="4" placeholder="<?= htmlspecialchars(__('auth_feature_1') . "\n" . __('auth_feature_2') . "\n" . __('auth_feature_3')) ?>"><?= htmlspecialchars($cs['login_features']) ?></textarea>
<div class="help-text"><?= __('settings_login_features_hint') ?></div>
</div>
<div class="form-group">
<label><?= __('settings_login_footer') ?></label>
<input type="text" name="login_footer" value="<?= htmlspecialchars($cs['login_footer']) ?>" placeholder="© <?= date('Y') ?> <?= htmlspecialchars($cs['app_title']) ?>">
</div>
<hr class="section-divider">
<?= Ui::imageField('login_bg_image', __('settings_login_bg_image'), $cs['login_bg_image'], __('settings_login_bg_image_hint')) ?>
<div class="form-grid">
<div class="form-group">
<label><?= __('settings_login_bg_from') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="login_bg_from"
value="<?= preg_match('/^#[0-9a-fA-F]{6}$/', $cs['login_bg_from']) ? htmlspecialchars($cs['login_bg_from']) : '#3b2f8f' ?>">
<input type="text" id="login_bg_from" name="login_bg_from" value="<?= htmlspecialchars($cs['login_bg_from']) ?>" placeholder="#3b2f8f">
</div>
</div>
<div class="form-group">
<label><?= __('settings_login_bg_to') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="login_bg_to"
value="<?= preg_match('/^#[0-9a-fA-F]{6}$/', $cs['login_bg_to']) ? htmlspecialchars($cs['login_bg_to']) : '#6d5ce7' ?>">
<input type="text" id="login_bg_to" name="login_bg_to" value="<?= htmlspecialchars($cs['login_bg_to']) ?>" placeholder="#6d5ce7">
</div>
</div>
<div class="form-group">
<label><?= __('settings_login_overlay') ?></label>
<input type="number" name="login_bg_overlay" min="0" max="90" value="<?= (int)$cs['login_bg_overlay'] ?>">
<div class="help-text"><?= __('settings_login_overlay_hint') ?></div>
</div>
</div>
<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" aria-hidden="true"></i> <?= __('btn_save') ?></button>
<a href="../login.php?preview=1" target="_blank" rel="noopener" class="btn btn-secondary">
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> <?= __('settings_login_preview') ?>
</a>
</div>
</form> </form>
</div> </div>
<!-- Cron-Sync --> <!-- Cron-Sync -->
<div id="tab-cron" class="tab-content<?= $activeTab === 'cron' ? ' active' : '' ?>"> <div id="tab-cron" class="tab-content">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></h2>
<div class="info-box"> <div class="info-box">
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> <?= __('settings_cron_what') ?></h4> <h4><i class="fas fa-info-circle"></i> Was macht der Cron-Job?</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);" aria-hidden="true"></i> Kein Token konfiguriert.</p> <p style="color: var(--text-muted); margin-bottom: 15px;"><i class="fas fa-exclamation-triangle" style="color: var(--warning);"></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" aria-hidden="true"></i> Token generieren</button> <button type="submit" name="generate_cron_token" class="btn btn-primary"><i class="fas fa-key"></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" aria-hidden="true"></i> Kopieren</button> <button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy"></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" aria-hidden="true"></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"></i> Neu generieren</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> <form method="post" style="display:inline;" onsubmit="return confirm('<?= addslashes(__('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"></i> Löschen</button></form>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php <?php
@ -511,7 +382,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" aria-hidden="true"></i></button> <button onclick="copyToClipboard(document.getElementById('cronUrl').value)" class="btn btn-secondary"><i class="fas fa-copy"></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);">
@ -522,7 +393,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" aria-hidden="true"></i> Jetzt ausführen</button> <button onclick="testCronJob()" class="btn btn-primary" id="testCronBtn"><i class="fas fa-play"></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; ?>
@ -531,24 +402,24 @@ $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" aria-hidden="true"></i> Microsoft 365</h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fab fa-microsoft"></i> Microsoft 365</h2>
<div class="info-box"> <div class="info-box">
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> Azure AD App</h4> <h4><i class="fas fa-info-circle"></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">
<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="m365"> <input type="hidden" name="form_type" value="m365">
<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" placeholder="<?= $cs['m365_client_secret'] !== '' ? '••••••••' : '' ?>"><div class="help-text"><?= __('m365_secret_hint') ?></div></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" aria-hidden="true"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
</form> </form>
</div> </div>
<!-- SMTP --> <!-- SMTP -->
<div id="tab-smtp" class="tab-content<?= $activeTab === 'smtp' ? ' active' : '' ?>"> <div id="tab-smtp" class="tab-content">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope" aria-hidden="true"></i> SMTP</h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope"></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">
@ -558,62 +429,63 @@ $adminBase = '';
<div class="form-group"><label>Port</label><input type="number" name="smtp_port" value="<?= htmlspecialchars($cs['smtp_port']) ?>"></div> <div class="form-group"><label>Port</label><input type="number" name="smtp_port" value="<?= htmlspecialchars($cs['smtp_port']) ?>"></div>
</div> </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-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="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="smtp_verify_ssl" id="smtp_verify_ssl" <?= $cs['smtp_verify_ssl'] == '1' ? 'checked' : '' ?>><label for="smtp_verify_ssl" style="margin:0;"><?= __('smtp_verify_ssl') ?></label></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="<?= __('settings_leave_empty') ?>"></div> <div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="Leer = nicht ändern"></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><?= __('settings_smtp_from_name') ?></label><input type="text" name="smtp_from_name" value="<?= htmlspecialchars($cs['smtp_from_name']) ?>"></div> <div class="form-group"><label>Absender 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" aria-hidden="true"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></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" aria-hidden="true"></i> <?= __('btn_test') ?></button> <button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn"><i class="fas fa-paper-plane"></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<?= $activeTab === 'templates_email' ? ' active' : '' ?>"> <div id="tab-templates_email" class="tab-content">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt" aria-hidden="true"></i> E-Mail Templates</h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt"></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);"><?= __('settings_tpl_voucher_mail') ?></h3> <h3 style="margin-bottom:15px; color: var(--text-primary);">Voucher E-Mail</h3>
<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="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="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);"><?= __('settings_tpl_user_notify') ?></h3> <h3 style="margin-bottom:15px; color: var(--text-primary);">Benutzer-Benachrichtigung</h3>
<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="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="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" aria-hidden="true"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
</form> </form>
</div> </div>
<!-- System --> <!-- System -->
<div id="tab-system" class="tab-content<?= $activeTab === 'system' ? ' active' : '' ?>"> <div id="tab-system" class="tab-content">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs" aria-hidden="true"></i> System & Erweitert</h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs"></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" aria-hidden="true"></i> WYSIWYG-Editor</h4> <h4><i class="fas fa-info-circle"></i> TinyMCE API Key</h4>
<p><?= __('settings_editor_hint') ?> <code>assets/vendor/</code> <?= __('settings_editor_hint2') ?></p> <p>Kostenlosen API Key: <a href="https://www.tiny.cloud/auth/signup/" target="_blank" rel="noopener" style="color:#0066cc;">tiny.cloud/signup</a></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><?= __('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="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="form-group"><label><?= __('settings_print_template') ?></label><textarea name="print_template" class="tinymce-editor" style="min-height:250px;"><?= htmlspecialchars($cs['print_template']) ?></textarea></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>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></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>
@ -625,19 +497,19 @@ $adminBase = '';
</div> </div>
<!-- Passwort --> <!-- Passwort -->
<div id="tab-password" class="tab-content<?= $activeTab === 'password' ? ' active' : '' ?>"> <div id="tab-password" class="tab-content">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></h2> <h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key"></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" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button> <button type="submit" name="change_password" class="btn btn-primary"><i class="fas fa-lock"></i> <?= __('settings_tab_password') ?></button>
</form> </form>
</div> </div>
</div> </div>
</main> </div><!-- main-content -->
<script src="../assets/global.js"></script> <script src="../assets/global.js"></script>
<script> <script>
@ -653,43 +525,7 @@ document.querySelectorAll('.tab-button').forEach(btn => {
}); });
}); });
// Branding-Vorschau live faerben // Restore tab from hash
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
document.querySelectorAll('.color-swatch').forEach(swatch => {
const field = document.getElementById(swatch.dataset.target);
if (!field) return;
swatch.addEventListener('input', () => { field.value = swatch.value; updateBrandPreview(); });
field.addEventListener('input', () => {
if (/^#[0-9a-fA-F]{6}$/.test(field.value.trim())) swatch.value = field.value.trim();
});
});
// 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) {
@ -703,9 +539,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 = '<?= __('js_enter_email') ?>'; return; } if (!email) { result.textContent = 'Bitte E-Mail eingeben.'; return; }
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></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() ?>');
@ -715,52 +551,38 @@ 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" aria-hidden="true"></i> Testen'; btn.innerHTML = '<i class="fas fa-paper-plane"></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" aria-hidden="true"></i> <?= __('js_running') ?>'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Läuft...';
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" aria-hidden="true"></i> ${data.message}</span>` ? `<span style="color:var(--success)"><i class="fas fa-check-circle"></i> ${data.message}</span>`
: `<span style="color:var(--danger)"><i class="fas fa-times-circle" aria-hidden="true"></i> ${data.message}</span>`; : `<span style="color:var(--danger)"><i class="fas fa-times-circle"></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)"><?= __('js_error') ?>: ${e.message}</span>`; result.innerHTML = `<span style="color:var(--danger)">Fehler: ${e.message}</span>`;
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play" aria-hidden="true"></i> <?= __('js_run_now') ?>'; btn.innerHTML = '<i class="fas fa-play"></i> Jetzt ausführen';
} }
function initTinyMCE() { function initTinyMCE() {
if (typeof tinymce === 'undefined') return; tinymce.init({
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
const config = {
selector: '.tinymce-editor', selector: '.tinymce-editor',
height: 350, height: 350,
menubar: false, menubar: false,
plugins: ['advlist','autolink','lists','link','searchreplace','visualblocks','code','fullscreen','table','help','wordcount'], plugins: ['advlist','autolink','lists','link','searchreplace','visualblocks','code','fullscreen','table','help','wordcount'],
toolbar: 'undo redo | blocks | bold italic forecolor | alignleft aligncenter alignright | bullist numlist | removeformat | help', toolbar: 'undo redo | blocks | bold italic forecolor | alignleft aligncenter alignright | bullist numlist | removeformat | help',
content_style: "body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.6; }", content_style: 'body { font-family: -apple-system, sans-serif; font-size: 14px; line-height: 1.6; }',
skin: dark ? 'oxide-dark' : 'oxide',
content_css: dark ? 'dark' : 'default',
branding: false, promotion: false branding: false, promotion: false
}; });
if (window.TINYMCE_BASE_URL) {
tinymce.baseURL = window.TINYMCE_BASE_URL;
config.base_url = window.TINYMCE_BASE_URL;
config.suffix = '.min';
if (document.documentElement.lang === 'de') {
config.language = 'de';
config.language_url = window.TINYMCE_BASE_URL + '/langs/de.js';
}
}
tinymce.init(config);
} }
</script> </script>
</body> </body>

View file

@ -8,6 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.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';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
@ -18,6 +19,32 @@ I18n::init();
$error = ''; $error = '';
$success = ''; $success = '';
// AJAX: Verbindungstest mit gespeicherten Zugangsdaten (Health-Check pro Site)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['ajax_test_site'])) {
header('Content-Type: application/json');
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
echo json_encode(['success' => false, 'message' => __('error_csrf')]);
exit;
}
$site = $db->fetchOne("SELECT * FROM sites WHERE id=?", [(int)$_POST['ajax_test_site']]);
if (!$site) {
echo json_encode(['success' => false, 'message' => __('error_site_not_found')]);
exit;
}
$test = UniFiController::testConnection(
$site['unifi_controller_url'],
$site['unifi_username'],
Crypto::decrypt($site['unifi_password']),
$site['site_id'],
$site['ssl_verify'] ?? 0
);
echo json_encode([
'success' => $test === true,
'message' => $test === true ? __('site_test_ok') : __('site_test_fail') . ': ' . $test,
]);
exit;
}
// Edit site // Edit site
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) {
@ -31,18 +58,27 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
$username = trim($_POST['username']); $username = trim($_POST['username']);
$password = $_POST['password']; $password = $_POST['password'];
$publicAccess = isset($_POST['public_access']) ? 1 : 0; $publicAccess = isset($_POST['public_access']) ? 1 : 0;
$sslVerify = isset($_POST['ssl_verify']) ? 1 : 0;
if (empty($name)||empty($siteIdStr)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all')); if (empty($name)||empty($siteIdStr)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
if (!empty($password)) { if (!empty($password)) {
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr); $test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr,$sslVerify);
if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test); if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=? WHERE id=?", $db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=?,ssl_verify=? WHERE id=?",
[$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$siteId]); [$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify,$siteId]);
} else { } else {
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=? WHERE id=?", // Auch ohne Passwortaenderung testen (mit gespeichertem Passwort)
[$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$siteId]); // sonst fallen Tippfehler in URL/Username erst beim naechsten Voucher auf.
$stored = $db->fetchOne("SELECT unifi_password FROM sites WHERE id=?", [$siteId]);
if (!$stored) throw new Exception(__('error_site_not_found'));
$test = UniFiController::testConnection($controllerUrl,$username,Crypto::decrypt($stored['unifi_password']),$siteIdStr,$sslVerify);
if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=?,ssl_verify=? WHERE id=?",
[$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$sslVerify,$siteId]);
} }
$auth->writeAuditLog($_SESSION['user_id'],'site_edit','site',$siteId,"Site {$name} aktualisiert"); $auth->writeAuditLog($_SESSION['user_id'],'site_edit','site',$siteId,"Site {$name} aktualisiert");
$success = __('sites_updated'); flashSet(__('sites_updated'));
header('Location: sites.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); } } catch (Exception $e) { $error = $e->getMessage(); }
} }
} }
@ -59,38 +95,49 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_site'])) {
$username = trim($_POST['username']); $username = trim($_POST['username']);
$password = $_POST['password']; $password = $_POST['password'];
$publicAccess = isset($_POST['public_access']) ? 1 : 0; $publicAccess = isset($_POST['public_access']) ? 1 : 0;
$sslVerify = isset($_POST['ssl_verify']) ? 1 : 0;
if (empty($name)||empty($siteId)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all')); if (empty($name)||empty($siteId)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId); $test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId,$sslVerify);
if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test); if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
$newId = $db->execute("INSERT INTO sites (name,site_id,unifi_controller_url,unifi_username,unifi_password,public_access) VALUES (?,?,?,?,?,?)", $newId = $db->execute("INSERT INTO sites (name,site_id,unifi_controller_url,unifi_username,unifi_password,public_access,ssl_verify) VALUES (?,?,?,?,?,?,?)",
[$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess]); [$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify]);
$auth->writeAuditLog($_SESSION['user_id'],'site_create','site',$newId,"Site {$name} erstellt"); $auth->writeAuditLog($_SESSION['user_id'],'site_create','site',$newId,"Site {$name} erstellt");
$success = __('sites_added'); flashSet(__('sites_added'));
header('Location: sites.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); } } catch (Exception $e) { $error = $e->getMessage(); }
} }
} }
// Delete site // Delete site (POST + PRG)
if (isset($_GET['delete']) && isset($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_site'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$delId = (int)$_GET['delete']; $delId = (int)$_POST['delete_site'];
$db->query("DELETE FROM sites WHERE id=?", [$delId]); $db->query("DELETE FROM sites WHERE id=?", [$delId]);
$auth->writeAuditLog($_SESSION['user_id'],'site_delete','site',$delId,'Site gelöscht'); $auth->writeAuditLog($_SESSION['user_id'],'site_delete','site',$delId,'Site gelöscht');
$success = __('sites_deleted'); flashSet(__('sites_deleted'));
header('Location: sites.php');
exit;
} else { $error = __('error_csrf'); } } else { $error = __('error_csrf'); }
} }
// Toggle site // Toggle site (POST + PRG)
if (isset($_GET['toggle']) && isset($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_site'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_GET['toggle']]); $site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_POST['toggle_site']]);
if ($site) { if ($site) {
$db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_GET['toggle']]); $db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_POST['toggle_site']]);
$success = 'Site-Status aktualisiert!'; flashSet(__('sites_status_updated'));
header('Location: sites.php');
exit;
} }
} else { $error = __('error_csrf'); } } else { $error = __('error_csrf'); }
} }
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$sites = $db->fetchAll("SELECT * FROM sites ORDER BY name"); $sites = $db->fetchAll("SELECT * FROM sites ORDER BY name");
$currentPage = 'sites'; $currentPage = 'sites';
?> ?>
@ -101,24 +148,61 @@ $currentPage = 'sites';
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('sites_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title><?= __('sites_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
.sites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 18px; }
.site-card { background: var(--bg-card); border: 2px solid var(--border-color); border-radius: 14px; padding: 20px; transition: border-color .2s, box-shadow .2s; }
.site-card:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(102,126,234,.15); }
.site-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 14px; }
.site-name { font-size: 17px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.site-id-label { font-size: 12px; color: var(--text-muted); font-family: monospace; }
.site-info { margin: 14px 0; font-size: 13px; color: var(--text-secondary); }
.site-info-item { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
.site-actions { display: flex; gap: 7px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color); flex-wrap: wrap; }
.btn { padding: 8px 15px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
.btn-secondary:hover { background: var(--border-color); }
.btn-danger { background: var(--danger); color: white; }
.btn-success { background: var(--success); color: white; }
.btn-sm { padding: 6px 11px; font-size: 12px; }
.modal { display: none; position: fixed; inset: 0; background: var(--modal-overlay); z-index: 1000; align-items: center; justify-content: center; }
.modal.active { display: flex; }
.modal-content { background: var(--bg-card); border-radius: 14px; max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border-color); }
.modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.modal-title { font-size: 19px; font-weight: 600; color: var(--text-primary); }
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
.modal-body { padding: 22px 25px; }
.form-group { margin-bottom: 17px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
input[type="text"], input[type="password"], input[type="url"] { width: 100%; padding: 11px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color .2s; }
input:focus { outline: none; border-color: var(--accent); }
.checkbox-group { display: flex; align-items: center; gap: 10px; }
.checkbox-group input { width: auto; }
.empty-card { background: var(--bg-card); border-radius: 14px; border: 1px solid var(--border-color); padding: 60px 20px; text-align: center; color: var(--text-muted); }
@media(max-width:768px){ .main-content{ margin-left:0!important; } .form-grid{ grid-template-columns:1fr; } }
</style>
<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" aria-hidden="true"></i> <?= __('sites_add') ?> <i class="fas fa-plus"></i> <?= __('sites_add') ?>
</button> </button>
</div> </div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div> <div class="alert alert-error"><i class="fas fa-exclamation-circle"></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" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div> <div class="alert alert-success"><i class="fas fa-check-circle"></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;" aria-hidden="true"></i> <i class="fas fa-map-marker-alt" style="font-size:48px;margin-bottom:20px;opacity:.3;display:block;"></i>
<p><?= __('sites_none') ?></p> <p><?= __('sites_none') ?></p>
</div> </div>
<?php else: ?> <?php else: ?>
@ -132,51 +216,62 @@ $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" aria-hidden="true"></i> <?= __('status_active') ?></span> <span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span>
<?php else: ?> <?php else: ?>
<span class="badge badge-warning"><i class="fas fa-pause" aria-hidden="true"></i> <?= __('status_inactive') ?></span> <span class="badge badge-warning"><i class="fas fa-pause"></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" aria-hidden="true"></i> <?= __('status_public') ?></span> <span class="badge badge-info"><i class="fas fa-globe"></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;" aria-hidden="true"></i> <i class="fas fa-server" style="color:var(--accent);width:16px;"></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;" aria-hidden="true"></i> <i class="fas fa-user" style="color:var(--accent);width:16px;"></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;" aria-hidden="true"></i> <i class="fas fa-clock" style="color:var(--text-muted);width:16px;"></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'] ?>, <?= (int)($site['ssl_verify'] ?? 0) ?>)"
class="btn btn-secondary btn-sm"> class="btn btn-secondary btn-sm">
<i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?> <i class="fas fa-edit"></i> <?= __('btn_edit') ?>
</button> </button>
<a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <form method="post" style="display:inline;">
class="btn btn-secondary btn-sm"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i> <input type="hidden" name="toggle_site" value="<?= $site['id'] ?>">
<button type="submit" class="btn btn-secondary btn-sm">
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>"></i>
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?> <?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
</a> </button>
<a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>" </form>
class="btn btn-danger-soft btn-sm" <button type="button" class="btn btn-secondary btn-sm" onclick="testSite(<?= $site['id'] ?>, this)"
onclick="return confirm('<?= __('js_confirm_delete_site') ?>')"> title="<?= __('site_test_btn') ?>" aria-label="<?= __('site_test_btn') ?>">
<i class="fas fa-trash" aria-hidden="true"></i> <i class="fas fa-plug"></i>
</a> </button>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= addslashes(__('confirm_delete_site')) ?>')">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="delete_site" value="<?= $site['id'] ?>">
<button type="submit" class="btn btn-danger btn-sm"
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
<i class="fas fa-trash"></i>
</button>
</form>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</main> </div><!-- /main-content -->
<!-- Add Site Modal --> <!-- Add Site Modal -->
<div id="addSiteModal" class="modal"> <div id="addSiteModal" class="modal">
@ -196,7 +291,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;"><?= __('sites_id_hint') ?></small> <small style="color:var(--text-muted);font-size:12px;">Zu finden in der UniFi Controller URL</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label><?= __('sites_controller') ?></label> <label><?= __('sites_controller') ?></label>
@ -217,9 +312,14 @@ $currentPage = 'sites';
<input type="checkbox" id="add_public" name="public_access"> <input type="checkbox" id="add_public" name="public_access">
<label for="add_public" style="margin:0;"><?= __('sites_public') ?></label> <label for="add_public" style="margin:0;"><?= __('sites_public') ?></label>
</div> </div>
<div class="form-group checkbox-group">
<input type="checkbox" id="add_ssl_verify" name="ssl_verify">
<label for="add_ssl_verify" style="margin:0;"><?= __('sites_ssl_verify') ?></label>
</div>
<small style="color:var(--text-muted);font-size:12px;display:block;margin-top:-10px;margin-bottom:14px;"><?= __('sites_ssl_verify_hint') ?></small>
<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" aria-hidden="true"></i> <?= __('sites_add') ?> <i class="fas fa-save"></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 +359,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="<?= __('sites_pw_unchanged') ?>"> <input type="password" id="edit_password" name="password" placeholder="Leer lassen = nicht ändern">
<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>
@ -267,9 +367,14 @@ $currentPage = 'sites';
<input type="checkbox" id="edit_public_access" name="public_access"> <input type="checkbox" id="edit_public_access" name="public_access">
<label for="edit_public_access" style="margin:0;"><?= __('sites_public') ?></label> <label for="edit_public_access" style="margin:0;"><?= __('sites_public') ?></label>
</div> </div>
<div class="form-group checkbox-group">
<input type="checkbox" id="edit_ssl_verify" name="ssl_verify">
<label for="edit_ssl_verify" style="margin:0;"><?= __('sites_ssl_verify') ?></label>
</div>
<small style="color:var(--text-muted);font-size:12px;display:block;margin-top:-10px;margin-bottom:14px;"><?= __('sites_ssl_verify_hint') ?></small>
<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" aria-hidden="true"></i> <?= __('btn_save') ?> <i class="fas fa-save"></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,13 +383,13 @@ $currentPage = 'sites';
</div> </div>
</div> </div>
<div id="toast-container" role="status" aria-live="polite"></div> <div id="toast-container"></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'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); } function closeModal(id) { document.getElementById(id).classList.remove('active'); }
function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAccess) { function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAccess, sslVerify) {
document.getElementById('edit_site_id').value = id; document.getElementById('edit_site_id').value = id;
document.getElementById('edit_name').value = name; document.getElementById('edit_name').value = name;
document.getElementById('edit_site_id_str').value = siteIdStr; document.getElementById('edit_site_id_str').value = siteIdStr;
@ -292,18 +397,19 @@ function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAcces
document.getElementById('edit_username').value = username; document.getElementById('edit_username').value = username;
document.getElementById('edit_password').value = ''; document.getElementById('edit_password').value = '';
document.getElementById('edit_public_access').checked = publicAccess == 1; document.getElementById('edit_public_access').checked = publicAccess == 1;
document.getElementById('edit_ssl_verify').checked = sslVerify == 1;
document.getElementById('editSiteModal').classList.add('active'); document.getElementById('editSiteModal').classList.add('active');
} }
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" aria-hidden="true"></i> <?= addslashes(__('sites_testing')) ?>'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></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" aria-hidden="true"></i> <?= addslashes(__('sites_testing')) ?>'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>';
}); });
['addSiteModal','editSiteModal'].forEach(id => { ['addSiteModal','editSiteModal'].forEach(id => {
@ -311,6 +417,23 @@ document.getElementById('editSiteForm').addEventListener('submit', function() {
if (e.target === this) closeModal(id); if (e.target === this) closeModal(id);
}); });
}); });
async function testSite(siteId, btn) {
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const fd = new FormData();
fd.append('ajax_test_site', siteId);
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
const result = await fetch('sites.php', { method: 'POST', body: fd }).then(r => r.json());
showToast(result.success ? 'success' : 'error', '<?= addslashes(__('site_test_btn')) ?>', result.message);
} catch (e) {
showToast('error', '<?= addslashes(__('site_test_btn')) ?>', e.message);
}
btn.disabled = false;
btn.innerHTML = original;
}
</script> </script>
</body> </body>
</html> </html>

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/I18n.php'; require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
@ -41,7 +42,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) {
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']] [$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
); );
$success = __('templates_added'); flashSet(__('templates_added'));
header('Location: templates.php');
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = $e->getMessage(); $error = $e->getMessage();
} }
@ -71,23 +74,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?", "UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?",
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id] [$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id]
); );
$success = __('templates_updated'); flashSet(__('templates_updated'));
header('Location: templates.php');
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = $e->getMessage(); $error = $e->getMessage();
} }
} }
} }
// Profil löschen // Profil löschen (POST + PRG)
if (isset($_GET['delete']) && isset($_GET['token'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_template'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_GET['delete']]); $db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_POST['delete_template']]);
$success = __('templates_deleted'); flashSet(__('templates_deleted'));
header('Location: templates.php');
exit;
} else { } else {
$error = __('error_csrf'); $error = __('error_csrf');
} }
} }
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$templates = $db->fetchAll("SELECT t.*, u.name as creator FROM voucher_templates t LEFT JOIN users u ON t.created_by = u.id ORDER BY t.is_active DESC, t.name"); $templates = $db->fetchAll("SELECT t.*, u.name as creator FROM voucher_templates t LEFT JOIN users u ON t.created_by = u.id ORDER BY t.is_active DESC, t.name");
$currentPage = 'templates'; $currentPage = 'templates';
@ -100,22 +111,56 @@ $adminBase = '';
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('templates_title') ?> - <?= htmlspecialchars($appTitle) ?></title> <title><?= __('templates_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; flex-wrap: wrap; gap: 15px; }
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); }
.card { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 25px; }
.card-header { padding: 20px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.card-title { font-size: 18px; font-weight: 600; color: var(--text-primary); }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 12px 15px; background: var(--bg-table-head); color: var(--text-secondary); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.table td { padding: 14px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: var(--bg-hover); }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: white; }
.btn-small { padding: 6px 12px; font-size: 12px; }
.modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--modal-overlay); z-index: 500; align-items: center; justify-content: center; }
.modal.active { display: flex; }
.modal-content { background: var(--bg-card); border-radius: 15px; max-width: 520px; width: 90%; max-height: 90vh; overflow-y: auto; }
.modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.modal-title { font-size: 18px; font-weight: 600; color: var(--text-primary); }
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
.modal-body { padding: 25px; }
.form-group { margin-bottom: 18px; }
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
input[type="text"], input[type="number"], textarea, select { width: 100%; padding: 11px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color 0.2s; font-family: inherit; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
.checkbox-group { display: flex; align-items: center; gap: 10px; }
.checkbox-group input { width: auto; accent-color: var(--accent); }
.help-text { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
.empty-state i { font-size: 48px; margin-bottom: 20px; opacity: 0.3; display: block; }
.duration-badge { display: inline-flex; align-items: center; gap: 5px; background: var(--bg-hover); padding: 3px 10px; border-radius: 20px; font-size: 12px; color: var(--text-secondary); }
</style>
</head>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title"><?= __('templates_title') ?></h1> <h1 class="page-title"><?= __('templates_title') ?></h1>
<p class="page-subtitle"><?= __('templates_subtitle') ?></p> <p style="color: var(--text-muted); font-size: 14px; margin-top: 5px;"><?= __('templates_subtitle') ?></p>
</div> </div>
<button onclick="openAddModal()" class="btn btn-primary"> <button onclick="openAddModal()" class="btn btn-primary">
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('templates_add') ?> <i class="fas fa-plus"></i> <?= __('templates_add') ?>
</button> </button>
</div> </div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div> <div class="alert alert-error"><i class="fas fa-exclamation-circle"></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" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div> <div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
<?php endif; ?> <?php endif; ?>
<div class="card"> <div class="card">
@ -125,15 +170,14 @@ $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" aria-hidden="true"></i> <i class="fas fa-layer-group"></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" aria-hidden="true"></i> <?= __('templates_add') ?></button> <button onclick="openAddModal()" class="btn btn-primary" style="margin-top: 20px;"><i class="fas fa-plus"></i> <?= __('templates_add') ?></button>
</div> </div>
<?php else: ?> <?php else: ?>
<div style="overflow-x: auto;"> <div style="overflow-x: auto;">
<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 +191,11 @@ $adminBase = '';
<tbody> <tbody>
<?php foreach ($templates as $t): ?> <?php foreach ($templates as $t): ?>
<tr> <tr>
<td data-label="<?= __('templates_name') ?>"><strong><?= htmlspecialchars($t['name']) ?></strong></td> <td><strong><?= htmlspecialchars($t['name']) ?></strong></td>
<td data-label="<?= __('templates_devices') ?>"> <td>
<span class="duration-badge"><i class="fas fa-mobile-alt" aria-hidden="true"></i> <?= (int)$t['max_uses'] ?></span> <span class="duration-badge"><i class="fas fa-mobile-alt"></i> <?= (int)$t['max_uses'] ?></span>
</td> </td>
<td data-label="<?= __('templates_duration') ?>"> <td>
<?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,39 +206,44 @@ $adminBase = '';
$durLabel = $m . ' Min.'; $durLabel = $m . ' Min.';
} }
?> ?>
<span class="duration-badge"><i class="fas fa-clock" aria-hidden="true"></i> <?= $durLabel ?></span> <span class="duration-badge"><i class="fas fa-clock"></i> <?= $durLabel ?></span>
</td> </td>
<td data-label="<?= __('templates_desc') ?>" style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td> <td style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
<td data-label="<?= __('label_status') ?>"> <td>
<?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 data-label="<?= __('label_actions') ?>"> <td>
<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" aria-hidden="true"></i></button> class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <form method="post" style="display:inline;"
onclick="return confirm('<?= __('js_confirm_delete_template') ?>')" onsubmit="return confirm('<?= addslashes(__('confirm_delete_template')) ?>')">
class="btn btn-danger-soft btn-small"><i class="fas fa-trash" aria-hidden="true"></i></a> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="delete_template" value="<?= $t['id'] ?>">
<button type="submit" class="btn btn-danger btn-small"
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
<i class="fas fa-trash"></i>
</button>
</form>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</main> </div><!-- main-content -->
<!-- Modal: Hinzufügen --> <!-- Modal: Hinzufügen -->
<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);" aria-hidden="true"></i> <?= __('templates_add') ?></h2> <h2 class="modal-title"><i class="fas fa-plus-circle" style="color: var(--accent);"></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 +259,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"><?= __('templates_minutes_hint') ?></div> <div class="help-text">480 = 8 Stunden</div>
</div> </div>
</div> </div>
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="<?= __('templates_desc_placeholder') ?>"></textarea></div> <div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="Kurze Beschreibung für Ihr Team"></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" aria-hidden="true"></i> <?= __('btn_save') ?></button> <button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></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 +281,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);" aria-hidden="true"></i> <?= __('templates_edit') ?></h2> <h2 class="modal-title"><i class="fas fa-edit" style="color: var(--accent);"></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 +310,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" aria-hidden="true"></i> <?= __('btn_save') ?></button> <button type="submit" name="edit_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></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

@ -8,6 +8,7 @@ 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/Helpers.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
@ -20,10 +21,11 @@ I18n::init();
$error = ''; $error = '';
$success = ''; $success = '';
// Send password reset link // Send password reset link (POST statt GET: kein CSRF-Token in URLs/Referrern,
if (isset($_GET['send_reset']) && isset($_GET['token'])) { // keine versehentliche Ausloesung durch Link-Prefetching)
if ($auth->validateCsrfToken($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['send_reset'])) {
$targetUser = $db->fetchOne("SELECT * FROM users WHERE id=? AND is_active=1 AND password_hash IS NOT NULL", [(int)$_GET['send_reset']]); if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$targetUser = $db->fetchOne("SELECT * FROM users WHERE id=? AND is_active=1 AND password_hash IS NOT NULL", [(int)$_POST['send_reset']]);
if ($targetUser) { if ($targetUser) {
try { try {
$db->execute("DELETE FROM password_reset_tokens WHERE user_id=?", [$targetUser['id']]); $db->execute("DELETE FROM password_reset_tokens WHERE user_id=?", [$targetUser['id']]);
@ -39,12 +41,14 @@ if (isset($_GET['send_reset']) && isset($_GET['token'])) {
$resetUrl = $systemUrl . '/reset_password.php?token=' . $token; $resetUrl = $systemUrl . '/reset_password.php?token=' . $token;
$mailer->sendRaw($targetUser['email'], $appTitle . ' Passwort zurücksetzen', $mailer->sendRaw($targetUser['email'], $appTitle . ' Passwort zurücksetzen',
"Hallo {$targetUser['name']},\n\nEin Administrator hat für Sie einen Passwort-Reset-Link erstellt:\n\n{$resetUrl}\n\n(Gültig für 1 Stunde)\n\n{$appTitle}"); "Hallo {$targetUser['name']},\n\nEin Administrator hat für Sie einen Passwort-Reset-Link erstellt:\n\n{$resetUrl}\n\n(Gültig für 1 Stunde)\n\n{$appTitle}");
$success = 'Passwort-Reset-Link wurde an ' . htmlspecialchars($targetUser['email']) . ' gesendet.'; flashSet(__('reset_link_sent', ['email' => $targetUser['email']]));
header('Location: users.php');
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = 'Fehler beim Senden: ' . $e->getMessage(); $error = 'Fehler beim Senden: ' . $e->getMessage();
} }
} else { } else {
$error = 'Benutzer nicht gefunden oder kein lokales Passwort.'; $error = __('reset_link_failed');
} }
} else { } else {
$error = __('error_csrf'); $error = __('error_csrf');
@ -60,6 +64,11 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
$userId = (int)$_POST['user_id']; $userId = (int)$_POST['user_id'];
$isAdmin = isset($_POST['is_admin']) ? 1 : 0; $isAdmin = isset($_POST['is_admin']) ? 1 : 0;
$siteIds = $_POST['site_ids'] ?? []; $siteIds = $_POST['site_ids'] ?? [];
// Lockout-Schutz: Der letzte Weg ins Admin-Panel darf nicht
// versehentlich gekappt werden.
if ($userId === (int)$_SESSION['user_id'] && !$isAdmin) {
throw new Exception(__('error_self_demote'));
}
$oldUser = $db->fetchOne("SELECT * FROM users WHERE id=?", [$userId]); $oldUser = $db->fetchOne("SELECT * FROM users WHERE id=?", [$userId]);
$oldSites= $db->fetchAll("SELECT s.name FROM sites s INNER JOIN user_site_access usa ON s.id=usa.site_id WHERE usa.user_id=?", [$userId]); $oldSites= $db->fetchAll("SELECT s.name FROM sites s INNER JOIN user_site_access usa ON s.id=usa.site_id WHERE usa.user_id=?", [$userId]);
$db->query("UPDATE users SET is_admin=? WHERE id=?", [$isAdmin, $userId]); $db->query("UPDATE users SET is_admin=? WHERE id=?", [$isAdmin, $userId]);
@ -83,8 +92,10 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
if (!empty($removedSites)) $changes[] = 'Zugriff entfernt von: ' . implode(', ', $removedSites); if (!empty($removedSites)) $changes[] = 'Zugriff entfernt von: ' . implode(', ', $removedSites);
if ($isAdmin && !$oldUser['is_admin']) $changes[] = 'Sie haben nun Zugriff auf alle Sites'; if ($isAdmin && !$oldUser['is_admin']) $changes[] = 'Sie haben nun Zugriff auf alle Sites';
if (!empty($changes)) $mailer->sendUserNotification($oldUser['email'], $oldUser['name'], $changes); if (!empty($changes)) $mailer->sendUserNotification($oldUser['email'], $oldUser['name'], $changes);
$success = __('users_updated') . (!empty($changes) ? ' '.__('users_notified') : '');
$auth->writeAuditLog($_SESSION['user_id'], 'user_edit', 'user', $userId, implode('; ', $changes) ?: 'Keine Änderungen'); $auth->writeAuditLog($_SESSION['user_id'], 'user_edit', 'user', $userId, implode('; ', $changes) ?: 'Keine Änderungen');
flashSet(__('users_updated') . (!empty($changes) ? ' '.__('users_notified') : ''));
header('Location: users.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); } } catch (Exception $e) { $error = $e->getMessage(); }
} }
} }
@ -103,59 +114,72 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_user'])) {
if (empty($email)||empty($name)||empty($password)) throw new Exception(__('error_fill_all')); if (empty($email)||empty($name)||empty($password)) throw new Exception(__('error_fill_all'));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid')); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
if (strlen($password) < 8) throw new Exception(__('settings_pw_minlength')); if (strlen($password) < 8) throw new Exception(__('settings_pw_minlength'));
if ($db->fetchOne("SELECT id FROM users WHERE email=?", [$email])) throw new Exception('E-Mail bereits vorhanden'); if ($db->fetchOne("SELECT id FROM users WHERE email=?", [$email])) throw new Exception(__('error_email_exists'));
$userId = $auth->registerUser($email, $name, $password, $isAdmin); $userId = $auth->registerUser($email, $name, $password, $isAdmin);
if (!$userId) throw new Exception('Benutzer konnte nicht erstellt werden'); if (!$userId) throw new Exception(__('error_user_create'));
if (!$isAdmin && !empty($siteIds)) { if (!$isAdmin && !empty($siteIds)) {
foreach ($siteIds as $siteId) { foreach ($siteIds as $siteId) {
$db->execute("INSERT INTO user_site_access (user_id, site_id) VALUES (?,?)", [$userId, $siteId]); $db->execute("INSERT INTO user_site_access (user_id, site_id) VALUES (?,?)", [$userId, $siteId]);
} }
} }
$auth->writeAuditLog($_SESSION['user_id'], 'user_create', 'user', $userId, "Benutzer {$name} erstellt"); $auth->writeAuditLog($_SESSION['user_id'], 'user_create', 'user', $userId, "Benutzer {$name} erstellt");
$success = __('users_added'); flashSet(__('users_added'));
header('Location: users.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); } } catch (Exception $e) { $error = $e->getMessage(); }
} }
} }
// Delete user // Delete user (POST + PRG)
if (isset($_GET['delete']) && isset($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_user'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$deleteId = (int)$_GET['delete']; $deleteId = (int)$_POST['delete_user'];
if ($deleteId === (int)$_SESSION['user_id']) { if ($deleteId === (int)$_SESSION['user_id']) {
$error = 'Sie können sich nicht selbst löschen'; $error = __('error_self_delete');
} else { } else {
$db->query("DELETE FROM users WHERE id=?", [$deleteId]); $db->query("DELETE FROM users WHERE id=?", [$deleteId]);
$auth->writeAuditLog($_SESSION['user_id'], 'user_delete', 'user', $deleteId, 'Benutzer gelöscht'); $auth->writeAuditLog($_SESSION['user_id'], 'user_delete', 'user', $deleteId, 'Benutzer gelöscht');
$success = __('users_deleted'); flashSet(__('users_deleted'));
header('Location: users.php');
exit;
} }
} else { $error = __('error_csrf'); } } else { $error = __('error_csrf'); }
} }
// Toggle user active // Toggle user active (POST + PRG)
if (isset($_GET['toggle']) && isset($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_user'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$toggleId = (int)$_GET['toggle']; $toggleId = (int)$_POST['toggle_user'];
if ($toggleId === (int)$_SESSION['user_id']) { if ($toggleId === (int)$_SESSION['user_id']) {
$error = 'Sie können sich nicht selbst deaktivieren'; $error = __('error_self_deactivate');
} else { } else {
$user = $db->fetchOne("SELECT is_active FROM users WHERE id=?", [$toggleId]); $user = $db->fetchOne("SELECT is_active FROM users WHERE id=?", [$toggleId]);
if ($user) { if ($user) {
$newStatus = $user['is_active'] ? 0 : 1; $newStatus = $user['is_active'] ? 0 : 1;
$db->query("UPDATE users SET is_active=? WHERE id=?", [$newStatus, $toggleId]); $db->query("UPDATE users SET is_active=? WHERE id=?", [$newStatus, $toggleId]);
$success = 'Benutzer-Status aktualisiert!'; flashSet(__('users_status_updated'));
header('Location: users.php');
exit;
} }
} }
} else { $error = __('error_csrf'); } } else { $error = __('error_csrf'); }
} }
// 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator) // 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator)
if (isset($_GET['reset_2fa']) && isset($_GET['token'])) { // POST + PRG wie die uebrigen state-aendernden Aktionen
if ($auth->validateCsrfToken($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['reset_2fa'])) {
$auth->disableTotp((int)$_GET['reset_2fa']); if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$success = '2FA des Benutzers wurde zurückgesetzt.'; $auth->disableTotp((int)$_POST['reset_2fa']);
flashSet('2FA des Benutzers wurde zurückgesetzt.');
header('Location: users.php');
exit;
} else { $error = __('error_csrf'); } } else { $error = __('error_csrf'); }
} }
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$users = $db->fetchAll("SELECT * FROM users ORDER BY name"); $users = $db->fetchAll("SELECT * FROM users ORDER BY name");
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name"); $sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
$userSiteAccess = []; $userSiteAccess = [];
@ -171,30 +195,67 @@ $currentPage = 'users';
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('users_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title><?= __('users_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 24px; }
.card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); }
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 12px 15px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
.table td { padding: 13px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
.table tr:last-child td { border-bottom: none; }
.table tr:hover { background: var(--bg-hover); }
.btn { padding: 8px 16px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
.btn-secondary:hover { background: var(--border-color); }
.btn-danger { background: var(--danger); color: white; }
.btn-warning { background: var(--warning); color: #333; }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.modal { display: none; position: fixed; inset: 0; background: var(--modal-overlay); z-index: 1000; align-items: center; justify-content: center; }
.modal.active { display: flex; }
.modal-content { background: var(--bg-card); border-radius: 14px; max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border-color); }
.modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.modal-title { font-size: 19px; font-weight: 600; color: var(--text-primary); }
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
.modal-body { padding: 22px 25px; }
.form-group { margin-bottom: 18px; }
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
input[type="text"], input[type="email"], input[type="password"] { width: 100%; padding: 11px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color .2s; }
input:focus { outline: none; border-color: var(--accent); }
.checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.checkbox-group input { width: auto; }
.site-selection { border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; max-height: 180px; overflow-y: auto; background: var(--bg-hover); }
.empty-state { text-align: center; padding: 50px 20px; color: var(--text-muted); }
.empty-state i { font-size: 40px; margin-bottom: 15px; opacity: .3; display: block; }
.action-btns { display: flex; gap: 5px; flex-wrap: wrap; }
@media(max-width:768px){ .main-content{ margin-left:0!important; } .table th:nth-child(5),.table td:nth-child(5){ display:none; } }
</style>
<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" aria-hidden="true"></i> <?= __('users_add') ?> <i class="fas fa-plus"></i> <?= __('users_add') ?>
</button> </button>
</div> </div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div> <div class="alert alert-error"><i class="fas fa-exclamation-circle"></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" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div> <div class="alert alert-success"><i class="fas fa-check-circle"></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" aria-hidden="true"></i><p><?= __('users_none_found') ?></p></div> <div class="empty-state"><i class="fas fa-users"></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"> <table class="table">
<table class="table table-stack">
<thead> <thead>
<tr> <tr>
<th><?= __('label_name') ?></th> <th><?= __('label_name') ?></th>
@ -210,28 +271,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 data-label="<?= __('label_name') ?>"> <td>
<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 data-label="<?= __('label_email') ?>"><?= htmlspecialchars($user['email']) ?></td> <td><?= htmlspecialchars($user['email']) ?></td>
<td data-label="<?= __('label_role') ?>"> <td>
<?php if ($user['is_admin']): ?> <?php if ($user['is_admin']): ?>
<span class="badge badge-danger"><i class="fas fa-crown" aria-hidden="true"></i> <?= __('status_admin') ?></span> <span class="badge badge-danger"><i class="fas fa-crown"></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 data-label="<?= __('label_status') ?>"> <td>
<?php if ($user['is_active']): ?> <?php if ($user['is_active']): ?>
<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_active') ?></span> <span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span>
<?php else: ?> <?php else: ?>
<span class="badge badge-warning"><i class="fas fa-pause" aria-hidden="true"></i> <?= __('status_inactive') ?></span> <span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td data-label="<?= __('users_site_access') ?>"> <td>
<?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 +303,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 data-label="<?= __('users_last_login') ?>" style="font-size:13px;"> <td 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,32 +314,49 @@ $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" aria-hidden="true"></i> <i class="fas fa-edit"></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() ?>" <form method="post" style="display:inline;">
class="btn btn-secondary btn-sm" title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i> <input type="hidden" name="toggle_user" value="<?= $user['id'] ?>">
</a> <button type="submit" class="btn btn-secondary btn-sm"
title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>"
aria-label="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>">
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>"></i>
</button>
</form>
<?php if ($smtpEnabled && !empty($user['password_hash'])): ?> <?php if ($smtpEnabled && !empty($user['password_hash'])): ?>
<a href="?send_reset=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <form method="post" style="display:inline;"
class="btn btn-warning btn-sm" title="<?= __('users_reset_pw') ?>" onsubmit="return confirm('<?= addslashes(__('confirm_send_reset', ['email' => $user['email']])) ?>')">
onclick="return confirm('Passwort-Reset-Link senden an <?= htmlspecialchars($user['email'], ENT_QUOTES) ?>?')"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<i class="fas fa-key" aria-hidden="true"></i> <input type="hidden" name="send_reset" value="<?= $user['id'] ?>">
</a> <button type="submit" class="btn btn-warning btn-sm"
title="<?= __('users_reset_pw') ?>" aria-label="<?= __('users_reset_pw') ?>">
<i class="fas fa-key"></i>
</button>
</form>
<?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() ?>" <form method="post" style="display:inline;"
class="btn btn-secondary btn-sm" title="2FA zurücksetzen" onsubmit="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?')"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<i class="fas fa-user-shield" aria-hidden="true"></i> <input type="hidden" name="reset_2fa" value="<?= $user['id'] ?>">
</a> <button type="submit" class="btn btn-secondary btn-sm"
title="2FA zurücksetzen" aria-label="2FA zurücksetzen">
<i class="fas fa-user-shield"></i>
</button>
</form>
<?php endif; ?> <?php endif; ?>
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <form method="post" style="display:inline;"
class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>" onsubmit="return confirm('<?= addslashes(__('confirm_delete_user')) ?>')">
onclick="return confirm('<?= __('js_confirm_delete_user') ?>')"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<i class="fas fa-trash" aria-hidden="true"></i> <input type="hidden" name="delete_user" value="<?= $user['id'] ?>">
</a> <button type="submit" class="btn btn-danger btn-sm"
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
<i class="fas fa-trash"></i>
</button>
</form>
<?php endif; ?> <?php endif; ?>
</div> </div>
</td> </td>
@ -287,12 +365,11 @@ $currentPage = 'users';
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
</main> </div><!-- /main-content -->
<!-- Add User Modal --> <!-- Add User Modal -->
<div id="addUserModal" class="modal"> <div id="addUserModal" class="modal">
@ -342,7 +419,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" aria-hidden="true"></i> <?= __('users_save') ?> <i class="fas fa-save"></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 +463,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" aria-hidden="true"></i> <?= __('users_save_edit') ?> <i class="fas fa-save"></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 +472,7 @@ $currentPage = 'users';
</div> </div>
</div> </div>
<div id="toast-container" role="status" aria-live="polite"></div> <div id="toast-container"></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

@ -23,13 +23,19 @@ if (isset($_GET['export_csv']) && isset($_GET['site_id'])) {
if (!$site) { http_response_code(404); exit; } if (!$site) { http_response_code(404); exit; }
$rows = $db->fetchAll("SELECT voucher_code,voucher_name,max_uses,expire_minutes,status,used_count,created_at,expires_at FROM vouchers WHERE site_id=? ORDER BY created_at DESC", [$siteId]); $rows = $db->fetchAll("SELECT voucher_code,voucher_name,max_uses,expire_minutes,status,used_count,created_at,expires_at FROM vouchers WHERE site_id=? ORDER BY created_at DESC", [$siteId]);
$filename = 'vouchers_' . preg_replace('/[^a-z0-9]/i','_',$site['name']) . '_' . date('Ymd_His') . '.csv'; $filename = 'vouchers_' . preg_replace('/[^a-z0-9]/i','_',$site['name']) . '_' . date('Ymd_His') . '.csv';
// Schutz vor CSV/Excel-Formula-Injection: Zellen, die mit =, +, -, @ oder
// Tab beginnen (Nutzereingabe voucher_name!), mit Apostroph neutralisieren.
$csvSafe = function ($v) {
$v = (string)$v;
return preg_match('/^[=+\-@\t]/', $v) ? "'" . $v : $v;
};
header('Content-Type: text/csv; charset=UTF-8'); header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output','w'); $out = fopen('php://output','w');
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF)); fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($out,['Code','Name','Max. Geräte','Gültigkeit (Min)','Status','Genutzt','Erstellt','Läuft ab'],';'); fputcsv($out,['Code','Name','Max. Geräte','Gültigkeit (Min)','Status','Genutzt','Erstellt','Läuft ab'],';');
foreach ($rows as $r) { foreach ($rows as $r) {
fputcsv($out,[$r['voucher_code'],$r['voucher_name'],$r['max_uses'],$r['expire_minutes'],$r['status'],$r['used_count'],$r['created_at'],$r['expires_at']??''],';'); fputcsv($out,[$csvSafe($r['voucher_code']),$csvSafe($r['voucher_name']),$r['max_uses'],$r['expire_minutes'],$r['status'],$r['used_count'],$r['created_at'],$r['expires_at']??''],';');
} }
fclose($out); exit; fclose($out); exit;
} }
@ -44,7 +50,7 @@ if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) {
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; } if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
if ($syncFirst) { if ($syncFirst) {
try { try {
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']); $ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id'],$site['ssl_verify'] ?? 0);
$ctrl->syncVouchersToDatabase($db,$siteId); $ctrl->syncVouchersToDatabase($db,$siteId);
$db->execute("INSERT INTO settings (setting_key,setting_value) VALUES ('last_cron_sync',NOW()) ON DUPLICATE KEY UPDATE setting_value=NOW()"); $db->execute("INSERT INTO settings (setting_key,setting_value) VALUES ('last_cron_sync',NOW()) ON DUPLICATE KEY UPDATE setting_value=NOW()");
} catch (Exception $e) { error_log("Sync error: ".$e->getMessage()); } } catch (Exception $e) { error_log("Sync error: ".$e->getMessage()); }
@ -83,12 +89,12 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
$siteId = (int)$_POST['site_id']; $siteId = (int)$_POST['site_id'];
$site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]); $site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]);
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; } if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']); $ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id'],$site['ssl_verify'] ?? 0);
if ($ctrl->deleteVoucher($voucherId)) { if ($ctrl->deleteVoucher($voucherId)) {
$db->execute("DELETE FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$voucherId,$siteId]); $db->execute("DELETE FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$voucherId,$siteId]);
echo json_encode(['success'=>true,'message'=>'Voucher erfolgreich gelöscht!']); echo json_encode(['success'=>true,'message'=>__('voucher_deleted')]);
} else { } else {
echo json_encode(['success'=>false,'message'=>'Voucher konnte nicht gelöscht werden']); echo json_encode(['success'=>false,'message'=>__('voucher_delete_failed')]);
} }
} catch (Exception $e) { } catch (Exception $e) {
echo json_encode(['success'=>false,'message'=>'Fehler: '.$e->getMessage()]); echo json_encode(['success'=>false,'message'=>'Fehler: '.$e->getMessage()]);
@ -130,22 +136,77 @@ $currentPage = 'vouchers';
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('vouchers_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title><?= __('vouchers_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.page-header { margin-bottom: 28px; }
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 12px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
.live-indicator { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--success); }
.live-indicator .dot { width: 8px; height: 8px; background: var(--success); border-radius: 50%; animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.5} }
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); margin-bottom: 22px; overflow: hidden; }
.card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.card-body { padding: 22px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px,1fr)); gap: 16px; margin-bottom: 22px; }
.stat-card { background: var(--bg-card); padding: 18px; border-radius: 12px; box-shadow: 0 2px 8px var(--shadow); border: 1px solid var(--border-color); }
.stat-label { color: var(--text-muted); font-size: 13px; margin-bottom: 6px; }
.stat-value { font-size: 28px; font-weight: 700; color: var(--text-primary); }
.stat-value.valid { color: var(--success); }
.stat-value.used { color: var(--warning); }
.stat-value.expired { color: var(--danger); }
.site-selector { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.site-selector select { padding: 10px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; min-width: 220px; cursor: pointer; background: var(--bg-input); color: var(--text-primary); }
.site-selector select:focus { outline: none; border-color: var(--accent); }
.search-bar { padding: 10px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); min-width: 200px; }
.search-bar:focus { outline: none; border-color: var(--accent); }
.btn { padding: 9px 18px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
.btn-secondary:hover { background: var(--border-color); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { opacity: .88; }
.btn-success { background: var(--success); color: white; }
.btn-sm { padding: 5px 11px; font-size: 12px; }
.btn:disabled { opacity: .55; cursor: not-allowed; }
.table-container { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 11px 14px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; white-space: nowrap; }
.table td { padding: 13px 14px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 13px; }
.table tr:last-child td { border-bottom: none; }
.table tr:hover { background: var(--bg-hover); }
.table tr.deleting { opacity: .45; pointer-events: none; }
code { background: var(--bg-hover); padding: 4px 8px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; letter-spacing: 1px; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
.empty-state i { font-size: 42px; margin-bottom: 18px; opacity: .3; display: block; }
.loading { display: flex; align-items: center; justify-content: center; padding: 60px; color: var(--text-muted); gap: 12px; }
.loading i { font-size: 22px; animation: spin 1s linear infinite; }
@keyframes spin { from{transform:rotate(0)}to{transform:rotate(360deg)} }
.filter-row { display: flex; gap: 8px; flex-wrap: wrap; }
.filter-btn { padding: 7px 14px; border: 2px solid var(--border-color); background: var(--bg-card); color: var(--text-secondary); border-radius: 8px; cursor: pointer; font-size: 13px; transition: all .2s; }
.filter-btn:hover { border-color: var(--accent); }
.filter-btn.active { border-color: var(--accent); background: var(--accent); color: white; }
.usage-info { display: flex; align-items: center; gap: 6px; }
.usage-bar { width: 50px; height: 5px; background: var(--border-color); border-radius: 3px; overflow: hidden; }
.usage-bar-fill { height: 100%; background: var(--accent); border-radius: 3px; }
.voucher-note { max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.no-sites-warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 12px; padding: 30px; text-align: center; color: #856404; }
@media(max-width:768px){ .main-content{ margin-left:0!important; } }
</style>
<div class="page-header"> <div class="page-header">
<div>
<h1 class="page-title"> <h1 class="page-title">
<?= __('vouchers_title') ?> <?= __('vouchers_title') ?>
<span class="live-indicator"><span class="dot"></span>LIVE</span> <span class="live-indicator"><span class="dot"></span>LIVE</span>
</h1> </h1>
<p class="page-subtitle"><?= __('vouchers_subtitle') ?></p> <p class="page-subtitle"><?= __('vouchers_subtitle') ?></p>
</div> </div>
</div>
<?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;" aria-hidden="true"></i> <i class="fas fa-exclamation-triangle" style="font-size:48px;margin-bottom:15px;opacity:.7;display:block;"></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" aria-hidden="true"></i> <?= __('nav_sites') ?></a> <a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus"></i> <?= __('nav_sites') ?></a>
</div> </div>
<?php else: ?> <?php else: ?>
@ -167,8 +228,8 @@ $currentPage = 'vouchers';
<?php endforeach; ?> <?php endforeach; ?>
</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-success" onclick="loadVouchers(true)" disabled>
<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('btn_refresh') ?> <i class="fas fa-sync-alt"></i> <?= __('btn_refresh') ?>
</button> </button>
</div> </div>
</div> </div>
@ -187,25 +248,23 @@ $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" aria-hidden="true"></i> <?= __('btn_export_csv') ?> <i class="fas fa-download"></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" aria-hidden="true"></i><p><?= __('vouchers_select_hint') ?></p></div> <div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('vouchers_select_hint') ?></p></div>
</div> </div>
</div> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
</main> </div><!-- /main-content -->
<div id="toast-container" role="status" aria-live="polite"></div> <div id="toast-container"></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';
@ -220,7 +279,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" aria-hidden="true"></i><p><?= addslashes(__('vouchers_select_hint')) ?></p></div>`; document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></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;
@ -229,9 +288,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" aria-hidden="true"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`; refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner" aria-hidden="true"></i><span>${syncFirst ? 'Synchronisiere...' : 'Lade...'}</span></div>`; document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner"></i><span>${syncFirst ? '<?= addslashes(__('syncing')) ?>' : '<?= addslashes(__('loading')) ?>'}</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,17 +306,17 @@ async function loadVouchers(syncFirst=false) {
const csvBtn = document.getElementById('csvExportBtn'); const csvBtn = document.getElementById('csvExportBtn');
csvBtn.style.display = 'inline-flex'; csvBtn.style.display = 'inline-flex';
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')) ?>', <?= json_encode(__('vouchers_loaded')) ?>.replace('{count}', result.count));
} else { } else {
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('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></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)" aria-hidden="true"></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)"></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" aria-hidden="true"></i> <?= addslashes(__('btn_refresh')) ?>'; refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= addslashes(__('btn_refresh')) ?>';
} }
function updateStats() { function updateStats() {
@ -295,7 +354,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" aria-hidden="true"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`; document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`;
return; return;
} }
@ -311,7 +370,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 table-stack"> <div class="table-container"><table class="table">
<thead><tr> <thead><tr>
<th><?= __('label_created') ?></th> <th><?= __('label_created') ?></th>
<th><?= __('label_code') ?></th> <th><?= __('label_code') ?></th>
@ -332,21 +391,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" aria-hidden="true"></i> <?= __('status_valid') ?></span>` ? `<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_valid') ?></span>`
: v.status==='used' : v.status==='used'
? `<span class="badge badge-warning"><i class="fas fa-user-check" aria-hidden="true"></i> <?= __('status_used') ?></span>` ? `<span class="badge badge-warning"><i class="fas fa-user-check"></i> <?= __('status_used') ?></span>`
: `<span class="badge badge-danger"><i class="fas fa-times" aria-hidden="true"></i> <?= __('status_expired') ?></span>`; : `<span class="badge badge-danger"><i class="fas fa-times"></i> <?= __('status_expired') ?></span>`;
html += `<tr id="voucher-${v._id}"> html += `<tr id="voucher-${v._id}">
<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><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_code') ?>"><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','<?= __('js_copied') ?>')" title="<?= __('js_copy') ?>" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td> <td><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','<?= addslashes(__('toast_copied')) ?>')" title="<?= addslashes(__('click_to_copy')) ?>" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
<td class="voucher-note" data-label="<?= __('label_note') ?>" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td> <td class="voucher-note" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
<td data-label="<?= __('label_status') ?>">${statusBadge}</td> <td>${statusBadge}</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><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_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>${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_actions') ?>" style="white-space:nowrap;"> <td style="white-space:nowrap;">
<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="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="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> <button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button>
</td> </td>
</tr>`; </tr>`;
}); });
@ -358,11 +417,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" aria-hidden="true"></i></button>`; <button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage-1})" ${currentPage<=1?'disabled':''}><i class="fas fa-chevron-left"></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" aria-hidden="true"></i></button> html += `<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}><i class="fas fa-chevron-right"></i></button>
</div></div>`; </div></div>`;
} }
@ -386,7 +445,7 @@ async function resendVoucher(voucherId, code) {
} }
async function deleteVoucher(voucherId) { async function deleteVoucher(voucherId) {
if (!confirm('<?= __('js_confirm_delete_voucher') ?>')) return; if (!confirm('<?= addslashes(__('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 {

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,7 @@
/* === DARK MODE === /* === DARK MODE === */
Reihenfolge: ausdrueckliche Auswahl des Nutzers > Systemeinstellung. */
(function() { (function() {
const saved = localStorage.getItem('theme'); const saved = localStorage.getItem('theme') || 'light';
// Der Inline-Schnipsel im <head> hat das Theme bereits gesetzt diese document.documentElement.setAttribute('data-theme', saved);
// 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() {
@ -31,12 +17,7 @@ function updateDarkModeBtn() {
const btn = document.getElementById('darkModeBtn'); const btn = document.getElementById('darkModeBtn');
if (!btn) return; if (!btn) return;
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const icon = btn.querySelector('i'); btn.textContent = isDark ? '☀️' : '🌙';
if (icon) {
icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
} else {
btn.textContent = isDark ? '☀' : '☾';
}
btn.title = isDark ? 'Light Mode' : 'Dark Mode'; btn.title = isDark ? 'Light Mode' : 'Dark Mode';
} }
@ -54,6 +35,9 @@ document.addEventListener('DOMContentLoaded', updateDarkModeBtn);
container.id = 'toast-container'; container.id = 'toast-container';
document.body.appendChild(container); document.body.appendChild(container);
} }
// Screenreader ueber neue Toasts informieren
container.setAttribute('role', 'status');
container.setAttribute('aria-live', 'polite');
} }
return container; return container;
} }
@ -61,8 +45,8 @@ document.addEventListener('DOMContentLoaded', updateDarkModeBtn);
const icons = { const icons = {
success: '✓', success: '✓',
error: '✕', error: '✕',
info: 'i', info: '',
warning: '!' warning: ''
}; };
window.showToast = function(type, title, message, duration) { window.showToast = function(type, title, message, duration) {
@ -111,13 +95,21 @@ document.addEventListener('DOMContentLoaded', function() {
if (overlay) overlay.addEventListener('click', closeMobileSidebar); if (overlay) overlay.addEventListener('click', closeMobileSidebar);
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeMobileSidebar(); if (e.key === 'Escape') {
closeMobileSidebar();
// Offene Modals per Esc schliessen (Accessibility)
document.querySelectorAll('.modal.active').forEach(m => m.classList.remove('active'));
}
}); });
}); });
/* === LANGUAGE SWITCHER === */ /* === LANGUAGE SWITCHER === */
function switchLanguage(lang) { function switchLanguage(lang) {
fetch('?set_lang=' + lang, { method: 'GET' }).then(() => location.reload()); // Direkter Navigationswechsel statt fetch+reload: vermeidet den
// "Formular erneut senden?"-Dialog und erhaelt bestehende URL-Parameter.
const url = new URL(window.location.href);
url.searchParams.set('set_lang', lang);
window.location.href = url.toString();
} }
/* === CLIPBOARD === */ /* === CLIPBOARD === */

View file

@ -1,30 +0,0 @@
# 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`).

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.

Binary file not shown.

Binary file not shown.

View file

@ -1,30 +0,0 @@
/* 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');
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,406 +0,0 @@
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"
});

View file

@ -1,21 +0,0 @@
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

@ -1,4 +0,0 @@
/**
* 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

@ -1,4 +0,0 @@
/**
* 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

@ -1,4 +0,0 @@
/**
* 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

@ -1,90 +0,0 @@
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

@ -1,90 +0,0 @@
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

@ -1,4 +0,0 @@
/**
* 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

@ -1 +0,0 @@
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

@ -1 +0,0 @@
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

File diff suppressed because one or more lines are too long

View file

@ -2,17 +2,6 @@
"name": "friloo/unifi-voucher-tool", "name": "friloo/unifi-voucher-tool",
"description": "Webbasiertes WLAN-Voucher-Management für UniFi OS", "description": "Webbasiertes WLAN-Voucher-Management für UniFi OS",
"license": "MIT", "license": "MIT",
"homepage": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool",
"authors": [
{
"name": "Friederich Loheide",
"homepage": "https://loheide.eu"
}
],
"support": {
"issues": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool/issues",
"source": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool"
},
"require": { "require": {
"php": ">=7.4" "php": ">=7.4"
}, },

View file

@ -117,7 +117,8 @@ if (empty($cronToken)) {
exit; exit;
} }
if ($providedToken !== $cronToken) { // hash_equals: zeitkonstanter Vergleich (kein Timing-Seitenkanal)
if (!hash_equals((string)$cronToken, (string)$providedToken)) {
outputResponse([ outputResponse([
'success' => false, 'success' => false,
'message' => 'Ungültiger Token' 'message' => 'Ungültiger Token'
@ -174,7 +175,8 @@ try {
$site['unifi_controller_url'], $site['unifi_controller_url'],
$site['unifi_username'], $site['unifi_username'],
Crypto::decrypt($site['unifi_password']), Crypto::decrypt($site['unifi_password']),
$site['site_id'] $site['site_id'],
$site['ssl_verify'] ?? 0
); );
$stats = $controller->syncVouchersToDatabase($db, $site['id']); $stats = $controller->syncVouchersToDatabase($db, $site['id']);

View file

@ -1,5 +1,12 @@
<?php <?php
// Minimaler Test für Cron-Sync Debugging // Minimaler Test für Cron-Sync Debugging
// Nur fuer angemeldete Admins zugaenglich (leakt sonst DB-Schema & Token-Status)
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
$cronTestAuth = new Auth();
$cronTestAuth->requireAdmin();
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['step' => 1, 'message' => 'PHP läuft']); echo json_encode(['step' => 1, 'message' => 'PHP läuft']);

View file

@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `sites` (
`unifi_password` VARCHAR(255) NOT NULL, `unifi_password` VARCHAR(255) NOT NULL,
`is_active` TINYINT(1) DEFAULT 1, `is_active` TINYINT(1) DEFAULT 1,
`public_access` TINYINT(1) DEFAULT 0, `public_access` TINYINT(1) DEFAULT 0,
`ssl_verify` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_active` (`is_active`) INDEX `idx_active` (`is_active`)
@ -66,34 +67,6 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` (
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `kiosks` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL,
`template_id` INT NULL,
`name` VARCHAR(255) NOT NULL,
`token` VARCHAR(64) NOT NULL,
`headline` VARCHAR(255) NULL,
`subline` VARCHAR(500) NULL,
`logo_url` VARCHAR(500) NULL,
`background_url` VARCHAR(500) NULL,
`bg_overlay` TINYINT NOT NULL DEFAULT 45,
`accent_color` VARCHAR(7) NULL,
`card_style` ENUM('light','dark') NOT NULL DEFAULT 'light',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`daily_limit` INT NOT NULL DEFAULT 100,
`cooldown_seconds` INT NOT NULL DEFAULT 20,
`display_seconds` INT NOT NULL DEFAULT 90,
`last_used_at` TIMESTAMP NULL,
`created_by` INT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uniq_token` (`token`),
INDEX `idx_site` (`site_id`),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`template_id`) REFERENCES `voucher_templates`(`id`) ON DELETE SET NULL,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `api_keys` ( CREATE TABLE IF NOT EXISTS `api_keys` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL, `name` VARCHAR(255) NOT NULL,
@ -121,7 +94,6 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL, `site_id` INT NOT NULL,
`user_id` INT, `user_id` INT,
`kiosk_id` INT NULL,
`voucher_code` VARCHAR(50) NOT NULL, `voucher_code` VARCHAR(50) NOT NULL,
`voucher_name` VARCHAR(255) NOT NULL, `voucher_name` VARCHAR(255) NOT NULL,
`max_uses` INT NOT NULL, `max_uses` INT NOT NULL,
@ -138,8 +110,7 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
INDEX `idx_site` (`site_id`), INDEX `idx_site` (`site_id`),
INDEX `idx_created` (`created_at`), INDEX `idx_created` (`created_at`),
INDEX `idx_unifi_id` (`unifi_voucher_id`), INDEX `idx_unifi_id` (`unifi_voucher_id`),
INDEX `idx_status` (`status`), INDEX `idx_status` (`status`)
INDEX `idx_kiosk` (`kiosk_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `sessions` ( CREATE TABLE IF NOT EXISTS `sessions` (
@ -177,6 +148,16 @@ CREATE TABLE IF NOT EXISTS `audit_log` (
INDEX `idx_created` (`created_at`) INDEX `idx_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- IP-basiertes Request-Throttling (anonyme Voucher-Erstellung, Passwort-Resets)
CREATE TABLE IF NOT EXISTS `request_throttle` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`ip_address` VARCHAR(45) NOT NULL,
`action` VARCHAR(50) NOT NULL,
`weight` INT NOT NULL DEFAULT 1,
`requested_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_throttle` (`action`, `ip_address`, `requested_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `password_reset_tokens` ( CREATE TABLE IF NOT EXISTS `password_reset_tokens` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL, `user_id` INT NOT NULL,

View file

@ -12,9 +12,6 @@ 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
@ -39,4 +36,3 @@ services:
volumes: volumes:
db_data: db_data:
uploads:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1,022 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 938 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 920 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

View file

@ -7,8 +7,8 @@ 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';
require_once __DIR__ . '/includes/Helpers.php';
$auth = new Auth(); $auth = new Auth();
if ($auth->isLoggedIn()) { header('Location: index.php'); exit; } if ($auth->isLoggedIn()) { header('Location: index.php'); exit; }
@ -17,8 +17,18 @@ I18n::init();
$db = Database::getInstance(); $db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', ''); $logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_url', '');
$systemUrl = rtrim($db->getSetting('system_url', ''), '/'); $systemUrl = rtrim($db->getSetting('system_url', ''), '/');
// Fallback: URL automatisch erkennen (wie im Mailer), sonst ist der
// Reset-Link in der E-Mail relativ und damit kaputt.
if (empty($systemUrl)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$systemUrl = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . $scriptPath;
}
$error = ''; $error = '';
$success = ''; $success = '';
@ -35,7 +45,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} else { } else {
$rl[] = $now; $rl[] = $now;
$_SESSION['pwreset_times'] = $rl; $_SESSION['pwreset_times'] = $rl;
$user = $db->fetchOne("SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL", [$email]);
// Zusaetzlich zum Session-Throttle: IP-Rate-Limit gegen Mail-Bombing
// (max. 5 Reset-Anfragen / 15 Min., umgeht Cookie-Loeschen). Bei Limit
// trotzdem die generische Erfolgsmeldung zeigen (keine Information
// darueber preisgeben, ob das Konto existiert).
$resetLimited = throttleHit($db, 'password_reset', 5, 15) === true;
$user = $resetLimited
? null
: $db->fetchOne("SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL", [$email]);
// Always show success (don't reveal whether email exists) // Always show success (don't reveal whether email exists)
if ($user) { if ($user) {
@ -84,12 +103,32 @@ 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>
<?= Ui::head($db) ?> <?php if ($faviconUrl): ?>
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
<?php endif; ?>
<link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.box { background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); max-width: 420px; width: 100%; padding: 45px 40px; text-align: center; }
.logo { max-width: 180px; height: auto; margin-bottom: 25px; }
h1 { color: var(--text-primary); font-size: 26px; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 28px; line-height: 1.5; }
.form-group { margin-bottom: 18px; text-align: left; }
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
input[type="email"] { width: 100%; padding: 13px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; background: var(--bg-input); color: var(--text-primary); transition: border-color 0.2s; }
input:focus { outline: none; border-color: var(--accent); }
.btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 8px; }
.btn:hover { background: var(--accent-hover); transform: translateY(-2px); }
.back-link { display: block; margin-top: 22px; color: var(--accent); text-decoration: none; font-size: 14px; }
.back-link:hover { text-decoration: underline; }
</style>
</head> </head>
<body class="app-body focus-page"> <body>
<div class="focus-card card"> <div class="box">
<?php if ($logoUrl): ?> <?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo"> <img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<?php else: ?> <?php else: ?>
<h1><?= htmlspecialchars($appTitle) ?></h1> <h1><?= htmlspecialchars($appTitle) ?></h1>
<?php endif; ?> <?php endif; ?>
@ -110,11 +149,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<label for="email"><?= __('reset_email_label') ?></label> <label for="email"><?= __('reset_email_label') ?></label>
<input type="email" id="email" name="email" required autofocus placeholder="name@example.com"> <input type="email" id="email" name="email" required autofocus placeholder="name@example.com">
</div> </div>
<button type="submit" class="btn btn-primary btn-lg btn-block"><?= __('reset_send_btn') ?></button> <button type="submit" class="btn"><?= __('reset_send_btn') ?></button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<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> <a href="login.php" class="back-link"><?= __('reset_back_login') ?></a>
</div> </div>
<script src="assets/global.js"></script> <script src="assets/global.js"></script>
</body> </body>

View file

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

View file

@ -5,6 +5,8 @@ require_once __DIR__ . '/Crypto.php';
class Auth { class Auth {
private $db; private $db;
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
private $sessionUser = false;
public function __construct() { public function __construct() {
try { try {
@ -18,6 +20,9 @@ class Auth {
ini_set('session.cookie_httponly', 1); ini_set('session.cookie_httponly', 1);
ini_set('session.use_strict_mode', 1); ini_set('session.use_strict_mode', 1);
ini_set('session.cookie_samesite', 'Lax'); ini_set('session.cookie_samesite', 'Lax');
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
ini_set('session.cookie_secure', 1);
}
// Opt-in: Sessions in der DB ablegen (für "überall abmelden" / Skalierung) // Opt-in: Sessions in der DB ablegen (für "überall abmelden" / Skalierung)
try { try {
@ -243,6 +248,8 @@ class Auth {
private function recordLoginAttempt($ip, $email) { private function recordLoginAttempt($ip, $email) {
try { try {
// Alte Eintraege aufraeumen, damit die Tabelle nicht unbegrenzt waechst
$this->db->query("DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
$this->db->query( $this->db->query(
"INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)", "INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)",
[$ip, $email] [$ip, $email]
@ -309,6 +316,12 @@ class Auth {
// Session setzen // Session setzen
private function setUserSession($user) { private function setUserSession($user) {
// Session-ID nach erfolgreichem Login rotieren (verhindert Session-Fixation)
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
$this->sessionUser = false; // User-Cache invalidieren
$_SESSION['user_id'] = $user['id']; $_SESSION['user_id'] = $user['id'];
$_SESSION['user_email'] = $user['email']; $_SESSION['user_email'] = $user['email'];
$_SESSION['user_name'] = $user['name']; $_SESSION['user_name'] = $user['name'];
@ -331,6 +344,7 @@ class Auth {
// Ausloggen // Ausloggen
public function logout() { public function logout() {
$this->sessionUser = null;
$_SESSION = []; $_SESSION = [];
if (isset($_COOKIE[session_name()])) { if (isset($_COOKIE[session_name()])) {
@ -340,6 +354,25 @@ class Auth {
session_destroy(); session_destroy();
} }
/**
* Laedt den Session-User einmal pro Request aus der DB. Dadurch wirken
* Rechteaenderungen (Admin entzogen, Konto deaktiviert/geloescht) sofort
* und nicht erst nach Ablauf der Session.
*/
private function loadSessionUser() {
if ($this->sessionUser === false) {
$this->sessionUser = null;
if (isset($_SESSION['user_id'])) {
$user = $this->db->fetchOne(
"SELECT * FROM users WHERE id = ? AND is_active = 1",
[$_SESSION['user_id']]
);
$this->sessionUser = $user ?: null;
}
}
return $this->sessionUser;
}
// Prüfen ob eingeloggt // Prüfen ob eingeloggt
public function isLoggedIn() { public function isLoggedIn() {
if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) { if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) {
@ -354,12 +387,24 @@ class Auth {
return false; return false;
} }
// Deaktivierte/geloeschte Konten sofort aussperren
if ($this->loadSessionUser() === null) {
$this->logout();
return false;
}
return true; return true;
} }
// Prüfen ob Admin // Prüfen ob Admin (live aus der DB, nicht aus dem Session-Cache)
public function isAdmin() { public function isAdmin() {
return $this->isLoggedIn() && isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true; if (!$this->isLoggedIn()) {
return false;
}
$user = $this->loadSessionUser();
$isAdmin = $user !== null && (bool)$user['is_admin'];
$_SESSION['is_admin'] = $isAdmin;
return $isAdmin;
} }
// Aktuellen Benutzer abrufen // Aktuellen Benutzer abrufen
@ -367,11 +412,7 @@ class Auth {
if (!$this->isLoggedIn()) { if (!$this->isLoggedIn()) {
return null; return null;
} }
return $this->loadSessionUser();
return $this->db->fetchOne(
"SELECT * FROM users WHERE id = ?",
[$_SESSION['user_id']]
);
} }
// Prüfen ob Benutzer Zugriff auf Site hat // Prüfen ob Benutzer Zugriff auf Site hat

View file

@ -114,4 +114,9 @@ class Crypto {
public static function isEncrypted($value) { public static function isEncrypted($value) {
return is_string($value) && strpos($value, self::PREFIX) === 0; return is_string($value) && strpos($value, self::PREFIX) === 0;
} }
/** Prueft, ob ein gueltiger APP_KEY konfiguriert ist (fuer Admin-Warnhinweis). */
public static function hasKey() {
return self::key() !== null;
}
} }

53
includes/Helpers.php Normal file
View file

@ -0,0 +1,53 @@
<?php
/**
* Kleine Shared-Helper:
* - Session-Flash-Messages fuer das PRG-Pattern (Redirect nach POST,
* Erfolgsmeldung ueberlebt den Redirect, F5 wiederholt keine Aktion).
* - IP-basiertes Request-Throttling ueber die Tabelle request_throttle.
*/
function flashSet($message, $type = 'success') {
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
/** @return array|null ['type' => ..., 'message' => ...] oder null */
function flashGet() {
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $flash;
}
/**
* Zaehlt eine Aktion fuer die aktuelle IP und prueft das Limit.
*
* @param Database $db
* @param string $action Logischer Name, z.B. 'voucher_create'
* @param int $maxWeight Erlaubte Summe im Zeitfenster
* @param int $windowMinutes Zeitfenster in Minuten
* @param int $weight Gewicht dieser Anfrage (z.B. Bulk-Anzahl)
* @return bool|null true = limitiert, false = erlaubt (und gezaehlt),
* null = Tabelle fehlt (Aufrufer entscheidet ueber Fallback)
*/
function throttleHit($db, $action, $maxWeight, $windowMinutes, $weight = 1) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
try {
$db->query("DELETE FROM request_throttle WHERE requested_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
$row = $db->fetchOne(
"SELECT COALESCE(SUM(weight), 0) AS cnt FROM request_throttle
WHERE action = ? AND ip_address = ?
AND requested_at > DATE_SUB(NOW(), INTERVAL " . (int)$windowMinutes . " MINUTE)",
[$action, $ip]
);
if ((int)$row['cnt'] + $weight > $maxWeight) {
return true;
}
$db->query(
"INSERT INTO request_throttle (ip_address, action, weight) VALUES (?, ?, ?)",
[$ip, $action, $weight]
);
return false;
} catch (Exception $e) {
// Tabelle existiert noch nicht (Migration 0002 nicht gelaufen)
return null;
}
}

View file

@ -1,169 +0,0 @@
<?php
/**
* Öffentliche Display-Seiten ("Kiosk").
*
* Ein Kiosk gehört zu genau einer Site und ist über einen geheimen Link
* erreichbar. Gäste holen sich darüber mit einem Klick einen Zugangscode
* ohne Anmeldung, aber begrenzt durch Tageslimit und Wartezeit.
*/
class Kiosk
{
/** Vorgaben für neue Kiosk-Seiten. */
public const DEFAULT_DAILY_LIMIT = 100;
public const DEFAULT_COOLDOWN = 20; // Sekunden zwischen zwei Codes
public const DEFAULT_DISPLAY_SECONDS = 90; // Anzeigedauer des Codes
/** Unrat-freier Zufallstoken für den öffentlichen Link. */
public static function newToken(): string
{
return bin2hex(random_bytes(16));
}
/** Token aus einer Anfrage säubern (Länge und Zeichen fest vorgegeben). */
public static function sanitizeToken(?string $token): string
{
$token = strtolower(trim((string)$token));
return preg_match('/^[0-9a-f]{32}$/', $token) ? $token : '';
}
/**
* Kiosk samt Site und Profil laden. Liefert null, wenn der Token nicht
* passt, der Kiosk deaktiviert ist oder die Site nicht mehr aktiv ist.
*/
public static function findByToken($db, string $token): ?array
{
$token = self::sanitizeToken($token);
if ($token === '') {
return null;
}
$row = $db->fetchOne(
"SELECT k.*, s.name AS site_name, s.is_active AS site_active,
t.name AS template_name, t.max_uses AS tpl_max_uses, t.expire_minutes AS tpl_expire_minutes,
t.qos_rate_max_down, t.qos_rate_max_up, t.qos_usage_quota
FROM kiosks k
INNER JOIN sites s ON s.id = k.site_id
LEFT JOIN voucher_templates t ON t.id = k.template_id
WHERE k.token = ? AND k.is_active = 1",
[$token]
);
if (!$row || (int)$row['site_active'] !== 1) {
return null;
}
return $row;
}
/** Wie viele Codes hat dieser Kiosk heute schon ausgegeben? */
public static function usedToday($db, int $kioskId): int
{
$row = $db->fetchOne(
"SELECT COUNT(*) AS c FROM vouchers WHERE kiosk_id = ? AND DATE(created_at) = CURDATE()",
[$kioskId]
);
return (int)($row['c'] ?? 0);
}
/**
* Darf gerade ein Code geholt werden?
*
* @return array{allowed:bool,reason:string,wait:int}
* reason: '' | 'cooldown' | 'daily_limit'
*/
public static function checkLimits($db, array $kiosk): array
{
$cooldown = max(0, (int)$kiosk['cooldown_seconds']);
if ($cooldown > 0 && !empty($kiosk['last_used_at'])) {
$elapsed = time() - strtotime((string)$kiosk['last_used_at']);
if ($elapsed >= 0 && $elapsed < $cooldown) {
return ['allowed' => false, 'reason' => 'cooldown', 'wait' => $cooldown - $elapsed];
}
}
$limit = max(0, (int)$kiosk['daily_limit']);
if ($limit > 0 && self::usedToday($db, (int)$kiosk['id']) >= $limit) {
return ['allowed' => false, 'reason' => 'daily_limit', 'wait' => 0];
}
return ['allowed' => true, 'reason' => '', 'wait' => 0];
}
/** Nach erfolgreicher Ausgabe den Zeitstempel fortschreiben. */
public static function markUsed($db, int $kioskId): void
{
$db->execute("UPDATE kiosks SET last_used_at = NOW() WHERE id = ?", [$kioskId]);
}
/**
* Voucher-Eckdaten eines Kiosks: entweder aus dem verknüpften Profil
* oder aus den globalen Standardwerten.
*/
public static function voucherSettings($db, array $kiosk): array
{
$maxUses = (int)($kiosk['tpl_max_uses'] ?? 0);
$expire = (int)($kiosk['tpl_expire_minutes'] ?? 0);
if ($maxUses < 1) {
$maxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
}
if ($expire < 1) {
$expire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
}
return [
'max_uses' => $maxUses,
'expire_minutes' => $expire,
'qos' => [
'down' => max(0, (int)($kiosk['qos_rate_max_down'] ?? 0)),
'up' => max(0, (int)($kiosk['qos_rate_max_up'] ?? 0)),
'quota_mb' => max(0, (int)($kiosk['qos_usage_quota'] ?? 0)),
],
];
}
/**
* Gestaltung einer Display-Seite: eigene Werte, sonst die des Systems.
*
* @return array{logo:string,background:string,overlay:float,accent:string,card:string}
*/
public static function appearance($db, array $kiosk): array
{
$accent = strtolower(trim((string)($kiosk['accent_color'] ?? '')));
if (!preg_match('/^#[0-9a-f]{6}$/', $accent)) {
$accent = '';
}
$overlay = (int)($kiosk['bg_overlay'] ?? 45);
$overlay = max(0, min(90, $overlay));
$logo = trim((string)($kiosk['logo_url'] ?? ''));
if ($logo === '' && $db) {
$logo = (string)$db->getSetting('logo_url', '');
}
return [
'logo' => $logo,
'background' => trim((string)($kiosk['background_url'] ?? '')),
'overlay' => (float)$overlay / 100, // immer float, auch bei 0
'accent' => $accent,
'card' => ($kiosk['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light',
];
}
/** Öffentliche Adresse eines Kiosks. */
public static function publicUrl(string $token, string $baseUrl = ''): string
{
if ($baseUrl === '') {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$path = dirname($_SERVER['SCRIPT_NAME'] ?? '/', 2);
$path = $path === '/' || $path === '\\' ? '' : $path;
$baseUrl = $protocol . '://' . $host . $path;
}
return rtrim($baseUrl, '/') . '/kiosk.php?k=' . $token;
}
}

View file

@ -7,6 +7,7 @@ class Mailer {
private $smtpUsername; private $smtpUsername;
private $smtpPassword; private $smtpPassword;
private $smtpEncryption; private $smtpEncryption;
private $smtpVerifySsl;
private $fromEmail; private $fromEmail;
private $fromName; private $fromName;
@ -22,7 +23,8 @@ class Mailer {
$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->smtpVerifySsl = $this->db->getSetting('smtp_verify_ssl', '0') === '1';
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost'));
$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'));
} }
@ -60,23 +62,29 @@ class Mailer {
private function sendWithSmtp($to, $subject, $body, $isHtml = false) { private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
try { try {
// Hostname auch im CLI-Kontext (Cron) verfuegbar
$heloHost = $_SERVER['HTTP_HOST'] ?? (gethostname() ?: 'localhost');
// Verbindung aufbauen // Verbindung aufbauen
$socket = $this->connectToSmtp(); $socket = $this->connectToSmtp();
// EHLO // EHLO
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); $this->smtpCommand($socket, "EHLO " . $heloHost);
// 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 " . $heloHost);
} }
// AUTH LOGIN // AUTH LOGIN nur wenn Zugangsdaten konfiguriert sind
// (Server ohne Auth lehnen ein leeres AUTH LOGIN sonst ab)
if ($this->smtpUsername !== '') {
$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}>");
@ -101,11 +109,12 @@ class Mailer {
$message .= "\r\n"; $message .= "\r\n";
// Body - bei Plain Text Zeilenumbrüche konvertieren // Zeilenumbrueche auf CRLF normalisieren (der fruehere
if (!$isHtml) { // nl2br/str_replace-Umweg hat Umbrueche verdoppelt)
$body = nl2br($body, false); // Für Plain Text $body = preg_replace("/\r\n|\r|\n/", "\r\n", $body);
$body = str_replace('<br>', "\r\n", $body); // SMTP-Dot-Stuffing: Zeilen, die mit '.' beginnen, wuerden sonst
} // die DATA-Phase vorzeitig beenden (RFC 5321, 4.5.2)
$body = preg_replace('/^\./m', '..', $body);
$message .= $body; $message .= $body;
$message .= "\r\n.\r\n"; $message .= "\r\n.\r\n";
@ -126,11 +135,12 @@ class Mailer {
} }
private function connectToSmtp() { private function connectToSmtp() {
// Zertifikatspruefung optional aktivierbar (Setting smtp_verify_ssl)
$context = stream_context_create([ $context = stream_context_create([
'ssl' => [ 'ssl' => [
'verify_peer' => false, 'verify_peer' => $this->smtpVerifySsl,
'verify_peer_name' => false, 'verify_peer_name' => $this->smtpVerifySsl,
'allow_self_signed' => true 'allow_self_signed' => !$this->smtpVerifySsl
] ]
]); ]);
@ -172,56 +182,6 @@ class Mailer {
} }
// Vordefinierte E-Mail-Templates // Vordefinierte E-Mail-Templates
/**
* Legt den Nachrichtentext in ein schlichtes, markentreues HTML-Gerüst.
* Bewusst Tabellen + Inline-Styles: nur so rendern Outlook & Co. zuverlässig.
*/
private function brandedHtml(string $title, string $contentHtml, string $footerNote = ''): string
{
$accent = $this->db->getSetting('brand_gradient_from', '') ?: '#5b5bd6';
$accent2 = $this->db->getSetting('brand_gradient_to', '') ?: '#8b5cf6';
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent)) { $accent = '#5b5bd6'; }
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent2)) { $accent2 = '#8b5cf6'; }
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$year = date('Y');
$footer = $footerNote !== '' ? '<div style="margin-top:8px;">' . htmlspecialchars($footerNote, ENT_QUOTES, 'UTF-8') . '</div>' : '';
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
. '<title>' . $safeTitle . '</title></head>'
. '<body style="margin:0;padding:0;background:#f6f7f9;">'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f7f9;padding:28px 12px;">'
. '<tr><td align="center">'
. '<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;">'
. '<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>'
. '<tr><td style="padding:26px;color:#101625;font-size:15px;line-height:1.6;">' . $contentHtml . '</td></tr>'
. '<tr><td style="padding:16px 26px;background:#f8f9fb;border-top:1px solid #e5e8ee;color:#6b7280;font-size:12px;">'
. '&copy; ' . $year . ' ' . $safeTitle . $footer
. '</td></tr>'
. '</table></td></tr></table></body></html>';
}
/**
* Voucher-Code als hervorgehobene Karte für die E-Mail.
*/
private function voucherCardHtml(string $code, string $siteName, $maxUses): string
{
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
. 'style="margin:18px 0;background:#f8f9fb;border:1px solid #e5e8ee;border-radius:12px;">'
. '<tr><td align="center" style="padding:22px;">'
. '<div style="font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">'
. 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>'
. '<div style="font-size:13px;color:#525c6e;">'
. htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' '
. htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '</div>'
. '</td></tr></table>';
}
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) { public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
$instructionHeader = $this->db->getSetting('instruction_header', ''); $instructionHeader = $this->db->getSetting('instruction_header', '');
@ -239,7 +199,7 @@ class Mailer {
// Template aus Datenbank laden // Template aus Datenbank laden
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'); $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}"); $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}");
// Anleitung formatieren // Anleitung formatieren
$instructions = ''; $instructions = '';
@ -249,7 +209,6 @@ class Mailer {
// Platzhalter ersetzen // Platzhalter ersetzen
$placeholders = [ $placeholders = [
'{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses),
'{VOUCHER_CODE}' => $voucherCode, '{VOUCHER_CODE}' => $voucherCode,
'{SITE_NAME}' => $siteName, '{SITE_NAME}' => $siteName,
'{MAX_USES}' => $maxUses, '{MAX_USES}' => $maxUses,
@ -259,16 +218,10 @@ class Mailer {
]; ];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
// Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln, // HTML oder Plain Text prüfen
// sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren. $isHtml = strip_tags($body) !== $body;
$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); return $this->send($to, $subject, $body, $isHtml);
} }
@ -312,28 +265,11 @@ class Mailer {
]; ];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate; // HTML oder Plain Text prüfen
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate; $isHtml = strip_tags($body) !== $body;
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
if ($isHtml) {
$body = $this->brandedHtml($appTitle, $body);
}
return $this->send($to, $subject, $body, $isHtml); 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);
}
} }

View file

@ -1,259 +0,0 @@
<?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>';
}
/**
* Bildfeld mit Vorschau, Upload und URL-Eingabe.
* Wird von den Einstellungen und der Kiosk-Verwaltung genutzt.
*/
public static function imageField(
string $name,
string $label,
?string $value,
string $hint = '',
string $accept = 'image/*',
string $base = '../'
): string {
$value = (string)$value;
$preview = self::mediaUrl($value, $base);
$esc = static fn ($text) => htmlspecialchars((string)$text, ENT_QUOTES);
$t = static fn ($key, $fallback) => function_exists('__') ? __($key) : $fallback;
$thumb = $preview !== ''
? '<img src="' . $esc($preview) . '" alt="">'
: '<i class="fas fa-image" aria-hidden="true"></i>';
$remove = $value !== ''
? '<label class="chk"><input type="checkbox" name="' . $esc($name) . '_remove" value="1"> '
. $esc($t('settings_image_remove', 'Bild entfernen')) . '</label>'
: '';
return '<div class="form-group">'
. '<label>' . $esc($label) . '</label>'
. '<div class="image-field">'
. '<div class="image-preview">' . $thumb . '</div>'
. '<div class="image-field-controls">'
. '<input type="file" name="' . $esc($name) . '_file" accept="' . $esc($accept) . '">'
. '<input type="text" name="' . $esc($name) . '" value="' . $esc($value) . '" '
. 'placeholder="' . $esc($t('settings_image_url_placeholder', 'https://… oder Datei hochladen')) . '">'
. $remove
. '</div></div>'
. ($hint !== '' ? '<div class="help-text">' . $esc($hint) . '</div>' : '')
. '</div>';
}
/**
* Version aus der Datei VERSION im Projektstamm.
* Damit tragen Oberfläche und Release-Paket dieselbe Nummer.
*/
public static function version(): string
{
static $version = null;
if ($version === null) {
$file = self::root() . '/VERSION';
$version = is_file($file) ? trim((string)file_get_contents($file)) : '';
}
return $version;
}
/** Entwicklerhinweis bewusst an einer Stelle gepflegt. */
public const CREDIT_NAME = 'Loheide.eu';
public const CREDIT_URL = 'https://loheide.eu';
/**
* Dezenter Hinweis auf den Entwickler, wie er im Seitenfuß erscheint.
*/
public static function credit(bool $withVersion = false): string
{
$label = function_exists('__') ? __('credit_by') : 'Entwickelt von';
$prefix = '';
if ($withVersion && self::version() !== '') {
$prefix = 'v' . htmlspecialchars(self::version()) . ' · ';
}
return '<p class="app-credit">' . $prefix . htmlspecialchars($label) . ' '
. '<a href="' . self::CREDIT_URL . '" target="_blank" rel="noopener">'
. self::CREDIT_NAME . '</a></p>';
}
/**
* 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);
}
}

View file

@ -10,12 +10,15 @@ class UniFiController {
private $csrfToken = null; private $csrfToken = null;
private $sessionCookie = null; private $sessionCookie = null;
private $loggedIn = false; private $loggedIn = false;
/** SSL-Zertifikat pruefen? Default aus, da UnifFi-Controller meist self-signed sind. */
private $sslVerify = false;
public function __construct($controllerUrl, $username, $password, $siteId) { public function __construct($controllerUrl, $username, $password, $siteId, $sslVerify = false) {
$this->controllerUrl = rtrim($controllerUrl, '/'); $this->controllerUrl = rtrim($controllerUrl, '/');
$this->username = $username; $this->username = $username;
$this->password = $password; $this->password = $password;
$this->siteId = $siteId; $this->siteId = $siteId;
$this->sslVerify = (bool)$sslVerify;
$this->cookieFile = tempnam(sys_get_temp_dir(), 'UNIFI_'); $this->cookieFile = tempnam(sys_get_temp_dir(), 'UNIFI_');
} }
@ -41,7 +44,8 @@ class UniFiController {
'password' => $this->password 'password' => $this->password
]), ]),
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_COOKIEJAR => $this->cookieFile,
CURLOPT_COOKIEFILE => $this->cookieFile, CURLOPT_COOKIEFILE => $this->cookieFile,
CURLOPT_TIMEOUT => 10, CURLOPT_TIMEOUT => 10,
@ -116,7 +120,8 @@ class UniFiController {
$options = [ $options = [
CURLOPT_URL => $url, CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
CURLOPT_TIMEOUT => 10, CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => $headers CURLOPT_HTTPHEADER => $headers
@ -155,13 +160,31 @@ class UniFiController {
return json_decode($response, true); return json_decode($response, true);
} }
// Voucher erstellen // Einzelnen Voucher erstellen
// $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB] // $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) { public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) {
$vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1, $options);
return $vouchers[0];
}
/**
* Erstellt $count Voucher in EINEM API-Call (UniFi 'n'-Parameter) statt
* pro Voucher Login + Full-Fetch auszufuehren.
*
* Matching: Die create-voucher-Antwort liefert die create_time der neuen
* Voucher; darueber (plus note) werden exakt die soeben erstellten Codes
* identifiziert. Der fruehere Fallback "global neuester Voucher" konnte
* bei parallelen Erstellungen fremde Codes liefern und wurde entfernt.
*
* @param array $options Optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
* @return array Liste von ['code','formatted_code','unifi_id','create_time']
*/
public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1, $options = []) {
$count = max(1, (int)$count);
$data = [ $data = [
'cmd' => 'create-voucher', 'cmd' => 'create-voucher',
'expire' => (int)$expireMinutes, 'expire' => (int)$expireMinutes,
'n' => 1, 'n' => $count,
'note' => $voucherName, 'note' => $voucherName,
'quota' => (int)$maxUses 'quota' => (int)$maxUses
]; ];
@ -182,51 +205,51 @@ class UniFiController {
if (!isset($response['data'][0]['create_time'])) { if (!isset($response['data'][0]['create_time'])) {
throw new Exception("Voucher konnte nicht erstellt werden"); throw new Exception("Voucher konnte nicht erstellt werden");
} }
$createTime = $response['data'][0]['create_time'];
// Voucher-Code abrufen. WICHTIG: getVouchers() liefert die Voucher $all = $this->getVouchers();
// unsortiert zurueck ein blindes reset() kann bei parallelen
// Erstellungen den falschen (fremden) Code liefern. Daher gezielt
// nach dem soeben erstellten Voucher suchen: gleiche note + neueste
// create_time.
$vouchers = $this->getVouchers();
if (empty($vouchers)) { // Exakte Treffer: gleiche note UND die vom Controller gemeldete create_time
throw new Exception("Voucher-Code konnte nicht abgerufen werden"); $matches = [];
foreach ($all as $voucher) {
if (($voucher['note'] ?? null) === $voucherName
&& ($voucher['create_time'] ?? null) == $createTime) {
$matches[] = $voucher;
}
} }
$latestVoucher = null; // Fallback: nur note matchen (falls der Controller create_time leicht
foreach ($vouchers as $voucher) { // abweichend meldet), neueste zuerst, auf $count begrenzen.
// Nur Voucher mit passender Notiz beruecksichtigen if (empty($matches)) {
if (($voucher['note'] ?? null) !== $voucherName) { foreach ($all as $voucher) {
if (($voucher['note'] ?? null) === $voucherName) {
$matches[] = $voucher;
}
}
usort($matches, function ($a, $b) {
return ($b['create_time'] ?? 0) <=> ($a['create_time'] ?? 0);
});
$matches = array_slice($matches, 0, $count);
}
$result = [];
foreach ($matches as $voucher) {
if (empty($voucher['code'])) {
continue; continue;
} }
if ($latestVoucher === null $result[] = [
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) { 'code' => $voucher['code'],
$latestVoucher = $voucher; 'formatted_code' => $this->formatVoucherCode($voucher['code']),
} 'unifi_id' => $voucher['_id'] ?? null,
'create_time' => $voucher['create_time'] ?? null
];
} }
// Fallback: falls keine note-Uebereinstimmung (z.B. Sonderzeichen), if (empty($result)) {
// den global neuesten Voucher nehmen.
if ($latestVoucher === null) {
foreach ($vouchers as $voucher) {
if ($latestVoucher === null
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
$latestVoucher = $voucher;
}
}
}
if ($latestVoucher === null || empty($latestVoucher['code'])) {
throw new Exception("Voucher-Code konnte nicht abgerufen werden"); throw new Exception("Voucher-Code konnte nicht abgerufen werden");
} }
return [ return $result;
'code' => $latestVoucher['code'],
'formatted_code' => $this->formatVoucherCode($latestVoucher['code']),
'unifi_id' => $latestVoucher['_id'] ?? null,
'create_time' => $latestVoucher['create_time'] ?? null
];
} }
// Alle Voucher abrufen // Alle Voucher abrufen
@ -320,9 +343,9 @@ class UniFiController {
} }
// Verbindung testen // Verbindung testen
public static function testConnection($controllerUrl, $username, $password, $siteId) { public static function testConnection($controllerUrl, $username, $password, $siteId, $sslVerify = false) {
try { try {
$controller = new self($controllerUrl, $username, $password, $siteId); $controller = new self($controllerUrl, $username, $password, $siteId, $sslVerify);
$controller->login(); $controller->login();
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
@ -351,17 +374,26 @@ class UniFiController {
// Alle aktuellen UniFi-IDs sammeln // Alle aktuellen UniFi-IDs sammeln
$unifiIds = []; $unifiIds = [];
// Bestehende Voucher der Site einmal als Map laden statt pro Voucher
// ein SELECT auszufuehren (halbiert die Query-Anzahl bei grossen Syncs)
$existingRows = $db->fetchAll(
"SELECT id, unifi_voucher_id FROM vouchers WHERE site_id = ? AND unifi_voucher_id IS NOT NULL",
[$dbSiteId]
);
$existingMap = [];
foreach ($existingRows as $row) {
$existingMap[$row['unifi_voucher_id']] = $row['id'];
}
foreach ($vouchers as $voucher) { foreach ($vouchers as $voucher) {
$unifiIds[] = $voucher['_id']; $unifiIds[] = $voucher['_id'];
// Status zählen // Status zählen
$stats[$voucher['status']]++; $stats[$voucher['status']]++;
// Prüfen ob Voucher bereits existiert $existing = isset($existingMap[$voucher['_id']])
$existing = $db->fetchOne( ? ['id' => $existingMap[$voucher['_id']]]
"SELECT id, status, used_count FROM vouchers WHERE unifi_voucher_id = ? AND site_id = ?", : null;
[$voucher['_id'], $dbSiteId]
);
$expiresAt = date('Y-m-d H:i:s', $voucher['expire_time']); $expiresAt = date('Y-m-d H:i:s', $voucher['expire_time']);
$createdAt = date('Y-m-d H:i:s', $voucher['create_time']); $createdAt = date('Y-m-d H:i:s', $voucher['create_time']);

View file

@ -1,175 +0,0 @@
<?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;
}
/**
* Neuer Wert eines Bildfeldes aus dem Formular.
*
* Reihenfolge: hochgeladene Datei schlaegt alles, danach der
* Entfernen-Schalter, sonst gilt das URL-Feld. Wird eine zuvor
* hochgeladene Datei ersetzt oder entfernt, verschwindet sie auch
* von der Platte.
*
* @param string $name Feldname (erwartet <name>, <name>_file, <name>_remove)
* @param string $current bisher gespeicherter Wert
* @param string $kind 'image' oder 'favicon'
*/
public static function resolveField(string $name, string $current, string $kind = 'image'): string
{
$uploaded = self::store($_FILES[$name . '_file'] ?? [], $kind);
if ($uploaded !== '') {
self::delete($current);
return $uploaded;
}
if (!empty($_POST[$name . '_remove'])) {
self::delete($current);
return '';
}
$value = trim((string)($_POST[$name] ?? ''));
if ($value !== $current && self::isLocal($current) && !self::isLocal($value)) {
self::delete($current);
}
return $value;
}
/**
* 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

@ -1,63 +0,0 @@
<?php
/**
* Erstellt Voucher im UniFi-Controller und schreibt sie in die Datenbank.
*
* Bis hierher lag diese Logik doppelt in index.php und der REST-API; mit der
* Kiosk-Seite waere sie ein drittes Mal noetig gewesen.
*/
require_once __DIR__ . '/UniFiController.php';
require_once __DIR__ . '/Crypto.php';
class VoucherService
{
/**
* @param array $site Zeile aus `sites`
* @param array $qos ['down' => kbit, 'up' => kbit, 'quota_mb' => MB]
* @param int|null $userId angemeldeter Benutzer, sonst null
* @param int|null $kioskId Herkunft, falls ueber eine Display-Seite geholt
*
* @return array{code:string,site_name:string,max_uses:int,expire_min:int,expiry_date:string,expiry_time:string}
* @throws Exception wenn der Controller keinen gueltigen Voucher liefert
*/
public static function create(
$db,
array $site,
string $voucherName,
int $maxUses,
int $expireMinutes,
?int $userId = null,
array $qos = [],
?int $kioskId = null
): array {
$fullName = date('Y-m-d') . '_' . $voucherName;
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
throw new Exception(function_exists('__') ? __('error_voucher_invalid') : 'Ungueltige Antwort des Controllers');
}
$db->execute(
"INSERT INTO vouchers (site_id, user_id, kiosk_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[$site['id'], $userId, $kioskId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
);
$expiryTs = time() + ($expireMinutes * 60);
return [
'code' => $voucher['formatted_code'],
'site_name' => $site['name'],
'max_uses' => $maxUses,
'expire_min' => $expireMinutes,
'expiry_date' => date('d.m.Y', $expiryTs),
'expiry_time' => date('H:i', $expiryTs),
];
}
}

View file

@ -4,122 +4,142 @@
* 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
$rootBase = $base === '' ? '../' : ''; // Prefix bis zum Projekt-Root
/** Navigation als Datenstruktur Reihenfolge = Darstellung. */
$navGroups = [
'nav_group_overview' => [
['dashboard', 'index.php', 'fa-chart-pie', 'nav_dashboard'],
['reports', 'reports.php', 'fa-chart-line', 'nav_reports'],
['audit_log', 'audit_log.php', 'fa-clock-rotate-left','nav_audit_log'],
],
'nav_group_manage' => [
['vouchers', 'vouchers.php', 'fa-ticket', 'nav_vouchers'],
['templates', 'templates.php', 'fa-layer-group', 'nav_templates'],
['import', 'import.php', 'fa-file-arrow-up', 'nav_import'],
['kiosks', 'kiosks.php', 'fa-display', 'nav_kiosks'],
['sites', 'sites.php', 'fa-location-dot', 'nav_sites'],
['users', 'users.php', 'fa-users', 'nav_users'],
],
'nav_group_system' => [
['settings', 'settings.php', 'fa-sliders', 'nav_settings'],
['integrations', 'integrations.php', 'fa-plug', 'nav_integrations'],
['api_keys', 'api_keys.php', 'fa-key', 'nav_api_keys'],
['security', 'security.php', 'fa-shield-halved','nav_security'],
['backup', 'backup.php', 'fa-database', 'nav_backup'],
['update', 'update.php', 'fa-rotate', 'nav_update'],
],
];
/** Aktuellen Seitentitel für die Breadcrumb finden. */
$currentLabel = __('nav_dashboard');
foreach ($navGroups as $items) {
foreach ($items as $item) {
if ($item[0] === $currentPage) { $currentLabel = __($item[3]); }
}
}
?> ?>
<?= Ui::head($db ?? null, $rootBase) ?> <?php if ($faviconUrl): ?>
<link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($faviconUrl) ?>">
<?php endif; ?>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="<?= $adminBase ?? '../' ?>assets/global.css">
<script>
(function(){
const t = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
})();
</script>
<style>
/* Base admin layout using CSS variables */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: var(--bg-body); color: var(--text-primary); }
.header { background: var(--bg-header); border-bottom: 1px solid var(--border-color); padding: 0 30px; height: 70px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 150; box-shadow: 0 2px 10px var(--shadow); }
.header-left { display: flex; align-items: center; gap: 12px; }
.header-title { font-size: 20px; font-weight: 600; color: var(--text-primary); }
.header-right { display: flex; align-items: center; gap: 10px; }
.sidebar { position: fixed; left: 0; top: 70px; bottom: 0; width: 260px; background: var(--bg-sidebar); border-right: 1px solid var(--border-color); padding: 20px 0; overflow-y: auto; z-index: 100; }
.sidebar-nav { list-style: none; }
.sidebar-nav li { margin-bottom: 2px; }
.sidebar-nav a { display: flex; align-items: center; gap: 12px; padding: 11px 25px; color: var(--text-secondary); text-decoration: none; transition: all 0.2s; font-size: 14px; border-radius: 0 8px 8px 0; margin-right: 12px; }
.sidebar-nav a:hover, .sidebar-nav a.active { background: var(--bg-hover); color: var(--accent); }
.sidebar-nav i { width: 18px; text-align: center; font-size: 15px; }
.sidebar-section { padding: 16px 25px 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
.main-content { margin-left: 260px; padding: 30px; min-height: calc(100vh - 70px); }
.user-menu { display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: var(--bg-hover); border-radius: 10px; }
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 14px; flex-shrink: 0; }
.user-name { font-weight: 500; font-size: 13px; color: var(--text-primary); }
.btn { padding: 8px 16px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all 0.2s; font-size: 13px; }
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
.btn-secondary:hover { background: var(--border-color); color: var(--text-primary); }
</style>
</head> </head>
<body> <body>
<a class="skip-link" href="#main-content"><?= __('a11y_skip') ?></a> <!-- Sidebar overlay for mobile -->
<div class="sidebar-overlay" onclick="closeMobileSidebar()"></div> <div class="sidebar-overlay" onclick="closeMobileSidebar()"></div>
<aside class="sidebar" id="adminSidebar" aria-label="<?= __('nav_administration') ?>"> <div class="header">
<a href="<?= $rootBase ?>index.php" class="sidebar-brand">
<span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
<span class="brand-text">
<span class="brand-name"><?= htmlspecialchars($appTitle ?? 'Voucher Tool') ?></span>
<span class="brand-sub"><?= __('nav_administration') ?></span>
</span>
</a>
<nav>
<?php foreach ($navGroups as $groupKey => $items): ?>
<div class="sidebar-section"><?= __($groupKey) ?></div>
<ul class="sidebar-nav">
<?php foreach ($items as [$key, $href, $icon, $labelKey]): ?>
<li>
<a href="<?= $base . $href ?>" class="<?= $currentPage === $key ? 'active' : '' ?>"
<?= $currentPage === $key ? 'aria-current="page"' : '' ?>>
<i class="fas <?= $icon ?>" aria-hidden="true"></i> <?= __($labelKey) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endforeach; ?>
</nav>
<div class="sidebar-foot">
<?php if ($currentUser): ?>
<div class="user-menu" style="width:100%;justify-content:flex-start;">
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
<div style="min-width:0;flex:1;">
<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>
<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" aria-hidden="true"></i>
</a>
</div>
<?php endif; ?>
<?= Ui::credit(true) ?>
</div>
</aside>
<header class="topbar">
<div class="header-left"> <div class="header-left">
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" aria-label="<?= __('a11y_menu') ?>" title="<?= __('a11y_menu') ?>"> <button class="mobile-menu-btn" onclick="toggleMobileSidebar()" title="Menu">
<i class="fas fa-bars" aria-hidden="true"></i> <i class="fas fa-bars"></i>
</button> </button>
<div class="breadcrumb"> <div class="header-title">
<span><?= __('nav_administration') ?></span> <i class="fas fa-shield-alt" style="color: var(--accent);"></i>
<span class="sep">/</span> <?= __('nav_administration') ?>
<span class="current"><?= htmlspecialchars($currentLabel) ?></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>"> <!-- Language switcher -->
<div class="lang-switcher">
<?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()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>"> <!-- Dark mode toggle -->
<i class="fas fa-moon" aria-hidden="true"></i> <button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">🌙</button>
</button> <!-- Back link -->
<a href="<?= $rootBase ?>index.php" class="btn btn-secondary"> <a href="<?= $adminBase ?? '../' ?>index.php" class="btn btn-secondary">
<i class="fas fa-arrow-left" aria-hidden="true"></i> <i class="fas fa-arrow-left"></i>
<span class="hide-mobile"><?= __('nav_back') ?></span> <span class="hide-mobile"><?= __('nav_back') ?></span>
</a> </a>
<!-- User menu -->
<?php if ($currentUser): ?>
<div class="user-menu">
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
<div class="user-name hide-mobile"><?= htmlspecialchars($currentUser['name']) ?></div>
</div>
<?php endif; ?>
</div>
</div> </div>
</header>
<main class="main-content" id="main-content"> <div class="sidebar" id="adminSidebar">
<nav>
<div class="sidebar-section">Main</div>
<ul class="sidebar-nav">
<li><a href="<?= $adminBase ?? '' ?>index.php" class="<?= $currentPage === 'dashboard' ? 'active' : '' ?>">
<i class="fas fa-home"></i> <?= __('nav_dashboard') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>sites.php" class="<?= $currentPage === 'sites' ? 'active' : '' ?>">
<i class="fas fa-map-marker-alt"></i> <?= __('nav_sites') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>users.php" class="<?= $currentPage === 'users' ? 'active' : '' ?>">
<i class="fas fa-users"></i> <?= __('nav_users') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>vouchers.php" class="<?= $currentPage === 'vouchers' ? 'active' : '' ?>">
<i class="fas fa-ticket-alt"></i> <?= __('nav_vouchers') ?>
</a></li>
</ul>
<div class="sidebar-section" style="margin-top: 10px;">Tools</div>
<ul class="sidebar-nav">
<li><a href="<?= $adminBase ?? '' ?>templates.php" class="<?= $currentPage === 'templates' ? 'active' : '' ?>">
<i class="fas fa-layer-group"></i> <?= __('nav_templates') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>audit_log.php" class="<?= $currentPage === 'audit_log' ? 'active' : '' ?>">
<i class="fas fa-history"></i> <?= __('nav_audit_log') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>reports.php" class="<?= $currentPage === 'reports' ? 'active' : '' ?>">
<i class="fas fa-chart-line"></i> <?= __('nav_reports') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>import.php" class="<?= $currentPage === 'import' ? 'active' : '' ?>">
<i class="fas fa-file-import"></i> <?= __('nav_import') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>api_keys.php" class="<?= $currentPage === 'api_keys' ? 'active' : '' ?>">
<i class="fas fa-key"></i> <?= __('nav_api_keys') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>security.php" class="<?= $currentPage === 'security' ? 'active' : '' ?>">
<i class="fas fa-user-shield"></i> <?= __('nav_security') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>integrations.php" class="<?= $currentPage === 'integrations' ? 'active' : '' ?>">
<i class="fas fa-plug"></i> <?= __('nav_integrations') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>backup.php" class="<?= $currentPage === 'backup' ? 'active' : '' ?>">
<i class="fas fa-database"></i> <?= __('nav_backup') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
</a></li>
</ul>
</nav>
</div>
<div class="main-content">
<style>
.hide-mobile { }
@media(max-width:600px) { .hide-mobile { display: none; } }
</style>

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