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"
done
- name: Validate language files
- name: Validate JSON language/migration assets
run: |
php -r '
$de = require "lang/de.php"; $en = require "lang/en.php";
if (!is_array($de) || !is_array($en)) { fwrite(STDERR, "Bad lang file\n"); exit(1); }
$missingEn = array_diff(array_keys($de), array_keys($en));
$missingDe = array_diff(array_keys($en), array_keys($de));
if ($missingEn || $missingDe) {
fwrite(STDERR, "Fehlend in en: " . implode(", ", $missingEn) . "\n");
fwrite(STDERR, "Fehlend in de: " . implode(", ", $missingDe) . "\n");
exit(1);
}
echo "lang OK (" . count($de) . " Schluessel)\n";'
- name: Check that every used translation key exists
run: |
php -r '
$de = require "lang/de.php";
$missing = [];
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(".", FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
$path = $file->getPathname();
if (substr($path, -4) !== ".php") continue;
if (strpos($path, "/vendor/") !== false || strpos($path, "/tools/") !== false) continue;
preg_match_all("/__\(\s*\x27([a-z0-9_]+)\x27/", file_get_contents($path), $m);
foreach ($m[1] as $key) {
if (!isset($de[$key]) && substr($key, -1) !== "_") { $missing[$key] = $path; }
}
}
if ($missing) {
foreach ($missing as $key => $path) { fwrite(STDERR, "Unbekannter Schluessel $key in $path\n"); }
exit(1);
}
echo "Alle verwendeten Schluessel vorhanden\n";'
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";'
test:
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'; \
} > /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
COPY . /var/www/html
# Laufzeit-Verzeichnisse beschreibbar machen
RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \
# Laufzeit-Verzeichnis des Updaters beschreibbar machen
RUN mkdir -p /var/www/html/updater/storage \
&& chown -R www-data:www-data /var/www/html
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.
Entwickelt von **[Loheide.eu](https://loheide.eu)**
![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)
![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)
![Version](https://img.shields.io/badge/Version-2.8.0-blueviolet)
![Tests](https://img.shields.io/badge/Tests-PHPUnit%20%2B%20PHPStan-brightgreen)
[![Repository](https://img.shields.io/badge/Code-git.loheide.cloud-4b3ec4)](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool)
![Version](https://img.shields.io/badge/Version-2.4.0-blueviolet)
![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg)
</div>
@ -28,8 +25,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
## ✨ Features
- 🎟️ **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
- 🧩 **Voucher-Profile/Templates** vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl
- 🏢 **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)
- 🐳 **Docker** Dockerfile + docker-compose (MariaDB)
- 🌍 **Ö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
- 🌐 **Mehrsprachig** Deutsch / Englisch per Umschalter (`lang/`)
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
@ -71,52 +59,31 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
## 📸 Screenshots
> Alle Screenshots stammen aus der Oberfläche in Version 2.5.0 (neues Design-System).
### Anmeldung & Voucher-Erstellung
<div align="center">
<img src="docs/screenshots/login.png" alt="Anmeldung" width="48%">
<img src="docs/screenshots/login-branding.png" alt="Anmeldung mit eigenem Branding" width="48%">
<img src="docs/screenshots/login.png" alt="Login mit Microsoft 365" width="32%">
<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 align="center">
<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
### Bulk-Erstellung & Dark Mode
<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%">
</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%">
</div>
### Administration & Updater
<div align="center">
<img src="docs/screenshots/vouchers.png" alt="Live-Voucher-Verwaltung" width="48%">
<img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%">
<img src="docs/screenshots/admin-dashboard.png" alt="Dashboard" width="48%">
<img src="docs/screenshots/updater-available.png" alt="Auto-Updater" width="48%">
</div>
<div align="center">
<img src="docs/screenshots/settings-branding.png" alt="Markenfarben einstellen" width="48%">
<img src="docs/screenshots/mobile-vouchers.png" alt="Ansicht auf dem Smartphone" width="22%">
<img src="docs/screenshots/updater.png" alt="Updater Ausgangszustand" width="48%">
<img src="docs/screenshots/maintenance.png" alt="Wartungsmodus" width="48%">
</div>
### 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%">
</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
@ -157,8 +113,8 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
## 🚀 Installation
```bash
git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git
cd Unifi-Voucher-Tool
git clone https://github.com/friloo/unifi-voucher-tool.git
cd unifi-voucher-tool
```
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
### `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
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 |
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern
(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal
liegen, erlaubt die CSP nur noch die eigene Herkunft externe Verbindungen
bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie
`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene
`.htaccess`-Dateien.
Empfohlene zusätzliche Härtung am Server:
> **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst
> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image
> erledigt das bereits.
Für **Nginx** entspricht das:
```nginx
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always;
location ~ ^/(includes|tools|tests)/ { deny all; }
location ~ ^/updater/(storage|migrations)/ { deny all; }
location ~ ^/(config\.php|database\.sql)$ { deny all; }
location ^~ /uploads/ { location ~ \.php$ { deny all; } }
```apache
# .htaccess sensible Dateien sperren (wird vom Installer erzeugt)
<FilesMatch "^(config\.php|database\.sql|install\.php|test\.php|m365_debug\.php|.*\.md)$">
Require all denied
</FilesMatch>
```
```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
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
- [x] Voucher-Templates (vordefinierte Laufzeiten)
@ -673,18 +378,11 @@ dem Git-Hoster.
- [x] Docker-Container
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
- [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">
**Version 2.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
Entwickelt von **[Loheide.eu](https://loheide.eu)**
**Version 2.4.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
</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");
$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']]);
if ($row) {
$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'])) {
$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');
$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");
@ -66,80 +66,94 @@ $adminBase = '';
<head>
<meta charset="UTF-8">
<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'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('api_title') ?></h1>
<p class="page-subtitle"><?= __('api_subtitle') ?></p>
</div>
</div>
<style>
.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); }
.card h2 { font-size:16px; margin-bottom:16px; color:var(--text-primary); }
table { width:100%; border-collapse:collapse; }
th,td { text-align:left; padding:11px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
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 ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<?php if ($newKey): ?>
<div class="card">
<h2><?= __('api_new_key') ?></h2>
<p class="muted"><?= __('api_new_key_hint') ?></p>
<h2>Neuer Schlüssel</h2>
<p class="muted">Kopieren Sie ihn jetzt aus Sicherheitsgründen wird er nicht erneut angezeigt.</p>
<div class="keybox"><?= htmlspecialchars($newKey) ?></div>
</div>
<?php endif; ?>
<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;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<div style="flex:2;min-width:200px;">
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_name') ?></label>
<input class="input" type="text" name="name" placeholder="<?= __('api_name_placeholder') ?>" required>
<label class="muted" style="display:block;margin-bottom:6px;">Bezeichnung</label>
<input class="input" type="text" name="name" placeholder="z.B. Buchungssystem, Terminal Foyer" required>
</div>
<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">
<option value="write"><?= __('api_scope_write') ?></option>
<option value="read"><?= __('api_scope_read') ?></option>
<option value="write">Lesen + Erstellen</option>
<option value="read">Nur Lesen</option>
</select>
</div>
<div style="flex:1;min-width:120px;">
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_limit') ?></label>
<input class="input" type="number" name="rate_limit" min="0" value="0" title="<?= __('api_limit_title') ?>">
<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="0 = unbegrenzt">
</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>
</div>
<div class="card">
<h2><?= __('api_existing') ?></h2>
<h2>Vorhandene Schlüssel</h2>
<?php if (empty($keys)): ?>
<p class="muted"><?= __('api_none') ?></p>
<p class="muted">Noch keine API-Schlüssel angelegt.</p>
<?php else: ?>
<div class="table-container">
<table class="table-stack">
<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>
<table>
<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>
<?php foreach ($keys as $k): ?>
<tr>
<td data-label="<?= __('label_name') ?>"><?= htmlspecialchars($k['name']) ?></td>
<td data-label="<?= __('api_col_prefix') ?>"><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 data-label="<?= __('api_col_limit') ?>"><?= (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 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" data-label="<?= __('api_col_created_by') ?>"><?= htmlspecialchars($k['creator'] ?? '') ?></td>
<td><?= htmlspecialchars($k['name']) ?></td>
<td><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
<td><?= ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?></td>
<td><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
<td><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? 'aktiv' : 'gesperrt' ?></span></td>
<td class="muted"><?= $k['last_used_at'] ? htmlspecialchars($k['last_used_at']) : '' ?></td>
<td class="muted"><?= htmlspecialchars($k['creator'] ?? '') ?></td>
<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" 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" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? 'Sperren' : 'Aktivieren' ?></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>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
</div>
<div class="card">
<h2><?= __('api_usage') ?></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>
<h2>Verwendung</h2>
<p class="muted" style="margin-bottom:10px;">Authentifizierung per Header <code>Authorization: Bearer &lt;key&gt;</code> oder <code>X-API-Key: &lt;key&gt;</code>.</p>
<pre class="keybox" style="color:#cdd3e0;white-space:pre-wrap;"># Voucher erstellen
curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
-H "Authorization: Bearer uvt_…" \
@ -148,10 +162,10 @@ curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
# Sites auflisten
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>
</main>
</div><!-- /main-content -->
<script src="../assets/global.js"></script>
</body>
</html>

View file

@ -45,15 +45,24 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY
$currentPage = 'audit_log';
$adminBase = '';
// Aktionsnamen uebersetzt anzeigen; unbekannte Aktionen bleiben technisch.
$actionLabels = [];
foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created',
'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted',
'settings_saved', 'password_reset', 'template_created', 'template_updated',
'template_deleted', 'voucher_kiosk', 'kiosk_created', 'kiosk_updated',
'kiosk_deleted'] as $action) {
$actionLabels[$action] = __('audit_action_' . $action);
}
// WICHTIG: Die Keys muessen den tatsaechlich via writeAuditLog() geschriebenen
// Action-Namen entsprechen (user_create, site_edit, ...), sonst erscheinen
// die Eintraege als rohe Keys.
$actionLabels = [
'voucher_create' => '🎫 Voucher erstellt',
'voucher_bulk' => '🎫 Bulk Voucher',
'user_login' => '🔐 Login',
'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>
<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">
<title><?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
<?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>
<h1 class="page-title"><?= __('audit_title') ?></h1>
<p class="page-subtitle"><?= __('audit_subtitle') ?></p>
</div>
<h1 class="page-title"><?= __('audit_title') ?></h1>
<p style="color: var(--text-muted); font-size: 14px;"><?= __('audit_subtitle') ?></p>
</div>
<!-- Filter -->
<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;">
<form method="get" class="filter-bar">
<div>
@ -89,7 +127,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<div>
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:5px;"><?= __('audit_user') ?></label>
<select name="user_id">
<option value=""><?= __('audit_all_users') ?></option>
<option value="">Alle Benutzer</option>
<?php foreach ($users as $u): ?>
<option value="<?= $u['id'] ?>" <?= (string)$filterUser === (string)$u['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($u['name']) ?>
@ -97,8 +135,8 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search" aria-hidden="true"></i> Filtern</button>
<a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times" aria-hidden="true"></i> Zurücksetzen</a>
<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"></i> Zurücksetzen</a>
</form>
</div>
</div>
@ -106,15 +144,14 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<!-- Log-Tabelle -->
<div class="card">
<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>
</div>
<?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: ?>
<div style="overflow-x:auto;">
<div class="table-container">
<table class="table table-stack">
<table class="table">
<thead>
<tr>
<th><?= __('audit_time') ?></th>
@ -128,55 +165,54 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<tbody>
<?php foreach ($logs as $log): ?>
<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>
<small><?= date('H:i:s', strtotime($log['created_at'])) ?></small>
</td>
<td data-label="<?= __('audit_action') ?>">
<td>
<span class="action-chip">
<?= htmlspecialchars($actionLabels[$log['action']] ?? $log['action']) ?>
</span>
</td>
<td data-label="<?= __('audit_user') ?>">
<td>
<?php if ($log['user_name']): ?>
<strong style="font-size:13px;"><?= htmlspecialchars($log['user_name']) ?></strong><br>
<small style="color:var(--text-muted);"><?= htmlspecialchars($log['user_email'] ?? '') ?></small>
<?php else: ?>
<em style="color:var(--text-muted);"><?= __('audit_system_anon') ?></em>
<em style="color:var(--text-muted);">System/Anonym</em>
<?php endif; ?>
</td>
<td data-label="<?= __('audit_entity') ?>" style="color:var(--text-secondary);">
<td style="color:var(--text-secondary);">
<?php if ($log['entity_type']): ?>
<code style="font-size:11px;"><?= htmlspecialchars($log['entity_type']) ?>:<?= htmlspecialchars($log['entity_id'] ?? '') ?></code>
<?php else: ?>
<span style="color:var(--text-muted);">-</span>
<?php endif; ?>
</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, '…')) ?>
</td>
<td class="ip-cell" data-label="<?= __('audit_ip') ?>"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
<td class="ip-cell"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if ($pages > 1): ?>
<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">
<?php
$baseUrl = '?' . http_build_query(array_filter(['action' => $filterAction, 'user_id' => $filterUser]));
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;
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>
<?php endfor;
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; ?>
</div>
</div>
@ -185,7 +221,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
<?php endif; ?>
</div>
</main>
</div><!-- main-content -->
<script src="../assets/global.js"></script>
</body>
</html>

View file

@ -43,12 +43,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} elseif (empty($_FILES['backup']['tmp_name'])) {
$error = __('backup_choose_file');
$error = 'Bitte eine Backup-Datei auswählen.';
} else {
$raw = file_get_contents($_FILES['backup']['tmp_name']);
$data = json_decode($raw, true);
if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') {
$error = __('backup_invalid_file');
$error = 'Ungültige oder fremde Backup-Datei.';
} else {
$importSites = isset($_POST['import_sites']);
$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');
$success = str_replace(['{settings}', '{sites}', '{templates}'],
[(string)$counts['settings'], (string)$counts['sites'], (string)$counts['templates']],
__('backup_imported'));
$success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile.";
} catch (Exception $e) {
$error = 'Import-Fehler: ' . $e->getMessage();
}
@ -114,38 +112,48 @@ $adminBase = '';
<head>
<meta charset="UTF-8">
<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'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('backup_title') ?></h1>
<p class="page-subtitle"><?= __('backup_subtitle') ?></p>
</div>
</div>
<style>
.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; }
.card h2 { font-size:16px; margin-bottom:12px; color:var(--text-primary); }
.muted { color:var(--text-muted); font-size:13px; margin-bottom: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; }
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 ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<div class="card">
<h2><?= __('backup_export') ?></h2>
<p class="muted"><?= __('backup_export_hint') ?> <code>APP_KEY</code> <?= __('backup_export_hint2') ?></p>
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>"><?= __('backup_export_btn') ?></a>
<h2>Export</h2>
<p class="muted">Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem <code>APP_KEY</code> dieser Installation verschlüsselt ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.</p>
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>">Konfiguration exportieren</a>
</div>
<div class="card">
<h2><?= __('backup_import') ?></h2>
<p class="muted"><?= __('backup_import_hint') ?></p>
<h2>Import / Restore</h2>
<p class="muted">Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.</p>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<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_sites" checked> <?= __('backup_opt_sites') ?></label>
<label class="chk"><input type="checkbox" name="import_templates" checked> <?= __('backup_opt_templates') ?></label>
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('<?= __('backup_import_confirm') ?>');"><?= __('import_submit') ?></button>
<label class="chk"><input type="checkbox" name="import_settings" checked> Einstellungen</label>
<label class="chk"><input type="checkbox" name="import_sites" checked> Sites</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('Import jetzt durchführen?');">Importieren</button>
</form>
</div>
</main>
</div><!-- /main-content -->
<script src="../assets/global.js"></script>
</body>
</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);
$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) {
$error = $e->getMessage();
}
@ -93,38 +93,48 @@ $adminBase = '';
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSV-Import <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('import_title') ?></h1>
<p class="page-subtitle"><?= __('import_subtitle') ?></p>
</div>
</div>
<style>
.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; }
.card h2 { font-size:15px; margin-bottom:12px; color:var(--text-primary); }
.muted { color:var(--text-muted); font-size:13px; margin-bottom:12px; }
label { display:block; font-size:13px; color:var(--text-secondary); margin:12px 0 6px; }
.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 ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<div class="card">
<h2><?= __('import_card_title') ?></h2>
<p class="muted"><?= __('import_format_hint') ?> <code>Name,MaxGeräte,Minuten</code> <?= __('import_format_hint2') ?><br>
<h2>Mehrere Voucher erstellen</h2>
<p class="muted">Eine Zeile pro Voucher: <code>Name,MaxGeräte,Minuten</code> MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:<br>
<code>Gast Müller,1,480</code> · <code>Konferenzraum A,5,240</code> · <code>Tagespass</code></p>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<label><?= __('import_site') ?></label>
<label>Standort</label>
<select class="input" name="site_id" required>
<?php foreach ($sites as $s): ?><option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?>
</select>
<label><?= __('import_file') ?></label>
<label>CSV-Datei (optional)</label>
<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>
<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>
</div>
<?php if (!empty($results)): ?>
<div class="card">
<h2><?= __('import_result') ?></h2>
<table><tr><th><?= __('label_name') ?></th><th><?= __('import_col_code') ?></th><th><?= __('label_status') ?></th></tr>
<h2>Ergebnis</h2>
<table><tr><th>Name</th><th>Code / Fehler</th><th>Status</th></tr>
<?php foreach ($results as $r): ?>
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><code><?= htmlspecialchars($r['code']) ?></code></td><td><?= $r['ok'] ? '✅' : '❌' ?></td></tr>
<?php endforeach; ?>
@ -132,7 +142,7 @@ $adminBase = '';
</div>
<?php endif; ?>
</main>
</div><!-- /main-content -->
<script src="../assets/global.js"></script>
</body>
</html>

View file

@ -6,7 +6,6 @@ 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/Ui.php';
require_once __DIR__ . '/../includes/UniFiController.php';
require_once __DIR__ . '/../includes/I18n.php';
@ -27,9 +26,12 @@ if (isset($_GET['ajax_stats'])) {
$syncErrors = [];
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) {
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']);
} catch (Exception $e) {
$syncErrors[$site['id']] = $e->getMessage();
@ -91,8 +93,78 @@ $currentPage = 'dashboard';
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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'; ?>
<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>
@ -104,8 +176,8 @@ $currentPage = 'dashboard';
<span class="dot"></span>
<span id="liveStatus">DB</span>
</span>
<button onclick="refreshData('live')" class="btn btn-secondary btn-sm" id="refreshBtn">
<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>
<button onclick="refreshData('live')" class="btn btn-success btn-sm" id="refreshBtn">
<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>
</button>
</div>
</div>
@ -114,43 +186,43 @@ $currentPage = 'dashboard';
<div class="stat-card">
<div class="stat-card-header">
<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 class="stat-card-value"><?= $stats['total_sites'] ?></div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<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 class="stat-card-value"><?= $stats['total_users'] ?></div>
</div>
<div class="stat-card live">
<div class="stat-card-header">
<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-title">🟢 <?= __('dashboard_valid') ?></div>
<div class="stat-card-icon" style="background:#e8f5e9;color:#388e3c;"><i class="fas fa-check-circle"></i></div>
</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>
<div class="stat-card live">
<div class="stat-card-header">
<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-title">🟡 <?= __('dashboard_used') ?></div>
<div class="stat-card-icon" style="background:#fff3e0;color:#f57c00;"><i class="fas fa-user-check"></i></div>
</div>
<div class="stat-card-value used" id="liveUsed"><?= (int)($voucherStats['used']??0) ?></div>
</div>
<div class="stat-card live">
<div class="stat-card-header">
<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-title">🔴 <?= __('dashboard_expired') ?></div>
<div class="stat-card-icon" style="background:#ffebee;color:#d32f2f;"><i class="fas fa-times-circle"></i></div>
</div>
<div class="stat-card-value expired" id="liveExpired"><?= (int)($voucherStats['expired']??0) ?></div>
</div>
<div class="stat-card live">
<div class="stat-card-header">
<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-title">📊 <?= __('dashboard_total') ?></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 class="stat-card-value" id="liveTotal"><?= (int)($voucherStats['total']??0) ?></div>
</div>
@ -158,16 +230,16 @@ $currentPage = 'dashboard';
<div class="card">
<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>
</div>
<div class="card-body">
<div class="card-body" style="padding:18px;">
<div class="site-status" id="siteStatusContainer">
<?php foreach ($sites as $site):
$ss = $siteStats[$site['id']] ?? ['total'=>0,'valid'=>0,'used'=>0,'expired'=>0];
?>
<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>
<span class="site-voucher-count <?= (int)$ss['total']===0 ? 'zero' : '' ?>" id="site-total-<?= $site['id'] ?>">
<?= (int)$ss['total'] ?>
@ -182,10 +254,10 @@ $currentPage = 'dashboard';
</div>
<?php endforeach; ?>
<?php if (empty($sites)): ?>
<div class="empty-state" style="grid-column:1/-1;">
<div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted);">
<i class="fas fa-map-marker-alt" style="font-size:32px;margin-bottom:15px;opacity:.3;display:block;"></i>
<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>
<?php endif; ?>
</div>
@ -193,18 +265,18 @@ $currentPage = 'dashboard';
</div>
<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="chart-container"><canvas id="voucherChart"></canvas></div>
</div>
</div>
<div class="two-col-grid">
<div class="card">
<div class="card-header"><h2 class="card-title"><?= __('dashboard_top_users') ?></h2></div>
<div class="card-body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;" class="two-col-grid">
<div class="card" style="margin-bottom:0;">
<div class="card-header"><h2 class="card-title">🏆 <?= __('dashboard_top_users') ?></h2></div>
<div class="card-body" style="padding:18px 22px;">
<?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: ?>
<ul class="top-users-list">
<?php foreach ($topUsers as $u): ?>
@ -224,22 +296,21 @@ $currentPage = 'dashboard';
</div>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title"><?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> Live</a></div>
<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-external-link-alt"></i> Live</a></div>
<div class="card-body" style="padding:0;">
<?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: ?>
<div class="table-container">
<table class="table table-stack">
<table class="table">
<thead><tr><th><?= __('label_created') ?></th><th><?= __('label_code') ?></th><th><?= __('label_site') ?></th><th><?= __('label_status') ?></th></tr></thead>
<tbody>
<?php foreach ($recentVouchers as $v): ?>
<tr>
<td data-label="<?= __('label_created') ?>" 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 data-label="<?= __('label_site') ?>"><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
<td data-label="<?= __('label_status') ?>">
<td style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
<td><code style="font-size:12px;background:var(--bg-hover);padding:3px 6px;border-radius:4px;"><?= htmlspecialchars($v['voucher_code']) ?></code></td>
<td><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
<td>
<?php $st=$v['status']??'valid'; ?>
<span class="badge badge-<?= $st==='valid'?'success':($st==='used'?'warning':'danger') ?>"><?= __('status_'.$st) ?></span>
</td>
@ -247,26 +318,17 @@ $currentPage = 'dashboard';
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</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>
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 accentFill = ctx.createLinearGradient(0, 0, 0, 280);
accentFill.addColorStop(0, accentColor + '33');
accentFill.addColorStop(1, accentColor + '00');
const chartData = <?= json_encode($chartData) ?>;
new Chart(ctx, {
type: 'line',
@ -275,20 +337,17 @@ new Chart(ctx, {
datasets: [{
label: 'Vouchers',
data: chartData.map(d => d.count),
borderColor: accentColor,
backgroundColor: accentFill,
borderColor: '#667eea',
backgroundColor: 'rgba(102,126,234,0.1)',
tension: 0.4, fill: true,
pointBackgroundColor: accentColor, pointBorderColor: surfaceColor,
pointBorderWidth: 2, pointRadius: 4, pointHoverRadius: 6
pointBackgroundColor: '#667eea', pointBorderColor: '#fff',
pointBorderWidth: 2, pointRadius: 5, pointHoverRadius: 7
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
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 } } }
}
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() } } }
}
});
@ -302,7 +361,7 @@ async function refreshData(mode='db') {
liveBadge.className = 'live-badge loading';
liveStatus.textContent = '...';
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 {
const url = `index.php?ajax_stats=1${mode==='live'?'&sync=1':''}`;
const result = await fetch(url).then(r=>r.json());
@ -340,8 +399,13 @@ async function refreshData(mode='db') {
}
isLoading = 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>
</body>
</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_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30)));
$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'])) {
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';
@ -92,14 +92,24 @@ $adminBase = '';
<head>
<meta charset="UTF-8">
<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'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('int_title') ?></h1>
<p class="page-subtitle"><?= __('int_subtitle') ?></p>
</div>
</div>
<style>
.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; }
.card h2 { font-size:16px; margin-bottom:6px; color:var(--text-primary); }
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
label { display:block; font-size:14px; color:var(--text-secondary); margin:14px 0 6px; }
.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 ($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) ?>">
<div class="card">
<h2><?= __('int_security') ?></h2>
<p class="muted"><?= __('int_security_hint') ?></p>
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> <?= __('int_enforce_2fa') ?></label>
<label><?= __('int_daily_limit') ?></label>
<h2>Sicherheitsrichtlinie</h2>
<p class="muted">Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.</p>
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</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;">
<label><?= __('int_session_driver') ?></label>
<select class="input" name="session_driver" style="max-width:340px;">
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>><?= __('int_session_php') ?></option>
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>><?= __('int_session_db') ?></option>
<label>Session-Speicher</label>
<select class="input" name="session_driver" style="max-width:240px;">
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>>PHP-Standard (Dateien)</option>
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>>Datenbank (ermöglicht „überall abmelden")</option>
</select>
<label><?= __('int_captcha') ?></label>
<select class="input" name="captcha_mode" style="max-width:340px;">
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>><?= __('int_captcha_off') ?></option>
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>><?= __('int_captcha_math') ?></option>
<label>Captcha im öffentlichen Modus</label>
<select class="input" name="captcha_mode" style="max-width:240px;">
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option>
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>>Rechenaufgabe (ohne externen Dienst)</option>
<option value="hcaptcha" <?= $captchaMode==='hcaptcha'?'selected':'' ?>>hCaptcha</option>
</select>
<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 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 class="card">
<h2><?= __('int_proxy') ?></h2>
<p class="muted"><?= __('int_proxy_hint') ?> <code>X-Forwarded-For</code> <?= __('int_proxy_hint2') ?></p>
<h2>Reverse-Proxy</h2>
<p class="muted">IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus <code>X-Forwarded-For</code> für Rate-Limit & Audit verwendet.</p>
<input class="input" type="text" name="trusted_proxy" value="<?= htmlspecialchars($trustedProxy) ?>" placeholder="z.B. 10.0.0.1, 172.18.0.1">
</div>
<div class="card">
<h2><?= __('int_webhook') ?></h2>
<p class="muted"><?= __('int_webhook_hint') ?></p>
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> <?= __('int_webhook_active') ?></label>
<label><?= __('int_webhook_url') ?></label>
<h2>Webhook-Benachrichtigungen</h2>
<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' : '' ?>> Webhook aktiv</label>
<label>Webhook-URL</label>
<input class="input" type="url" name="webhook_url" value="<?= htmlspecialchars($webhookUrl) ?>" placeholder="https://hooks.slack.com/services/…">
<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 class="card">
<h2><?= __('int_sms') ?></h2>
<p class="muted"><?= __('int_sms_hint') ?></p>
<label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> <?= __('int_sms_active') ?></label>
<h2>SMS-Versand (Twilio)</h2>
<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' : '' ?>> SMS-Versand aktiv</label>
<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>Auth Token<?= $twilioTokenSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? __('int_secret_placeholder') : '' ?>"></div>
<div><label><?= __('int_sms_from') ?></label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div>
<div><label>Auth Token<?= $twilioTokenSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
<div><label>Absender (From)</label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div>
</div>
</div>
<div class="card">
<h2><?= __('int_sso') ?></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>
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> <?= __('int_sso_active') ?></label>
<h2>Single Sign-On (OpenID Connect)</h2>
<p class="muted">Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI: <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p>
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> OIDC-Login aktiv</label>
<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 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>
<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">
@ -174,21 +184,21 @@ $adminBase = '';
</div>
<div class="card">
<h2><?= __('int_cleanup') ?></h2>
<p class="muted"><?= __('int_cleanup_hint') ?> <code>cron_cleanup.php</code> <?= __('int_cleanup_hint2') ?>
<?php if ($lastCleanup): ?><br><?= __('int_cleanup_last') ?> <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
<h2>Datenhaltung & Cleanup (DSGVO)</h2>
<p class="muted">Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per <code>cron_cleanup.php</code> (täglich empfohlen).
<?php if ($lastCleanup): ?><br>Letzter Lauf: <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
</p>
<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><?= __('int_cleanup_audit') ?></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>Abgelaufene Voucher</label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
<div><label>Audit-Log</label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
<div><label>Login-Versuche</label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></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>
</main>
</div><!-- /main-content -->
<script src="../assets/global.js"></script>
</body>
</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__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Ui.php';
require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth();
@ -96,46 +95,56 @@ $adminBase = '';
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('rep_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
<title>Reporting <?= htmlspecialchars($appTitle) ?></title>
<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'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('rep_title') ?></h1>
<p class="page-subtitle"><?= __('rep_subtitle') ?></p>
</div>
</div>
<style>
.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); }
.card h2 { font-size:15px; margin-bottom:14px; color:var(--text-primary); }
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px; }
.stat { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:20px; }
.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">
<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()">
<?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; ?>
</select>
</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-secondary" href="?export=per_site"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_site') ?></a>
<a class="btn btn-secondary" href="?export=per_user"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_user') ?></a>
<button class="btn btn-secondary" onclick="window.print()"><i class="fas fa-print" aria-hidden="true"></i> <?= __('rep_print') ?></button>
<a class="btn btn-s" href="?export=daily&days=<?= $days ?>">⬇️ CSV (täglich)</a>
<a class="btn btn-s" href="?export=per_site">⬇️ CSV (pro Site)</a>
<a class="btn btn-s" href="?export=per_user">⬇️ CSV (pro Nutzer)</a>
<button class="btn btn-s" onclick="window.print()">🖨️ Drucken/PDF</button>
</div>
<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['valid'] ?></div><div class="l"><?= __('status_valid') ?></div></div>
<div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l"><?= __('status_used') ?></div></div>
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l"><?= str_replace('{days}', (string)$days, __('rep_in_period')) ?></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">Gültig</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">In <?= $days ?> Tagen erstellt</div></div>
</div>
<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>
</div>
<div class="card">
<h2><?= __('rep_per_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>
<h2>Pro Site</h2>
<table><tr><th>Site</th><th>Gesamt</th><th>Gültig</th><th>Verwendet</th><th>Abgelaufen</th></tr>
<?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>
<?php endforeach; ?>
@ -143,32 +152,22 @@ $adminBase = '';
</div>
<div class="card">
<h2><?= __('rep_top_users') ?></h2>
<table><tr><th><?= __('label_user') ?></th><th><?= __('rep_col_created') ?></th></tr>
<h2>Top-Nutzer</h2>
<table><tr><th>Benutzer</th><th>Voucher erstellt</th></tr>
<?php foreach ($perUser as $r): ?>
<tr><td><?= htmlspecialchars($r['name'] ?? '') ?></td><td><?= (int)$r['c'] ?></td></tr>
<?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>
</div>
</main>
</div><!-- /main-content -->
<script src="../assets/global.js"></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'), {
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 }] },
options:{
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}} }
}
}
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:{ plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true,ticks:{precision:0}}} }
});
</script>
</body>

View file

@ -6,10 +6,6 @@ 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';
I18n::init();
$auth = new Auth();
$auth->requireLogin();
@ -28,20 +24,20 @@ $setupRequired = isset($_GET['setup_required']);
// 2FA aktivieren (Code bestaetigen)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('sec_token_invalid');
$error = 'Ungültiges Sicherheits-Token';
} else {
$secret = $_SESSION['totp_setup_secret'] ?? '';
$code = trim($_POST['code'] ?? '');
if ($secret === '') {
$error = __('sec_setup_expired');
$error = 'Setup abgelaufen, bitte erneut starten.';
} elseif (!Totp::verify($secret, $code)) {
$error = __('sec_code_invalid');
$error = 'Code ungültig. Bitte erneut versuchen.';
} else {
$backupCodes = $auth->enableTotp($user['id'], $secret);
unset($_SESSION['totp_setup_secret']);
$totpEnabled = true;
$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)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('sec_token_invalid');
$error = 'Ungültiges Sicherheits-Token';
} else {
$auth->logoutOtherSessions();
$success = __('sec_sessions_closed');
$success = 'Alle anderen Sitzungen wurden beendet.';
}
}
// Recovery-Codes neu erzeugen
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('sec_token_invalid');
$error = 'Ungültiges Sicherheits-Token';
} elseif (!empty($user['totp_enabled'])) {
$backupCodes = $auth->regenerateBackupCodes($user['id']);
$user = $auth->getCurrentUser();
$success = __('sec_codes_new');
$success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.';
}
}
// 2FA deaktivieren
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('sec_token_invalid');
$error = 'Ungültiges Sicherheits-Token';
} else {
$auth->disableTotp($user['id']);
$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;
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<html lang="de">
<head>
<meta charset="UTF-8">
<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): ?>
<?= 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; ?>
<?= 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>
<body class="app-body focus-page">
<div class="focus-card card">
<div class="focus-head">
<span class="focus-icon"><i class="fas fa-shield-halved" aria-hidden="true"></i></span>
<div>
<h1><?= __('sec_title') ?></h1>
<p class="sub"><?= __('sec_account') ?> <?= htmlspecialchars($user['email']) ?></p>
</div>
</div>
<body>
<div class="card">
<h1>🔐 Zwei-Faktor-Authentifizierung</h1>
<p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p>
<?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 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($backupCodes)): ?>
<div class="codes-box">
<strong><i class="fas fa-key" aria-hidden="true"></i> <?= __('sec_recovery_codes') ?></strong>
<p><?= __('sec_recovery_hint') ?></p>
<strong>🔑 Recovery-Codes</strong>
<p>Bewahren Sie diese sicher auf. Jeder Code funktioniert <em>einmal</em>, falls Sie keinen Zugriff auf Ihre App haben.</p>
<div class="codes">
<?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?>
</div>
@ -128,54 +142,53 @@ $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
<?php endif; ?>
<?php if (!$hasPassword): ?>
<div class="status off"><i class="fas fa-circle-minus" aria-hidden="true"></i> <?= __('sec_unavailable') ?></div>
<p class="sub"><?= __('sec_m365_hint') ?></p>
<div class="status off"> Nicht verfügbar</div>
<p class="sub">Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.</p>
<?php elseif ($totpEnabled): ?>
<div class="status on"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('sec_active') ?></div>
<p class="sub"><?= __('sec_active_hint') ?><br>
<?= __('sec_codes_left') ?> <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
<div class="status on"> Aktiv</div>
<p class="sub">Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.<br>
Verbleibende Recovery-Codes: <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
<form method="post" style="margin-bottom:10px;">
<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 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) ?>">
<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>
<?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>
<li><?= __('sec_step_1') ?></li>
<li><?= __('sec_step_2') ?></li>
<li><?= __('sec_step_3') ?></li>
<li>Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator )</li>
<li>QR-Code scannen <em>oder</em> Secret manuell eingeben</li>
<li>Den angezeigten 6-stelligen Code unten eingeben</li>
</ol>
<div class="qr"><div id="qrcode"></div></div>
<div class="secret"><?= htmlspecialchars($setupSecret) ?></div>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<label for="code"><?= __('sec_code_label') ?></label>
<input type="text" id="code" name="code" class="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required placeholder="123456">
<button type="submit" name="enable_totp" class="btn btn-primary btn-lg btn-block" style="margin-top:14px;"><?= __('sec_enable') ?></button>
<label for="code">6-stelliger Code</label>
<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">2FA aktivieren</button>
</form>
<script>
new QRCode(document.getElementById('qrcode'), {
text: <?= json_encode($otpUri) ?>, width: 168, height: 168,
colorDark: '#101625', colorLight: '#ffffff',
text: <?= json_encode($otpUri) ?>, width: 180, height: 180,
correctLevel: QRCode.CorrectLevel.M
});
</script>
<?php endif; ?>
<?php if ($dbSessions): ?>
<hr>
<p class="sub"><?= __('sec_sessions') ?> <strong><?= (int)$activeSessions ?></strong></p>
<form method="post" onsubmit="return confirm('<?= __('sec_logout_others_confirm') ?>');">
<hr style="margin:20px 0;border:none;border-top:1px solid #eee;">
<p class="sub">Aktive Sitzungen: <strong><?= (int)$activeSessions ?></strong></p>
<form method="post" onsubmit="return confirm('Alle anderen Sitzungen abmelden?');">
<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>
<?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>
</body>
</html>

View file

@ -8,8 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Mailer.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Ui.php';
require_once __DIR__ . '/../includes/Upload.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth();
$auth->requireAdmin();
@ -53,37 +52,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if ($formType === 'general') {
$settings['app_title'] = trim($_POST['app_title'] ?? '');
$settings['logo_url'] = Upload::resolveField('logo_url', (string)$db->getSetting('logo_url', ''), 'image');
$settings['favicon_url'] = Upload::resolveField('favicon_url', (string)$db->getSetting('favicon_url', ''), 'favicon');
$settings['logo_url'] = trim($_POST['logo_url'] ?? '');
$settings['favicon_url'] = trim($_POST['favicon_url'] ?? '');
$settings['instruction_header'] = trim($_POST['instruction_header'] ?? '');
$settings['instruction_text'] = $_POST['instruction_text'] ?? '';
$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') {
$expMin = (int)($_POST['default_expire_minutes'] ?? 480);
$defDev = (int)($_POST['default_max_uses'] ?? 1);
@ -98,7 +73,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if ($formType === 'm365') {
$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'] ?? '');
}
@ -111,6 +90,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
$settings['smtp_password'] = trim($_POST['smtp_password']);
}
$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_name'] = trim($_POST['smtp_from_name'] ?? '');
}
@ -124,6 +104,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
}
if ($formType === 'system') {
$settings['tinymce_api_key'] = trim($_POST['tinymce_api_key'] ?? '');
$settings['print_template'] = $_POST['print_template'] ?? '';
}
@ -131,9 +112,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
$db->setSetting($key, $value);
}
$success = __('settings_saved');
} catch (RuntimeException $e) {
$error = $e->getMessage();
// PRG + Tab-Anker: F5 speichert nicht erneut, und der Nutzer landet
// wieder auf dem Tab, in dem er gespeichert hat.
$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) {
$error = 'Fehler: ' . $e->getMessage();
}
@ -146,7 +133,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['generate_cron_token']
$error = __('error_csrf');
} else {
$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'])) {
@ -154,7 +143,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token']))
$error = __('error_csrf');
} else {
$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 {
$user = $auth->getCurrentUser();
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) {
throw new Exception(__('settings_pw_minlength'));
}
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 = ?",
[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) {
$error = $e->getMessage();
}
}
}
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME'], 2);
@ -208,30 +205,16 @@ $cs = [
'smtp_username' => $db->getSetting('smtp_username', ''),
'smtp_password' => $db->getSetting('smtp_password', ''),
'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_name' => $db->getSetting('smtp_from_name', ''),
'system_url' => $db->getSetting('system_url', $autoDetectedUrl),
'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_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"),
'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()),
'brand_accent' => $db->getSetting('brand_accent', '') ?: Ui::DEFAULT_ACCENT,
'brand_accent_dark' => $db->getSetting('brand_accent_dark', '') ?: Ui::DEFAULT_ACCENT_DARK,
'brand_gradient_from' => $db->getSetting('brand_gradient_from', '') ?: Ui::DEFAULT_GRADIENT_FROM,
'brand_gradient_to' => $db->getSetting('brand_gradient_to', '') ?: Ui::DEFAULT_GRADIENT_TO,
'brand_radius' => $db->getSetting('brand_radius', (string)Ui::DEFAULT_RADIUS),
'login_panel_enabled' => $db->getSetting('login_panel_enabled', '1'),
'login_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'),
'tinymce_api_key' => $db->getSetting('tinymce_api_key', ''),
'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>'),
'cron_token' => $db->getSetting('cron_token', ''),
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
];
@ -246,63 +229,98 @@ $adminBase = '';
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
<!-- TinyMCE wird lokal ausgeliefert (GPL-Variante) keine externen Aufrufe. -->
<?= Ui::script('assets/vendor/tinymce/tinymce.min.js', '../') ?>
<script>window.TINYMCE_BASE_URL = '../assets/vendor/tinymce';</script>
<?php if (!empty($cs['tinymce_api_key'])): ?>
<script src="https://cdn.tiny.cloud/1/<?= htmlspecialchars($cs['tinymce_api_key']) ?>/tinymce/6/tinymce.min.js"></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'; ?>
<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>
<h1 class="page-title"><?= __('settings_title') ?></h1>
<p class="page-subtitle"><?= __('settings_subtitle') ?></p>
</div>
<h1 class="page-title"><?= __('settings_title') ?></h1>
<p style="color: var(--text-muted); font-size: 14px;"><?= __('settings_subtitle') ?></p>
</div>
<?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 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; ?>
<div class="tab-container">
<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<?= $activeTab === 'defaults' ? ' active' : '' ?>" data-tab="defaults"><i class="fas fa-sliders-h" aria-hidden="true"></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<?= $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<?= $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="m365"><i class="fab fa-microsoft" aria-hidden="true"></i> <?= __('settings_tab_m365') ?></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<?= $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<?= $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>
<button class="tab-button active" data-tab="general"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></button>
<button class="tab-button" data-tab="defaults"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></button>
<button class="tab-button" data-tab="cron"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></button>
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft"></i> <?= __('settings_tab_m365') ?></button>
<button class="tab-button" data-tab="smtp"><i class="fas fa-envelope"></i> <?= __('settings_tab_smtp') ?></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" data-tab="system"><i class="fas fa-cogs"></i> <?= __('settings_tab_system') ?></button>
<button class="tab-button" data-tab="password"><i class="fas fa-key"></i> <?= __('settings_tab_password') ?></button>
</div>
<!-- Allgemein -->
<div id="tab-general" class="tab-content<?= $activeTab === 'general' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></h2>
<form method="post" enctype="multipart/form-data">
<div id="tab-general" class="tab-content active">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></h2>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<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-grid">
<?= Ui::imageField('logo_url', __('settings_logo_url'), $cs['logo_url'], __('settings_upload_hint')) ?>
<?= 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_logo_url') ?></label><input type="url" name="logo_url" value="<?= htmlspecialchars($cs['logo_url']) ?>" placeholder="https://example.com/logo.png"></div>
<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>
<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_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>
<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>
</div>
<!-- Voucher-Standards -->
<div id="tab-defaults" class="tab-content<?= $activeTab === 'defaults' ? ' active' : '' ?>">
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></h2>
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_defaults_hint') ?></p>
<div id="tab-defaults" class="tab-content">
<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;">Diese Werte werden als Vorgabe im Voucher-Formular verwendet.</p>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="defaults">
@ -324,184 +342,37 @@ $adminBase = '';
</div>
</div>
<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>
60 Min = 1 Stunde &nbsp;|&nbsp; 480 Min = 8 Stunden &nbsp;|&nbsp;
1440 Min = 1 Tag &nbsp;|&nbsp; 10080 Min = 1 Woche &nbsp;|&nbsp;
43200 Min = 30 Tage
</p>
</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>
<!-- 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>
<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>
<!-- Cron-Sync -->
<div id="tab-cron" class="tab-content<?= $activeTab === 'cron' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></h2>
<div id="tab-cron" class="tab-content">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></h2>
<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>
</div>
<hr class="section-divider">
<?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">
<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>
<?php else: ?>
<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;">
<button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy" aria-hidden="true"></i> Kopieren</button>
<form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync" aria-hidden="true"></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>
<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"></i> Neu generieren</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>
<?php endif; ?>
<?php
@ -511,7 +382,7 @@ $adminBase = '';
<label>Cron-URL</label>
<div style="display:flex;gap:10px;">
<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 class="placeholder-info" style="background: var(--bg-hover); border-color: var(--border-color);">
@ -522,7 +393,7 @@ $adminBase = '';
</div>
<?php if ($cs['cron_token']): ?>
<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>
</div>
<?php endif; ?>
@ -531,24 +402,24 @@ $adminBase = '';
<!-- M365 -->
<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">
<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>
</div>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<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 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>
<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>
</div>
<!-- SMTP -->
<div id="tab-smtp" class="tab-content<?= $activeTab === 'smtp' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope" aria-hidden="true"></i> SMTP</h2>
<div id="tab-smtp" class="tab-content">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope"></i> SMTP</h2>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<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>
<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-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 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><?= __('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>
<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>
<hr class="section-divider">
<h3 style="margin-bottom:15px; color: var(--text-primary);">SMTP testen</h3>
<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>
<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>
<span id="smtpTestResult" style="display:block;margin-top:10px;font-size:13px;"></span>
</div>
<!-- E-Mail Templates -->
<div id="tab-templates_email" class="tab-content<?= $activeTab === 'templates_email' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt" aria-hidden="true"></i> E-Mail Templates</h2>
<div id="tab-templates_email" class="tab-content">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt"></i> E-Mail Templates</h2>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<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>
<hr class="section-divider">
<h3 style="margin-bottom:15px; color: var(--text-primary);"><?= __('settings_tpl_voucher_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>
<h3 style="margin-bottom:15px; color: var(--text-primary);">Voucher E-Mail</h3>
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{VOUCHER_CODE}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div></div>
<div class="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>
<hr class="section-divider">
<h3 style="margin-bottom:15px; color: var(--text-primary);"><?= __('settings_tpl_user_notify') ?></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>
<h3 style="margin-bottom:15px; color: var(--text-primary);">Benutzer-Benachrichtigung</h3>
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{USER_NAME}</code><code>{CHANGES}</code><code>{APP_TITLE}</code><code>{SYSTEM_URL}</code></div></div>
<div class="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>
<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>
</div>
<!-- System -->
<div id="tab-system" class="tab-content<?= $activeTab === 'system' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs" aria-hidden="true"></i> System & Erweitert</h2>
<div id="tab-system" class="tab-content">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs"></i> System & Erweitert</h2>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="system">
<div class="info-box">
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> WYSIWYG-Editor</h4>
<p><?= __('settings_editor_hint') ?> <code>assets/vendor/</code> <?= __('settings_editor_hint2') ?></p>
<h4><i class="fas fa-info-circle"></i> TinyMCE API Key</h4>
<p>Kostenlosen API Key: <a href="https://www.tiny.cloud/auth/signup/" target="_blank" rel="noopener" style="color:#0066cc;">tiny.cloud/signup</a></p>
</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">
<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="form-group"><label><?= __('settings_print_template') ?></label><textarea name="print_template" class="tinymce-editor" style="min-height:250px;"><?= htmlspecialchars($cs['print_template']) ?></textarea></div>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
<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>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"></i> <?= __('btn_save') ?></button>
</form>
<hr class="section-divider">
<h3 style="margin-bottom:15px; color: var(--text-primary);">System-Information</h3>
@ -625,19 +497,19 @@ $adminBase = '';
</div>
<!-- Passwort -->
<div id="tab-password" class="tab-content<?= $activeTab === 'password' ? ' active' : '' ?>">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></h2>
<div id="tab-password" class="tab-content">
<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;">
<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_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>
<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>
</div>
</div>
</main>
</div><!-- main-content -->
<script src="../assets/global.js"></script>
<script>
@ -653,43 +525,7 @@ document.querySelectorAll('.tab-button').forEach(btn => {
});
});
// Branding-Vorschau live faerben
function updateBrandPreview() {
const preview = document.getElementById('brandPreview');
if (!preview) return;
const accent = (document.getElementById('brand_accent') || {}).value || '';
const from = (document.getElementById('brand_gradient_from') || {}).value || '';
const to = (document.getElementById('brand_gradient_to') || {}).value || '';
const radius = (document.getElementById('brand_radius') || {}).value || '14';
if (/^#[0-9a-fA-F]{6}$/.test(accent)) {
preview.style.setProperty('--accent', accent);
preview.style.setProperty('--accent-hover', `color-mix(in srgb, ${accent} 84%, #000)`);
preview.style.setProperty('--accent-soft', `color-mix(in srgb, ${accent} 12%, #fff)`);
preview.style.setProperty('--accent-border', `color-mix(in srgb, ${accent} 32%, #fff)`);
}
if (/^#[0-9a-fA-F]{6}$/.test(from) && /^#[0-9a-fA-F]{6}$/.test(to)) {
preview.style.setProperty('--brand-gradient', `linear-gradient(135deg, ${from} 0%, ${to} 100%)`);
}
preview.style.setProperty('--r-lg', radius + 'px');
}
['brand_accent', 'brand_gradient_from', 'brand_gradient_to', 'brand_radius'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', updateBrandPreview);
if (el) el.addEventListener('change', updateBrandPreview);
});
updateBrandPreview();
// Farbwähler und Hex-Feld synchron halten
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)
// Restore tab from hash
window.addEventListener('DOMContentLoaded', function() {
const hash = location.hash.substring(1);
if (hash) {
@ -703,9 +539,9 @@ async function testSmtp() {
const email = document.getElementById('smtpTestEmail').value.trim();
const btn = document.getElementById('smtpTestBtn');
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.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();
fd.append('ajax_smtp_test', '1');
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
@ -715,52 +551,38 @@ async function testSmtp() {
result.textContent = data.message;
result.style.color = data.success ? 'var(--success)' : 'var(--danger)';
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() {
const btn = document.getElementById('testCronBtn');
const result = document.getElementById('testCronResult');
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 {
const res = await fetch('../cron_sync.php?token=<?= htmlspecialchars($cs['cron_token']) ?>');
const data = await res.json();
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(--danger)"><i class="fas fa-times-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"></i> ${data.message}</span>`;
if (data.success) showToast('success', 'Cron ausgeführt', data.message);
} 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.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() {
if (typeof tinymce === 'undefined') return;
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
const config = {
tinymce.init({
selector: '.tinymce-editor',
height: 350,
menubar: false,
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',
content_style: "body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.6; }",
skin: dark ? 'oxide-dark' : 'oxide',
content_css: dark ? 'dark' : 'default',
content_style: 'body { font-family: -apple-system, sans-serif; font-size: 14px; line-height: 1.6; }',
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>
</body>

View file

@ -8,6 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/UniFiController.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth();
$auth->requireAdmin();
@ -18,6 +19,32 @@ I18n::init();
$error = '';
$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
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) {
@ -31,18 +58,27 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
$username = trim($_POST['username']);
$password = $_POST['password'];
$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($password)) {
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr);
if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test);
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=? WHERE id=?",
[$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$siteId]);
$test = UniFiController::testConnection($controllerUrl,$username,$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=?,unifi_password=?,public_access=?,ssl_verify=? WHERE id=?",
[$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify,$siteId]);
} else {
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=? WHERE id=?",
[$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$siteId]);
// Auch ohne Passwortaenderung testen (mit gespeichertem Passwort)
// 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");
$success = __('sites_updated');
flashSet(__('sites_updated'));
header('Location: sites.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); }
}
}
@ -59,38 +95,49 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_site'])) {
$username = trim($_POST['username']);
$password = $_POST['password'];
$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'));
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId);
if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test);
$newId = $db->execute("INSERT INTO sites (name,site_id,unifi_controller_url,unifi_username,unifi_password,public_access) VALUES (?,?,?,?,?,?)",
[$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess]);
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId,$sslVerify);
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,ssl_verify) VALUES (?,?,?,?,?,?,?)",
[$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify]);
$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(); }
}
}
// Delete site
if (isset($_GET['delete']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$delId = (int)$_GET['delete'];
// Delete site (POST + PRG)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_site'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$delId = (int)$_POST['delete_site'];
$db->query("DELETE FROM sites WHERE id=?", [$delId]);
$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'); }
}
// Toggle site
if (isset($_GET['toggle']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_GET['toggle']]);
// Toggle site (POST + PRG)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_site'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_POST['toggle_site']]);
if ($site) {
$db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_GET['toggle']]);
$success = 'Site-Status aktualisiert!';
$db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_POST['toggle_site']]);
flashSet(__('sites_status_updated'));
header('Location: sites.php');
exit;
}
} else { $error = __('error_csrf'); }
}
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$sites = $db->fetchAll("SELECT * FROM sites ORDER BY name");
$currentPage = 'sites';
?>
@ -101,24 +148,61 @@ $currentPage = 'sites';
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('sites_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?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">
<h1 class="page-title"><?= __('sites_title') ?></h1>
<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>
</div>
<?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 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 if (empty($sites)): ?>
<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>
</div>
<?php else: ?>
@ -132,51 +216,62 @@ $currentPage = 'sites';
</div>
<div>
<?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: ?>
<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 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; ?>
</div>
</div>
<div class="site-info">
<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>
</div>
<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>
</div>
<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>
</div>
</div>
<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">
<i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?>
<i class="fas fa-edit"></i> <?= __('btn_edit') ?>
</button>
<a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-sm">
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i>
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
</a>
<a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-danger-soft btn-sm"
onclick="return confirm('<?= __('js_confirm_delete_site') ?>')">
<i class="fas fa-trash" aria-hidden="true"></i>
</a>
<form method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<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') ?>
</button>
</form>
<button type="button" class="btn btn-secondary btn-sm" onclick="testSite(<?= $site['id'] ?>, this)"
title="<?= __('site_test_btn') ?>" aria-label="<?= __('site_test_btn') ?>">
<i class="fas fa-plug"></i>
</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>
<?php endforeach; ?>
</div>
<?php endif; ?>
</main>
</div><!-- /main-content -->
<!-- Add Site Modal -->
<div id="addSiteModal" class="modal">
@ -196,7 +291,7 @@ $currentPage = 'sites';
<div class="form-group">
<label><?= __('sites_site_id') ?></label>
<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 class="form-group">
<label><?= __('sites_controller') ?></label>
@ -217,9 +312,14 @@ $currentPage = 'sites';
<input type="checkbox" id="add_public" name="public_access">
<label for="add_public" style="margin:0;"><?= __('sites_public') ?></label>
</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;">
<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 type="button" onclick="closeModal('addSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div>
@ -259,7 +359,7 @@ $currentPage = 'sites';
</div>
<div class="form-group">
<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>
</div>
</div>
@ -267,9 +367,14 @@ $currentPage = 'sites';
<input type="checkbox" id="edit_public_access" name="public_access">
<label for="edit_public_access" style="margin:0;"><?= __('sites_public') ?></label>
</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;">
<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 type="button" onclick="closeModal('editSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div>
@ -278,13 +383,13 @@ $currentPage = 'sites';
</div>
</div>
<div id="toast-container" role="status" aria-live="polite"></div>
<div id="toast-container"></div>
<script src="../assets/global.js"></script>
<script>
function openModal() { document.getElementById('addSiteModal').classList.add('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_name').value = name;
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_password').value = '';
document.getElementById('edit_public_access').checked = publicAccess == 1;
document.getElementById('edit_ssl_verify').checked = sslVerify == 1;
document.getElementById('editSiteModal').classList.add('active');
}
document.getElementById('addSiteForm').addEventListener('submit', function() {
const btn = document.getElementById('addSiteSubmitBtn');
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() {
const btn = document.getElementById('editSiteSubmitBtn');
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 => {
@ -311,6 +417,23 @@ document.getElementById('editSiteForm').addEventListener('submit', function() {
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>
</body>
</html>

View file

@ -7,6 +7,7 @@ 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/Helpers.php';
$auth = new Auth();
$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 (?, ?, ?, ?, ?, ?, ?, ?)",
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
);
$success = __('templates_added');
flashSet(__('templates_added'));
header('Location: templates.php');
exit;
} catch (Exception $e) {
$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=?",
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id]
);
$success = __('templates_updated');
flashSet(__('templates_updated'));
header('Location: templates.php');
exit;
} catch (Exception $e) {
$error = $e->getMessage();
}
}
}
// Profil löschen
if (isset($_GET['delete']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_GET['delete']]);
$success = __('templates_deleted');
// Profil löschen (POST + PRG)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_template'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_POST['delete_template']]);
flashSet(__('templates_deleted'));
header('Location: templates.php');
exit;
} else {
$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");
$currentPage = 'templates';
@ -100,22 +111,56 @@ $adminBase = '';
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('templates_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
<?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>
<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>
<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>
</div>
<?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 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; ?>
<div class="card">
@ -125,15 +170,14 @@ $adminBase = '';
</div>
<?php if (empty($templates)): ?>
<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: 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>
<?php else: ?>
<div style="overflow-x: auto;">
<div class="table-container">
<table class="table table-stack">
<table class="table">
<thead>
<tr>
<th><?= __('templates_name') ?></th>
@ -147,11 +191,11 @@ $adminBase = '';
<tbody>
<?php foreach ($templates as $t): ?>
<tr>
<td data-label="<?= __('templates_name') ?>"><strong><?= htmlspecialchars($t['name']) ?></strong></td>
<td data-label="<?= __('templates_devices') ?>">
<span class="duration-badge"><i class="fas fa-mobile-alt" aria-hidden="true"></i> <?= (int)$t['max_uses'] ?></span>
<td><strong><?= htmlspecialchars($t['name']) ?></strong></td>
<td>
<span class="duration-badge"><i class="fas fa-mobile-alt"></i> <?= (int)$t['max_uses'] ?></span>
</td>
<td data-label="<?= __('templates_duration') ?>">
<td>
<?php
$m = (int)$t['expire_minutes'];
if ($m >= 1440 && $m % 1440 === 0) {
@ -162,39 +206,44 @@ $adminBase = '';
$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 data-label="<?= __('templates_desc') ?>" style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
<td data-label="<?= __('label_status') ?>">
<td style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
<td>
<?php if ($t['is_active']): ?>
<span class="badge badge-success"><?= __('status_active') ?></span>
<?php else: ?>
<span class="badge badge-secondary"><?= __('status_inactive') ?></span>
<?php endif; ?>
</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) ?>)"
class="btn btn-secondary btn-small"><i class="fas fa-edit" aria-hidden="true"></i></button>
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
onclick="return confirm('<?= __('js_confirm_delete_template') ?>')"
class="btn btn-danger-soft btn-small"><i class="fas fa-trash" aria-hidden="true"></i></a>
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= addslashes(__('confirm_delete_template')) ?>')">
<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>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
</main>
</div><!-- main-content -->
<!-- Modal: Hinzufügen -->
<div id="addModal" class="modal">
<div class="modal-content">
<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>
</div>
<div class="modal-body">
@ -210,17 +259,17 @@ $adminBase = '';
<div class="form-group">
<label><?= __('templates_duration') ?> *</label>
<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 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 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>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" min="0" placeholder="0 = unbegrenzt"></div>
</div>
<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>
</div>
</form>
@ -232,7 +281,7 @@ $adminBase = '';
<div id="editModal" class="modal">
<div class="modal-content">
<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>
</div>
<div class="modal-body">
@ -261,7 +310,7 @@ $adminBase = '';
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
</div>
<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>
</div>
</form>

View file

@ -8,6 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Mailer.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth();
$auth->requireAdmin();
@ -20,10 +21,11 @@ I18n::init();
$error = '';
$success = '';
// Send password reset link
if (isset($_GET['send_reset']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$targetUser = $db->fetchOne("SELECT * FROM users WHERE id=? AND is_active=1 AND password_hash IS NOT NULL", [(int)$_GET['send_reset']]);
// Send password reset link (POST statt GET: kein CSRF-Token in URLs/Referrern,
// keine versehentliche Ausloesung durch Link-Prefetching)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['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) {
try {
$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;
$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}");
$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) {
$error = 'Fehler beim Senden: ' . $e->getMessage();
}
} else {
$error = 'Benutzer nicht gefunden oder kein lokales Passwort.';
$error = __('reset_link_failed');
}
} else {
$error = __('error_csrf');
@ -60,6 +64,11 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
$userId = (int)$_POST['user_id'];
$isAdmin = isset($_POST['is_admin']) ? 1 : 0;
$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]);
$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]);
@ -83,8 +92,10 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
if (!empty($removedSites)) $changes[] = 'Zugriff entfernt von: ' . implode(', ', $removedSites);
if ($isAdmin && !$oldUser['is_admin']) $changes[] = 'Sie haben nun Zugriff auf alle Sites';
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');
flashSet(__('users_updated') . (!empty($changes) ? ' '.__('users_notified') : ''));
header('Location: users.php');
exit;
} 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 (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
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);
if (!$userId) throw new Exception('Benutzer konnte nicht erstellt werden');
if (!$userId) throw new Exception(__('error_user_create'));
if (!$isAdmin && !empty($siteIds)) {
foreach ($siteIds as $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");
$success = __('users_added');
flashSet(__('users_added'));
header('Location: users.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); }
}
}
// Delete user
if (isset($_GET['delete']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$deleteId = (int)$_GET['delete'];
// Delete user (POST + PRG)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_user'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$deleteId = (int)$_POST['delete_user'];
if ($deleteId === (int)$_SESSION['user_id']) {
$error = 'Sie können sich nicht selbst löschen';
$error = __('error_self_delete');
} else {
$db->query("DELETE FROM users WHERE id=?", [$deleteId]);
$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'); }
}
// Toggle user active
if (isset($_GET['toggle']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$toggleId = (int)$_GET['toggle'];
// Toggle user active (POST + PRG)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_user'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$toggleId = (int)$_POST['toggle_user'];
if ($toggleId === (int)$_SESSION['user_id']) {
$error = 'Sie können sich nicht selbst deaktivieren';
$error = __('error_self_deactivate');
} else {
$user = $db->fetchOne("SELECT is_active FROM users WHERE id=?", [$toggleId]);
if ($user) {
$newStatus = $user['is_active'] ? 0 : 1;
$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'); }
}
// 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator)
if (isset($_GET['reset_2fa']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$auth->disableTotp((int)$_GET['reset_2fa']);
$success = '2FA des Benutzers wurde zurückgesetzt.';
// POST + PRG wie die uebrigen state-aendernden Aktionen
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['reset_2fa'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$auth->disableTotp((int)$_POST['reset_2fa']);
flashSet('2FA des Benutzers wurde zurückgesetzt.');
header('Location: users.php');
exit;
} else { $error = __('error_csrf'); }
}
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$users = $db->fetchAll("SELECT * FROM users ORDER BY name");
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
$userSiteAccess = [];
@ -171,30 +195,67 @@ $currentPage = 'users';
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('users_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?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">
<h1 class="page-title"><?= __('users_title') ?></h1>
<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>
</div>
<?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 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; ?>
<div class="card">
<div class="card-header"><h2 class="card-title"><?= __('users_all') ?></h2></div>
<div class="card-body" style="padding:0;">
<?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: ?>
<div style="overflow-x:auto;">
<div class="table-container">
<table class="table table-stack">
<table class="table">
<thead>
<tr>
<th><?= __('label_name') ?></th>
@ -210,28 +271,28 @@ $currentPage = 'users';
<?php foreach ($users as $user): ?>
<?php $userSiteIds = array_column($userSiteAccess[$user['id']]??[], 'id'); ?>
<tr>
<td data-label="<?= __('label_name') ?>">
<td>
<strong><?= htmlspecialchars($user['name']) ?></strong>
<?php if ($user['id'] == $_SESSION['user_id']): ?>
<span class="badge badge-info"><?= __('users_you') ?></span>
<?php endif; ?>
</td>
<td data-label="<?= __('label_email') ?>"><?= htmlspecialchars($user['email']) ?></td>
<td data-label="<?= __('label_role') ?>">
<td><?= htmlspecialchars($user['email']) ?></td>
<td>
<?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: ?>
<span class="badge badge-info"><?= __('status_user') ?></span>
<?php endif; ?>
</td>
<td data-label="<?= __('label_status') ?>">
<td>
<?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: ?>
<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; ?>
</td>
<td data-label="<?= __('users_site_access') ?>">
<td>
<?php if ($user['is_admin']): ?>
<em style="color:var(--text-muted);"><?= __('users_all_sites') ?></em>
<?php elseif (!empty($userSiteAccess[$user['id']])): ?>
@ -242,7 +303,7 @@ $currentPage = 'users';
<em style="color:var(--text-muted);"><?= __('users_none') ?></em>
<?php endif; ?>
</td>
<td data-label="<?= __('users_last_login') ?>" style="font-size:13px;">
<td style="font-size:13px;">
<?php if ($user['last_login']): ?>
<?= date('d.m.Y H:i', strtotime($user['last_login'])) ?>
<?php else: ?>
@ -253,32 +314,49 @@ $currentPage = 'users';
<div class="action-btns">
<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') ?>">
<i class="fas fa-edit" aria-hidden="true"></i>
<i class="fas fa-edit"></i>
</button>
<?php if ($user['id'] != $_SESSION['user_id']): ?>
<a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-sm" title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>">
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i>
</a>
<form method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="toggle_user" value="<?= $user['id'] ?>">
<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'])): ?>
<a href="?send_reset=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-warning btn-sm" title="<?= __('users_reset_pw') ?>"
onclick="return confirm('Passwort-Reset-Link senden an <?= htmlspecialchars($user['email'], ENT_QUOTES) ?>?')">
<i class="fas fa-key" aria-hidden="true"></i>
</a>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= addslashes(__('confirm_send_reset', ['email' => $user['email']])) ?>')">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="send_reset" value="<?= $user['id'] ?>">
<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 if (!empty($user['totp_enabled'])): ?>
<a href="?reset_2fa=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-sm" title="2FA zurücksetzen"
onclick="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
<i class="fas fa-user-shield" aria-hidden="true"></i>
</a>
<form method="post" style="display:inline;"
onsubmit="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="reset_2fa" value="<?= $user['id'] ?>">
<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; ?>
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>"
onclick="return confirm('<?= __('js_confirm_delete_user') ?>')">
<i class="fas fa-trash" aria-hidden="true"></i>
</a>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= addslashes(__('confirm_delete_user')) ?>')">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="delete_user" value="<?= $user['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>
<?php endif; ?>
</div>
</td>
@ -287,12 +365,11 @@ $currentPage = 'users';
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
</div>
</main>
</div><!-- /main-content -->
<!-- Add User Modal -->
<div id="addUserModal" class="modal">
@ -342,7 +419,7 @@ $currentPage = 'users';
</div>
<div style="display:flex;gap:10px;margin-top:20px;">
<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 type="button" onclick="closeModal('addUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div>
@ -386,7 +463,7 @@ $currentPage = 'users';
</div>
<div style="display:flex;gap:10px;margin-top:20px;">
<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 type="button" onclick="closeModal('editUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div>
@ -395,7 +472,7 @@ $currentPage = 'users';
</div>
</div>
<div id="toast-container" role="status" aria-live="polite"></div>
<div id="toast-container"></div>
<script src="../assets/global.js"></script>
<script>
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; }
$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';
// 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-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output','w');
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($out,['Code','Name','Max. Geräte','Gültigkeit (Min)','Status','Genutzt','Erstellt','Läuft ab'],';');
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;
}
@ -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 ($syncFirst) {
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);
$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()); }
@ -83,12 +89,12 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
$siteId = (int)$_POST['site_id'];
$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; }
$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)) {
$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 {
echo json_encode(['success'=>false,'message'=>'Voucher konnte nicht gelöscht werden']);
echo json_encode(['success'=>false,'message'=>__('voucher_delete_failed')]);
}
} catch (Exception $e) {
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">
<title><?= __('vouchers_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?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>
<h1 class="page-title">
<?= __('vouchers_title') ?>
<span class="live-indicator"><span class="dot"></span>LIVE</span>
</h1>
<p class="page-subtitle"><?= __('vouchers_subtitle') ?></p>
</div>
<h1 class="page-title">
<?= __('vouchers_title') ?>
<span class="live-indicator"><span class="dot"></span>LIVE</span>
</h1>
<p class="page-subtitle"><?= __('vouchers_subtitle') ?></p>
</div>
<?php if (empty($sites)): ?>
<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>
<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>
<?php else: ?>
@ -167,8 +228,8 @@ $currentPage = 'vouchers';
<?php endforeach; ?>
</select>
<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>
<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('btn_refresh') ?>
<button id="refreshBtn" class="btn btn-success" onclick="loadVouchers(true)" disabled>
<i class="fas fa-sync-alt"></i> <?= __('btn_refresh') ?>
</button>
</div>
</div>
@ -187,25 +248,23 @@ $currentPage = 'vouchers';
<div class="card-header">
<h2 class="card-title" id="voucherListTitle">Vouchers</h2>
<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>
</div>
<div class="card-body" style="padding:0;">
<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>
<?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>
const csrfToken = '<?= $auth->getCsrfToken() ?>';
// Datums- und Zeitformat folgen der gewaehlten Sprache
const LOCALE = '<?= I18n::getLanguage() === 'en' ? 'en-GB' : 'de-DE' ?>';
let currentSiteId = null;
let allVouchers = [];
let currentFilter = 'all';
@ -220,7 +279,7 @@ async function loadVouchers(syncFirst=false) {
const searchInput = document.getElementById('searchInput');
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';
searchInput.style.display = 'none';
refreshBtn.disabled = true;
@ -229,9 +288,9 @@ async function loadVouchers(syncFirst=false) {
currentSiteId = siteId;
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 {
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');
csvBtn.style.display = 'inline-flex';
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 {
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';
}
} 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';
}
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() {
@ -295,7 +354,7 @@ function renderVouchers() {
const pageVouchers = vouchers.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE);
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;
}
@ -311,7 +370,7 @@ function renderVouchers() {
<button class="filter-btn ${currentFilter==='expired'?'active':''}" data-filter="expired" onclick="setFilter('expired')"><?= __('vouchers_filter_expired') ?> (${expiredCnt})</button>
</div>
</div>
<div class="table-container"><table class="table table-stack">
<div class="table-container"><table class="table">
<thead><tr>
<th><?= __('label_created') ?></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 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'
? `<span class="badge badge-warning"><i class="fas fa-user-check" aria-hidden="true"></i> <?= __('status_used') ?></span>`
: `<span class="badge badge-danger"><i class="fas fa-times" aria-hidden="true"></i> <?= __('status_expired') ?></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"></i> <?= __('status_expired') ?></span>`;
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 data-label="<?= __('label_code') ?>"><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','<?= __('js_copied') ?>')" title="<?= __('js_copy') ?>" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
<td class="voucher-note" data-label="<?= __('label_note') ?>" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
<td data-label="<?= __('label_status') ?>">${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 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 data-label="<?= __('label_actions') ?>" 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="deleteVoucher('${v._id}')" class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>"><i class="fas fa-trash" aria-hidden="true"></i></button>
<td><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><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" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
<td>${statusBadge}</td>
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
<td>${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 style="white-space:nowrap;">
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="Per E-Mail senden"><i class="fas fa-envelope"></i></button>
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button>
</td>
</tr>`;
});
@ -358,11 +417,11 @@ function renderVouchers() {
<span style="font-size:13px;color:var(--text-muted);">${<?= json_encode(__('vouchers_page_of')) ?>
.replace('{current}',currentPage).replace('{total}',totalPages)} (${vouchers.length})</span>
<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++) {
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>`;
}
@ -386,7 +445,7 @@ async function resendVoucher(voucherId, code) {
}
async function deleteVoucher(voucherId) {
if (!confirm('<?= __('js_confirm_delete_voucher') ?>')) return;
if (!confirm('<?= addslashes(__('confirm_delete_voucher')) ?>')) return;
const row = document.getElementById(`voucher-${voucherId}`);
if (row) row.classList.add('deleting');
try {

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,7 @@
/* === DARK MODE ===
Reihenfolge: ausdrueckliche Auswahl des Nutzers > Systemeinstellung. */
/* === DARK MODE === */
(function() {
const saved = localStorage.getItem('theme');
// Der Inline-Schnipsel im <head> hat das Theme bereits gesetzt diese
// Entscheidung wird hier nicht überschrieben.
const current = document.documentElement.getAttribute('data-theme');
const system = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', saved || current || system);
// Solange nichts ausgewaehlt wurde, folgt die Oberflaeche dem System.
if (!saved && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (localStorage.getItem('theme')) return;
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
if (typeof updateDarkModeBtn === 'function') updateDarkModeBtn();
});
}
const saved = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
})();
function toggleDarkMode() {
@ -31,12 +17,7 @@ function updateDarkModeBtn() {
const btn = document.getElementById('darkModeBtn');
if (!btn) return;
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const icon = btn.querySelector('i');
if (icon) {
icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
} else {
btn.textContent = isDark ? '☀' : '☾';
}
btn.textContent = isDark ? '☀️' : '🌙';
btn.title = isDark ? 'Light Mode' : 'Dark Mode';
}
@ -54,6 +35,9 @@ document.addEventListener('DOMContentLoaded', updateDarkModeBtn);
container.id = 'toast-container';
document.body.appendChild(container);
}
// Screenreader ueber neue Toasts informieren
container.setAttribute('role', 'status');
container.setAttribute('aria-live', 'polite');
}
return container;
}
@ -61,8 +45,8 @@ document.addEventListener('DOMContentLoaded', updateDarkModeBtn);
const icons = {
success: '✓',
error: '✕',
info: 'i',
warning: '!'
info: '',
warning: ''
};
window.showToast = function(type, title, message, duration) {
@ -111,13 +95,21 @@ document.addEventListener('DOMContentLoaded', function() {
if (overlay) overlay.addEventListener('click', closeMobileSidebar);
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 === */
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 === */

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",
"description": "Webbasiertes WLAN-Voucher-Management für UniFi OS",
"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": {
"php": ">=7.4"
},

View file

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

View file

@ -1,5 +1,12 @@
<?php
// 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');
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,
`is_active` TINYINT(1) DEFAULT 1,
`public_access` TINYINT(1) DEFAULT 0,
`ssl_verify` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
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
) 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` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
@ -121,7 +94,6 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL,
`user_id` INT,
`kiosk_id` INT NULL,
`voucher_code` VARCHAR(50) NOT NULL,
`voucher_name` VARCHAR(255) NOT NULL,
`max_uses` INT NOT NULL,
@ -138,8 +110,7 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
INDEX `idx_site` (`site_id`),
INDEX `idx_created` (`created_at`),
INDEX `idx_unifi_id` (`unifi_voucher_id`),
INDEX `idx_status` (`status`),
INDEX `idx_kiosk` (`kiosk_id`)
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `sessions` (
@ -177,6 +148,16 @@ CREATE TABLE IF NOT EXISTS `audit_log` (
INDEX `idx_created` (`created_at`)
) 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` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`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));"
APP_KEY: ""
TZ: Europe/Berlin
volumes:
# Hochgeladene Logos/Hintergruende ueberleben so ein Image-Update
- uploads:/var/www/html/uploads
depends_on:
db:
condition: service_healthy
@ -39,4 +36,3 @@ services:
volumes:
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/Auth.php';
require_once __DIR__ . '/includes/Mailer.php';
require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/I18n.php';
require_once __DIR__ . '/includes/Helpers.php';
$auth = new Auth();
if ($auth->isLoggedIn()) { header('Location: index.php'); exit; }
@ -17,8 +17,18 @@ I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_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 = '';
$success = '';
@ -35,7 +45,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} else {
$rl[] = $now;
$_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)
if ($user) {
@ -84,12 +103,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
<body class="app-body focus-page">
<div class="focus-card card">
<body>
<div class="box">
<?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<?php else: ?>
<h1><?= htmlspecialchars($appTitle) ?></h1>
<?php endif; ?>
@ -110,11 +149,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<label for="email"><?= __('reset_email_label') ?></label>
<input type="email" id="email" name="email" required autofocus placeholder="name@example.com">
</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>
<?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>
<script src="assets/global.js"></script>
</body>

View file

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

View file

@ -5,7 +5,9 @@ require_once __DIR__ . '/Crypto.php';
class Auth {
private $db;
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
private $sessionUser = false;
public function __construct() {
try {
$this->db = Database::getInstance();
@ -18,6 +20,9 @@ class Auth {
ini_set('session.cookie_httponly', 1);
ini_set('session.use_strict_mode', 1);
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)
try {
@ -243,6 +248,8 @@ class Auth {
private function recordLoginAttempt($ip, $email) {
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(
"INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)",
[$ip, $email]
@ -309,6 +316,12 @@ class Auth {
// Session setzen
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_email'] = $user['email'];
$_SESSION['user_name'] = $user['name'];
@ -331,6 +344,7 @@ class Auth {
// Ausloggen
public function logout() {
$this->sessionUser = null;
$_SESSION = [];
if (isset($_COOKIE[session_name()])) {
@ -340,6 +354,25 @@ class Auth {
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
public function isLoggedIn() {
if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) {
@ -354,24 +387,32 @@ class Auth {
return false;
}
// Deaktivierte/geloeschte Konten sofort aussperren
if ($this->loadSessionUser() === null) {
$this->logout();
return false;
}
return true;
}
// Prüfen ob Admin
// Prüfen ob Admin (live aus der DB, nicht aus dem Session-Cache)
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
public function getCurrentUser() {
if (!$this->isLoggedIn()) {
return null;
}
return $this->db->fetchOne(
"SELECT * FROM users WHERE id = ?",
[$_SESSION['user_id']]
);
return $this->loadSessionUser();
}
// Prüfen ob Benutzer Zugriff auf Site hat

View file

@ -114,4 +114,9 @@ class Crypto {
public static function isEncrypted($value) {
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

@ -1,339 +1,275 @@
<?php
class Mailer {
private $db;
private $smtpEnabled;
private $smtpHost;
private $smtpPort;
private $smtpUsername;
private $smtpPassword;
private $smtpEncryption;
private $fromEmail;
private $fromName;
public function __construct() {
$this->db = Database::getInstance();
$this->loadSettings();
}
private function loadSettings() {
$this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1';
$this->smtpHost = $this->db->getSetting('smtp_host', '');
$this->smtpPort = (int)$this->db->getSetting('smtp_port', '587');
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']);
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
}
public function sendRaw($to, $subject, $plainBody) {
return $this->send($to, $subject, $plainBody, false);
}
public function send($to, $subject, $body, $isHtml = false) {
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
$attempts = 2;
for ($i = 1; $i <= $attempts; $i++) {
if (!$this->smtpEnabled || empty($this->smtpHost)) {
$ok = $this->sendWithPhpMail($to, $subject, $body);
} else {
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
}
if ($ok) {
return true;
}
if ($i < $attempts) {
usleep(500000); // 0,5s vor erneutem Versuch
}
}
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
return false;
}
private function sendWithPhpMail($to, $subject, $body) {
$headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
$headers .= "Reply-To: {$this->fromEmail}\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
return mail($to, $subject, $body, $headers);
}
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
try {
// Verbindung aufbauen
$socket = $this->connectToSmtp();
// EHLO
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
// STARTTLS wenn nötig
if ($this->smtpEncryption === 'tls') {
$this->smtpCommand($socket, "STARTTLS");
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
}
// AUTH LOGIN
$this->smtpCommand($socket, "AUTH LOGIN");
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
// MAIL FROM
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
// RCPT TO
$this->smtpCommand($socket, "RCPT TO:<{$to}>");
// DATA
$this->smtpCommand($socket, "DATA");
// Headers
$message = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
$message .= "To: {$to}\r\n";
$message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
$message .= "MIME-Version: 1.0\r\n";
if ($isHtml) {
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
} else {
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
}
$message .= "\r\n";
// Body - bei Plain Text Zeilenumbrüche konvertieren
if (!$isHtml) {
$body = nl2br($body, false); // Für Plain Text
$body = str_replace('<br>', "\r\n", $body);
}
$message .= $body;
$message .= "\r\n.\r\n";
fwrite($socket, $message);
$response = fgets($socket);
// QUIT
$this->smtpCommand($socket, "QUIT");
fclose($socket);
return strpos($response, '250') === 0;
} catch (Exception $e) {
error_log("SMTP Error: " . $e->getMessage());
return false;
}
}
private function connectToSmtp() {
$context = stream_context_create([
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true
]
]);
if ($this->smtpEncryption === 'ssl') {
$host = 'ssl://' . $this->smtpHost;
} else {
$host = $this->smtpHost;
}
$socket = stream_socket_client(
$host . ':' . $this->smtpPort,
$errno,
$errstr,
30,
STREAM_CLIENT_CONNECT,
$context
);
if (!$socket) {
throw new Exception("SMTP Connection failed: $errstr ($errno)");
}
// Willkommensnachricht lesen
fgets($socket);
return $socket;
}
private function smtpCommand($socket, $command) {
fwrite($socket, $command . "\r\n");
$response = fgets($socket);
// Prüfen auf Fehler (4xx oder 5xx)
if (preg_match('/^[45]/', $response)) {
throw new Exception("SMTP Error: $response");
}
return $response;
}
// 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) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
$instructionHeader = $this->db->getSetting('instruction_header', '');
$instructionText = $this->db->getSetting('instruction_text', '');
// System-URL aus Einstellungen oder automatisch erkennen
$systemUrl = $this->db->getSetting('system_url', '');
if (empty($systemUrl)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$systemUrl = $protocol . '://' . $host . $scriptPath;
}
// Template aus Datenbank laden
$subjectTemplate = $this->db->getSetting('email_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}");
// Anleitung formatieren
$instructions = '';
if ($instructionText) {
$instructions = $instructionHeader . "\n" . $instructionText;
}
// Platzhalter ersetzen
$placeholders = [
'{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses),
'{VOUCHER_CODE}' => $voucherCode,
'{SITE_NAME}' => $siteName,
'{MAX_USES}' => $maxUses,
'{APP_TITLE}' => $appTitle,
'{INSTRUCTIONS}' => $instructions,
'{SYSTEM_URL}' => $systemUrl
];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
// Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln,
// sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren.
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate || strpos($bodyTemplate, '{VOUCHER_CARD}') !== false;
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
if ($isHtml) {
$body = $this->brandedHtml($appTitle, $body);
}
return $this->send($to, $subject, $body, $isHtml);
}
public function sendTestEmail($to) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
$subject = '[Test] E-Mail-Konfiguration ' . $appTitle;
$body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet.";
return $this->send($to, $subject, $body, false);
}
public function sendUserNotification($to, $userName, $changes) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
// System-URL aus Einstellungen oder automatisch erkennen
$systemUrl = $this->db->getSetting('system_url', '');
if (empty($systemUrl)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$systemUrl = $protocol . '://' . $host . $scriptPath;
}
// Template aus Datenbank laden
$subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert');
$bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
// Änderungen formatieren
$changesText = '';
foreach ($changes as $change) {
$changesText .= "$change\n";
}
// Platzhalter ersetzen
$placeholders = [
'{USER_NAME}' => $userName,
'{CHANGES}' => $changesText,
'{APP_TITLE}' => $appTitle,
'{SYSTEM_URL}' => $systemUrl
];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate;
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
if ($isHtml) {
$body = $this->brandedHtml($appTitle, $body);
}
return $this->send($to, $subject, $body, $isHtml);
}
/**
* Zeilenumbrüche aus dem Vorlagentext in HTML übernehmen, ohne bereits
* vorhandenes Markup (z. B. aus dem WYSIWYG-Editor) zu zerstören.
*/
private function textToHtml(string $body): string
{
if (preg_match('#<(p|div|ul|ol|h[1-6])[\s>]#i', $body)) {
return $body;
}
return nl2br($body, false);
}
<?php
class Mailer {
private $db;
private $smtpEnabled;
private $smtpHost;
private $smtpPort;
private $smtpUsername;
private $smtpPassword;
private $smtpEncryption;
private $smtpVerifySsl;
private $fromEmail;
private $fromName;
public function __construct() {
$this->db = Database::getInstance();
$this->loadSettings();
}
private function loadSettings() {
$this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1';
$this->smtpHost = $this->db->getSetting('smtp_host', '');
$this->smtpPort = (int)$this->db->getSetting('smtp_port', '587');
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
$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'));
}
public function sendRaw($to, $subject, $plainBody) {
return $this->send($to, $subject, $plainBody, false);
}
public function send($to, $subject, $body, $isHtml = false) {
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
$attempts = 2;
for ($i = 1; $i <= $attempts; $i++) {
if (!$this->smtpEnabled || empty($this->smtpHost)) {
$ok = $this->sendWithPhpMail($to, $subject, $body);
} else {
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
}
if ($ok) {
return true;
}
if ($i < $attempts) {
usleep(500000); // 0,5s vor erneutem Versuch
}
}
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
return false;
}
private function sendWithPhpMail($to, $subject, $body) {
$headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
$headers .= "Reply-To: {$this->fromEmail}\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
return mail($to, $subject, $body, $headers);
}
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
try {
// Hostname auch im CLI-Kontext (Cron) verfuegbar
$heloHost = $_SERVER['HTTP_HOST'] ?? (gethostname() ?: 'localhost');
// Verbindung aufbauen
$socket = $this->connectToSmtp();
// EHLO
$this->smtpCommand($socket, "EHLO " . $heloHost);
// STARTTLS wenn nötig
if ($this->smtpEncryption === 'tls') {
$this->smtpCommand($socket, "STARTTLS");
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
$this->smtpCommand($socket, "EHLO " . $heloHost);
}
// 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, base64_encode($this->smtpUsername));
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
}
// MAIL FROM
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
// RCPT TO
$this->smtpCommand($socket, "RCPT TO:<{$to}>");
// DATA
$this->smtpCommand($socket, "DATA");
// Headers
$message = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
$message .= "To: {$to}\r\n";
$message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
$message .= "MIME-Version: 1.0\r\n";
if ($isHtml) {
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
} else {
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
}
$message .= "\r\n";
// Zeilenumbrueche auf CRLF normalisieren (der fruehere
// nl2br/str_replace-Umweg hat Umbrueche verdoppelt)
$body = preg_replace("/\r\n|\r|\n/", "\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 .= "\r\n.\r\n";
fwrite($socket, $message);
$response = fgets($socket);
// QUIT
$this->smtpCommand($socket, "QUIT");
fclose($socket);
return strpos($response, '250') === 0;
} catch (Exception $e) {
error_log("SMTP Error: " . $e->getMessage());
return false;
}
}
private function connectToSmtp() {
// Zertifikatspruefung optional aktivierbar (Setting smtp_verify_ssl)
$context = stream_context_create([
'ssl' => [
'verify_peer' => $this->smtpVerifySsl,
'verify_peer_name' => $this->smtpVerifySsl,
'allow_self_signed' => !$this->smtpVerifySsl
]
]);
if ($this->smtpEncryption === 'ssl') {
$host = 'ssl://' . $this->smtpHost;
} else {
$host = $this->smtpHost;
}
$socket = stream_socket_client(
$host . ':' . $this->smtpPort,
$errno,
$errstr,
30,
STREAM_CLIENT_CONNECT,
$context
);
if (!$socket) {
throw new Exception("SMTP Connection failed: $errstr ($errno)");
}
// Willkommensnachricht lesen
fgets($socket);
return $socket;
}
private function smtpCommand($socket, $command) {
fwrite($socket, $command . "\r\n");
$response = fgets($socket);
// Prüfen auf Fehler (4xx oder 5xx)
if (preg_match('/^[45]/', $response)) {
throw new Exception("SMTP Error: $response");
}
return $response;
}
// Vordefinierte E-Mail-Templates
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
$instructionHeader = $this->db->getSetting('instruction_header', '');
$instructionText = $this->db->getSetting('instruction_text', '');
// System-URL aus Einstellungen oder automatisch erkennen
$systemUrl = $this->db->getSetting('system_url', '');
if (empty($systemUrl)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$systemUrl = $protocol . '://' . $host . $scriptPath;
}
// Template aus Datenbank laden
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang');
$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
$instructions = '';
if ($instructionText) {
$instructions = $instructionHeader . "\n" . $instructionText;
}
// Platzhalter ersetzen
$placeholders = [
'{VOUCHER_CODE}' => $voucherCode,
'{SITE_NAME}' => $siteName,
'{MAX_USES}' => $maxUses,
'{APP_TITLE}' => $appTitle,
'{INSTRUCTIONS}' => $instructions,
'{SYSTEM_URL}' => $systemUrl
];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
// HTML oder Plain Text prüfen
$isHtml = strip_tags($body) !== $body;
return $this->send($to, $subject, $body, $isHtml);
}
public function sendTestEmail($to) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
$subject = '[Test] E-Mail-Konfiguration ' . $appTitle;
$body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet.";
return $this->send($to, $subject, $body, false);
}
public function sendUserNotification($to, $userName, $changes) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
// System-URL aus Einstellungen oder automatisch erkennen
$systemUrl = $this->db->getSetting('system_url', '');
if (empty($systemUrl)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$systemUrl = $protocol . '://' . $host . $scriptPath;
}
// Template aus Datenbank laden
$subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert');
$bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
// Änderungen formatieren
$changesText = '';
foreach ($changes as $change) {
$changesText .= "$change\n";
}
// Platzhalter ersetzen
$placeholders = [
'{USER_NAME}' => $userName,
'{CHANGES}' => $changesText,
'{APP_TITLE}' => $appTitle,
'{SYSTEM_URL}' => $systemUrl
];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
// HTML oder Plain Text prüfen
$isHtml = strip_tags($body) !== $body;
return $this->send($to, $subject, $body, $isHtml);
}
}

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 $sessionCookie = null;
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->username = $username;
$this->password = $password;
$this->siteId = $siteId;
$this->sslVerify = (bool)$sslVerify;
$this->cookieFile = tempnam(sys_get_temp_dir(), 'UNIFI_');
}
@ -41,7 +44,8 @@ class UniFiController {
'password' => $this->password
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
CURLOPT_COOKIEJAR => $this->cookieFile,
CURLOPT_COOKIEFILE => $this->cookieFile,
CURLOPT_TIMEOUT => 10,
@ -116,7 +120,8 @@ class UniFiController {
$options = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => $headers
@ -155,13 +160,31 @@ class UniFiController {
return json_decode($response, true);
}
// Voucher erstellen
// Einzelnen Voucher erstellen
// $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
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 = [
'cmd' => 'create-voucher',
'expire' => (int)$expireMinutes,
'n' => 1,
'n' => $count,
'note' => $voucherName,
'quota' => (int)$maxUses
];
@ -182,51 +205,51 @@ class UniFiController {
if (!isset($response['data'][0]['create_time'])) {
throw new Exception("Voucher konnte nicht erstellt werden");
}
// Voucher-Code abrufen. WICHTIG: getVouchers() liefert die Voucher
// 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();
$createTime = $response['data'][0]['create_time'];
if (empty($vouchers)) {
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
}
$all = $this->getVouchers();
$latestVoucher = null;
foreach ($vouchers as $voucher) {
// Nur Voucher mit passender Notiz beruecksichtigen
if (($voucher['note'] ?? null) !== $voucherName) {
continue;
}
if ($latestVoucher === null
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
$latestVoucher = $voucher;
// Exakte Treffer: gleiche note UND die vom Controller gemeldete create_time
$matches = [];
foreach ($all as $voucher) {
if (($voucher['note'] ?? null) === $voucherName
&& ($voucher['create_time'] ?? null) == $createTime) {
$matches[] = $voucher;
}
}
// Fallback: falls keine note-Uebereinstimmung (z.B. Sonderzeichen),
// 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;
// Fallback: nur note matchen (falls der Controller create_time leicht
// abweichend meldet), neueste zuerst, auf $count begrenzen.
if (empty($matches)) {
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);
}
if ($latestVoucher === null || empty($latestVoucher['code'])) {
$result = [];
foreach ($matches as $voucher) {
if (empty($voucher['code'])) {
continue;
}
$result[] = [
'code' => $voucher['code'],
'formatted_code' => $this->formatVoucherCode($voucher['code']),
'unifi_id' => $voucher['_id'] ?? null,
'create_time' => $voucher['create_time'] ?? null
];
}
if (empty($result)) {
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
}
return [
'code' => $latestVoucher['code'],
'formatted_code' => $this->formatVoucherCode($latestVoucher['code']),
'unifi_id' => $latestVoucher['_id'] ?? null,
'create_time' => $latestVoucher['create_time'] ?? null
];
return $result;
}
// Alle Voucher abrufen
@ -320,9 +343,9 @@ class UniFiController {
}
// Verbindung testen
public static function testConnection($controllerUrl, $username, $password, $siteId) {
public static function testConnection($controllerUrl, $username, $password, $siteId, $sslVerify = false) {
try {
$controller = new self($controllerUrl, $username, $password, $siteId);
$controller = new self($controllerUrl, $username, $password, $siteId, $sslVerify);
$controller->login();
return true;
} catch (Exception $e) {
@ -351,17 +374,26 @@ class UniFiController {
// Alle aktuellen UniFi-IDs sammeln
$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) {
$unifiIds[] = $voucher['_id'];
// Status zählen
$stats[$voucher['status']]++;
// Prüfen ob Voucher bereits existiert
$existing = $db->fetchOne(
"SELECT id, status, used_count FROM vouchers WHERE unifi_voucher_id = ? AND site_id = ?",
[$voucher['_id'], $dbSiteId]
);
$existing = isset($existingMap[$voucher['_id']])
? ['id' => $existingMap[$voucher['_id']]]
: null;
$expiresAt = date('Y-m-d H:i:s', $voucher['expire_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 I18n to be initialized.
*/
require_once __DIR__ . '/Ui.php';
$currentPage = $currentPage ?? '';
$faviconUrl = isset($db) ? $db->getSetting('favicon_url', '') : '';
$currentUser = isset($auth) ? $auth->getCurrentUser() : null;
$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>
<body>
<a class="skip-link" href="#main-content"><?= __('a11y_skip') ?></a>
<!-- Sidebar overlay for mobile -->
<div class="sidebar-overlay" onclick="closeMobileSidebar()"></div>
<aside class="sidebar" id="adminSidebar" aria-label="<?= __('nav_administration') ?>">
<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">
<div class="header-left">
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" aria-label="<?= __('a11y_menu') ?>" title="<?= __('a11y_menu') ?>">
<i class="fas fa-bars" aria-hidden="true"></i>
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" title="Menu">
<i class="fas fa-bars"></i>
</button>
<div class="breadcrumb">
<span><?= __('nav_administration') ?></span>
<span class="sep">/</span>
<span class="current"><?= htmlspecialchars($currentLabel) ?></span>
<div class="header-title">
<i class="fas fa-shield-alt" style="color: var(--accent);"></i>
<?= __('nav_administration') ?>
</div>
</div>
<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): ?>
<button class="lang-btn <?= $lang === $code ? 'active' : '' ?>"
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
<?php endforeach; ?>
</div>
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
<i class="fas fa-moon" aria-hidden="true"></i>
</button>
<a href="<?= $rootBase ?>index.php" class="btn btn-secondary">
<i class="fas fa-arrow-left" aria-hidden="true"></i>
<!-- Dark mode toggle -->
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">🌙</button>
<!-- Back link -->
<a href="<?= $adminBase ?? '../' ?>index.php" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
<span class="hide-mobile"><?= __('nav_back') ?></span>
</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>
</header>
</div>
<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