Compare commits

..

No commits in common. "main" and "redesign/design-system" have entirely different histories.

126 changed files with 1426 additions and 6232 deletions

15
.gitattributes vendored
View file

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

View file

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

View file

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

View file

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

View file

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

267
Readme.md
View file

@ -4,15 +4,12 @@
**Webbasiertes WLAN-Voucher-Management für UniFi OS** mit Multi-Site-Support, Benutzerverwaltung, Microsoft-365-Login und integriertem Auto-Updater. **Webbasiertes WLAN-Voucher-Management für UniFi OS** mit Multi-Site-Support, Benutzerverwaltung, Microsoft-365-Login und integriertem Auto-Updater.
Entwickelt von **[Loheide.eu](https://loheide.eu)**
![PHP](https://img.shields.io/badge/PHP-7.4%2B-777BB4?logo=php&logoColor=white) ![PHP](https://img.shields.io/badge/PHP-7.4%2B-777BB4?logo=php&logoColor=white)
![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white)
![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white) ![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white)
![License](https://img.shields.io/badge/Lizenz-MIT-green) ![License](https://img.shields.io/badge/Lizenz-MIT-green)
![Version](https://img.shields.io/badge/Version-2.8.0-blueviolet) ![Version](https://img.shields.io/badge/Version-2.5.0-blueviolet)
![Tests](https://img.shields.io/badge/Tests-PHPUnit%20%2B%20PHPStan-brightgreen) ![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg)
[![Repository](https://img.shields.io/badge/Code-git.loheide.cloud-4b3ec4)](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool)
</div> </div>
@ -28,8 +25,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
## ✨ Features ## ✨ Features
- 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand - 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand
- 🖥️ **Display-Seiten (Kiosk)** öffentliche Seite je Site, an der Gäste sich mit einem Klick selbst einen Zugang holen
- 🎨 **Jede Display-Seite eigenständig gestaltbar** Logo, Hintergrundbild, Akzentfarbe, helle oder dunkle Karte
- 📦 **Bulk-Erstellung** bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout - 📦 **Bulk-Erstellung** bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout
- 🧩 **Voucher-Profile/Templates** vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl - 🧩 **Voucher-Profile/Templates** vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl
- 🏢 **Multi-Site-Support** beliebig viele UniFi-Standorte zentral verwalten - 🏢 **Multi-Site-Support** beliebig viele UniFi-Standorte zentral verwalten
@ -52,11 +47,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
- 🌍 **Öffentlicher Modus** optional ohne Login nutzbar (mit CSRF-Schutz & Throttle) - 🌍 **Öffentlicher Modus** optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
- 🎨 **Einheitliches Design-System** ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark) - 🎨 **Einheitliches Design-System** ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark)
- 🏷️ **Login-Seite individualisierbar** Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf - 🏷️ **Login-Seite individualisierbar** Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf
- 🖌️ **Eigene Markenfarben** Akzentfarbe, Verlauf und Eckenradius wirken auf die gesamte Oberfläche
- ⬆️ **Bild-Upload** für Logo, Favicon und Login-Hintergrund (kein externes Hosting nötig)
- 🔒 **Keine externen CDNs** Schrift, Icons, Diagramme und Editor werden lokal ausgeliefert (DSGVO, Offline-Netze)
- ♿ **Barrierearm** Kontraste nach WCAG AA, Sprungmarke, aria-Beschriftungen, `prefers-reduced-motion`
- 📱 **Mobil nutzbar** Tabellen werden auf schmalen Geräten zu Karten
- 🌗 **Dark Mode** umschaltbar, Einstellung wird im Browser gespeichert - 🌗 **Dark Mode** umschaltbar, Einstellung wird im Browser gespeichert
- 🌐 **Mehrsprachig** Deutsch / Englisch per Umschalter (`lang/`) - 🌐 **Mehrsprachig** Deutsch / Englisch per Umschalter (`lang/`)
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay - 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
@ -85,18 +75,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
<img src="docs/screenshots/settings-login.png" alt="Einstellungen der Login-Seite" width="48%"> <img src="docs/screenshots/settings-login.png" alt="Einstellungen der Login-Seite" width="48%">
</div> </div>
### Display-Seite für Gäste
<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"> <div align="center">
<img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis mit QR-Code" width="48%"> <img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis mit QR-Code" width="48%">
<img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%"> <img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%">
@ -114,11 +92,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
<img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%"> <img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%">
</div> </div>
<div align="center">
<img src="docs/screenshots/settings-branding.png" alt="Markenfarben einstellen" width="48%">
<img src="docs/screenshots/mobile-vouchers.png" alt="Ansicht auf dem Smartphone" width="22%">
</div>
### REST-API, 2FA & Integrationen ### REST-API, 2FA & Integrationen
<div align="center"> <div align="center">
@ -157,8 +130,8 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
## 🚀 Installation ## 🚀 Installation
```bash ```bash
git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git git clone https://github.com/friloo/unifi-voucher-tool.git
cd Unifi-Voucher-Tool cd unifi-voucher-tool
``` ```
1. Dateien auf den Webserver hochladen 1. Dateien auf den Webserver hochladen
@ -228,64 +201,6 @@ Während eines Updates wird die Anwendung kurz in den **Wartungsmodus** versetzt
--- ---
## 🖥️ Display-Seiten für Gäste
Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine **öffentliche Seite**
anlegen, die auf einem Bildschirm oder Tablet läuft. Gäste tippen auf einen
Knopf und bekommen sofort einen eigenen Zugangscode ohne Anmeldung, ohne
Personal am Tresen.
**Anlegen:** Administration → **Display-Seiten** → *Display-Seite anlegen*
<div align="center">
<img src="docs/screenshots/kiosks-admin.png" alt="Verwaltung der Display-Seiten" width="80%">
</div>
| Einstellung | Wirkung |
|---|---|
| Site | für welchen Standort die Codes erzeugt werden |
| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) |
| Überschrift / Text | was auf dem Bildschirm steht |
| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) |
| Wartezeit | Abstand zwischen zwei Codes an diesem Display |
| Anzeigedauer | danach springt der Bildschirm automatisch zurück |
Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich
kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und
jederzeit erneuern der alte Link ist dann sofort ungültig. Den Link nicht
öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen.
Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite
zeigt. Gäste können sie damit **am eigenen Handy** öffnen praktisch bei
Bildschirmen ohne Touch.
### Jede Seite eigenständig gestalten
Jede Display-Seite bringt ihr eigenes Erscheinungsbild mit das Hotel am
Empfang sieht anders aus als der Tagungsraum nebenan:
| Einstellung | Wirkung |
|---|---|
| Logo | eigenes Logo auf der Karte (leer = Logo aus den Einstellungen) |
| Hintergrundbild | formatfüllend hinter der Karte, z. B. ein Foto des Hauses |
| Abdunklung | 090 % dunkle Ebene über dem Bild, damit die Karte lesbar bleibt |
| Akzentfarbe | färbt den Knopf dieser Seite (leer = Farbe aus dem Design-Tab) |
| Karte | hell oder dunkel auf Fotos wirkt die dunkle Karte meist ruhiger |
Logo und Hintergrund lassen sich direkt hochladen (PNG, JPG, WEBP, GIF, SVG bis
3 MB) oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden
die hochgeladenen Dateien mit.
Die ausgegebenen Codes erscheinen normal in *Live Vouchers*, im *Reporting* und
im *Audit-Log* (Aktion „Voucher am Display geholt"), sodass jederzeit
nachvollziehbar bleibt, woher ein Zugang stammt.
> Display-Seiten funktionieren unabhängig vom globalen öffentlichen Modus der
> geheime Link ist der Zugang. Webhook-Benachrichtigungen werden für diese Codes
> bewusst **nicht** ausgelöst, sonst wäre der Slack-Kanal voll.
---
## ⚙️ Konfiguration ## ⚙️ Konfiguration
### `config.php` ### `config.php`
@ -334,44 +249,21 @@ gemeinsames Stylesheet: **`assets/global.css`**.
- **Dark Mode** ausschließlich über Tokens keine `!important`-Overrides mehr - **Dark Mode** ausschließlich über Tokens keine `!important`-Overrides mehr
- **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback - **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback
### Markenfarben ohne Code Eigenes Branding lässt sich meist mit wenigen Zeilen umsetzen z. B. in einer
eigenen CSS-Datei oder direkt in `assets/global.css`:
Unter **Administration → Einstellungen → Design** lassen sich Akzentfarbe
(hell und dunkel), Markenverlauf und Eckenradius setzen. Abgeleitete Töne
Hover, weiche Flächen, Rahmen, Fokusring berechnet das System per `color-mix`
aus der Grundfarbe; eine Farbe genügt also. Eine Live-Vorschau zeigt Button,
Badge, Chip und Logo-Kachel sofort im neuen Ton.
Die Werte landen als schlanker `:root`-Override im Seitenkopf und gelten überall,
auch auf Login-Seite, Installer und Updater. Wer lieber in CSS arbeitet, kann
dieselben Variablen weiterhin in `assets/global.css` überschreiben:
```css ```css
:root { :root {
--accent: #0f766e; /* Primärfarbe (Buttons, aktive Navigation) */ --accent: #0f766e; /* Primärfarbe (Buttons, aktive Navigation) */
--accent-hover: #0d5f59;
--accent-soft: #e6f4f2; /* Flächen für aktive Zustände */
--brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%); --brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%);
--r-lg: 14px; /* Eckenradius für Cards */ --r-lg: 14px; /* Eckenradius für Cards */
} }
``` ```
### Bilder hochladen Logo und Favicon werden nicht über CSS, sondern unter
**Administration → Einstellungen → Allgemein** gesetzt.
Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich direkt hochladen
alternativ bleibt das URL-Feld bestehen. Die Dateien landen unter `uploads/`
(Docker: eigenes Volume, siehe unten). Erlaubt sind PNG, JPG, WEBP, GIF und SVG
bis 3 MB; SVGs werden vor dem Speichern von Skripten und externen Verweisen
befreit, und im Upload-Ordner sperrt eine `.htaccess` die PHP-Ausführung.
### Assets ohne Drittanbieter
Schrift (Inter), Icons (Font Awesome), Diagramme (Chart.js), QR-Codes und der
WYSIWYG-Editor (TinyMCE) liegen unter `assets/vendor/` und kommen vom eigenen
Server. Das hält Besucher-IPs bei Ihnen und die Oberfläche funktioniert auch
dort, wo das Netz keinen Weg nach außen hat. Details und Aktualisierungs-Hinweise:
[`assets/vendor/README.md`](assets/vendor/README.md).
Alle Asset-URLs tragen einen Versionsstempel (`?v=…`), damit Browser nach einem
Update nicht die alten Dateien aus dem Cache verwenden.
### Login-Seite individualisieren ### Login-Seite individualisieren
@ -411,30 +303,13 @@ Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
| **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout | | **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout |
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) | | **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern Empfohlene zusätzliche Härtung am Server:
(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal
liegen, erlaubt die CSP nur noch die eigene Herkunft externe Verbindungen
bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie
`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene
`.htaccess`-Dateien.
> **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst ```apache
> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image # .htaccess sensible Dateien sperren (wird vom Installer erzeugt)
> erledigt das bereits. <FilesMatch "^(config\.php|database\.sql|install\.php|test\.php|m365_debug\.php|.*\.md)$">
Require all denied
Für **Nginx** entspricht das: </FilesMatch>
```nginx
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always;
location ~ ^/(includes|tools|tests)/ { deny all; }
location ~ ^/updater/(storage|migrations)/ { deny all; }
location ~ ^/(config\.php|database\.sql)$ { deny all; }
location ^~ /uploads/ { location ~ \.php$ { deny all; } }
``` ```
```sql ```sql
@ -557,108 +432,6 @@ Das Schema wird beim ersten Start automatisch in MariaDB geladen; danach den
Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV
setzen (`DB_*`, `APP_KEY`). setzen (`DB_*`, `APP_KEY`).
Hochgeladene Logos und Hintergründe liegen im Volume `uploads` und überstehen
damit ein Image-Update. Bei eigener Apache-/Nginx-Installation muss `uploads/`
für den Webserver beschreibbar sein:
```bash
chown -R www-data:www-data uploads updater/storage
```
---
## 🧪 Entwicklung
Für Screenshots und einen schnellen Durchlauf aller Seiten gibt es eine
Demo-Instanz **ohne Datenbank** `Database` und `Auth` werden durch Stubs mit
festen Beispieldaten ersetzt:
```bash
python3 tools/demo/build.py /tmp/uvt-demo
php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
# Bilder in docs/screenshots neu erzeugen (benötigt headless Chromium)
CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py
```
Details und die verfügbaren Demo-Zustände: [`tools/README.md`](tools/README.md).
Tests und statische Analyse:
```bash
composer install
vendor/bin/phpunit # 29 Tests (Crypto, TOTP, API-Keys, Upload, Ui)
vendor/bin/phpstan analyse # Level 5
```
Die Versionsnummer steht in der Datei **`VERSION`** im Projektstamm. Sie wird
im Admin-Bereich unten in der Seitenleiste angezeigt und benennt das
Release-Paket für eine neue Version also dort (und im Badge oben) anheben.
Die Pipeline (`.github/workflows/ci.yml`) führt zusätzlich einen
Syntax-Check über alle PHP-Dateien aus und prüft, ob `lang/de.php` und
`lang/en.php` dieselben Schlüssel enthalten und jeder im Code verwendete
Schlüssel existiert. Dieselben Schritte lassen sich lokal ausführen.
---
## 📦 Repository & Mitwirken
Der Quellcode liegt auf der eigenen Forgejo-Instanz **nicht** auf GitHub:
**<https://git.loheide.cloud/friloo/Unifi-Voucher-Tool>**
```bash
# HTTPS
git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git
# SSH (Port 2222)
git clone ssh://git@git.loheide.cloud:2222/friloo/Unifi-Voucher-Tool.git
```
### Fertige Pakete
Jeder Merge nach `main` erzeugt automatisch ein installierbares ZIP
(`.github/workflows/release.yml`) und hängt es an das rollende Vorab-Release
**`latest-main`**:
**<https://git.loheide.cloud/friloo/Unifi-Voucher-Tool/releases>**
Das Paket enthält nur die Laufzeit-Dateien `docs/`, `tests/`, `tools/` und die
CI-Konfiguration bleiben draußen (rund 1,4 MB). Wird ein Tag `v*` gepusht,
entsteht daraus ein reguläres Release mit derselben Mechanik.
> Beim **Update einer bestehenden Installation** `config.php`, `uploads/` und
> `updater/storage/` nicht überschreiben oder gleich den eingebauten Updater
> verwenden, der genau diese Pfade schützt.
Voraussetzung ist ein registrierter **Forgejo-Actions-Runner**; ohne Runner
bleiben die Workflows in der Warteschlange stehen. Compose-Datei und Anleitung
dafür liegen in [`tools/runner/`](tools/runner/README.md).
### Mitwirken
Fehlerberichte und Änderungsvorschläge laufen über die **Issues** und **Pull
Requests** dort. Für die Kommandozeile eignet sich [`tea`](https://gitea.com/gitea/tea),
die Gitea-/Forgejo-CLI:
```bash
tea pr create # Pull Request öffnen
tea issues ls # offene Tickets ansehen
```
> Die Workflows unter `.github/workflows/` werden von Forgejo Actions
> mitgelesen; das Badge oben ist bewusst statisch, solange kein Runner
> registriert ist. `docker-publish.yml` veröffentlicht nach `ghcr.io` und
> stammt noch aus der GitHub-Zeit für den Forgejo-Betrieb entweder auf die
> eigene Registry umstellen oder entfernen.
Der **Auto-Updater** ist davon unabhängig: er zieht seine Pakete über
`update.loheide.eu` (Channels `stable` und `development`) und nicht direkt aus
dem Git-Hoster.
---
## 🗺️ Roadmap ## 🗺️ Roadmap
- [x] Voucher-Templates (vordefinierte Laufzeiten) - [x] Voucher-Templates (vordefinierte Laufzeiten)
@ -674,17 +447,11 @@ dem Git-Hoster.
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint - [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan) - [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
- [x] Gemeinsames Design-System für Frontend, Login und Backend - [x] Gemeinsames Design-System für Frontend, Login und Backend
- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite)
- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
- [x] Vollständige englische Übersetzung des Admin-Bereichs
- [x] Display-Seiten: Selbstbedienung für Gäste am Bildschirm
--- ---
<div align="center"> <div align="center">
**Version 2.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** **Version 2.5.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
Entwickelt von **[Loheide.eu](https://loheide.eu)**
</div> </div>

View file

@ -1 +0,0 @@
2.8.0

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,7 +27,7 @@
/* Text */ /* Text */
--text-primary: #101625; --text-primary: #101625;
--text-secondary: #525c6e; --text-secondary: #525c6e;
--text-muted: #6b7280; --text-muted: #8a93a3;
--text-inverse: #ffffff; --text-inverse: #ffffff;
/* Linien */ /* Linien */
@ -107,7 +107,7 @@
--text-primary: #e9ecf3; --text-primary: #e9ecf3;
--text-secondary: #a2abbd; --text-secondary: #a2abbd;
--text-muted: #8a93a6; --text-muted: #6f7889;
--text-inverse: #0a0c11; --text-inverse: #0a0c11;
--border-color: #232838; --border-color: #232838;
@ -562,28 +562,7 @@ input:focus, select:focus, textarea:focus, .input:focus {
} }
input:disabled, select:disabled, textarea:disabled { background: var(--bg-hover); color: var(--text-muted); cursor: not-allowed; } input:disabled, select:disabled, textarea:disabled { background: var(--bg-hover); color: var(--text-muted); cursor: not-allowed; }
input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; } input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; }
input[type="file"] { input[type="file"] { font-size: 13px; color: var(--text-secondary); }
width: 100%;
font-size: 13px;
color: var(--text-secondary);
}
input[type="file"]::file-selector-button {
margin-right: 10px;
padding: 7px 13px;
border: 1px solid var(--border-color);
border-radius: var(--r-sm);
background: var(--bg-card);
color: var(--text-primary);
font: inherit;
font-size: 12.5px;
font-weight: 550;
cursor: pointer;
transition: background-color .15s, border-color .15s;
}
input[type="file"]::file-selector-button:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
}
.chk, label.chk { .chk, label.chk {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
@ -1081,22 +1060,6 @@ input[type="file"]::file-selector-button:hover {
border-radius: var(--r-xl); border-radius: var(--r-xl);
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-xl);
} }
/* Bildfeld: Vorschau, Upload und URL nebeneinander */
.image-field { display: flex; gap: 14px; align-items: flex-start; }
.image-preview {
width: 84px; height: 64px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
padding: 6px;
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-radius: var(--r-md);
color: var(--text-muted);
overflow: hidden;
}
.image-preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-field-controls { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.image-field-controls .chk { margin: 0; font-size: 12.5px; color: var(--text-secondary); }
/* Farbwähler mit Hex-Eingabe */ /* Farbwähler mit Hex-Eingabe */
.color-field { display: flex; align-items: center; gap: 8px; } .color-field { display: flex; align-items: center; gap: 8px; }
.color-field input[type="color"] { .color-field input[type="color"] {
@ -1531,253 +1494,6 @@ input.search-bar, .site-selector .search-bar { min-width: 240px; width: auto; fl
.row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } .row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
@media (max-width: 720px) { .row, .row3 { grid-template-columns: 1fr; } } @media (max-width: 720px) { .row, .row3 { grid-template-columns: 1fr; } }
/* QR-Code auf der Druckkarte */
.print-qr { display: flex; justify-content: center; line-height: 0; }
.print-qr img, .print-qr canvas { display: block; }
/* Entwicklerhinweis im Seitenfuß */
.app-credit {
margin: 0;
font-size: 11.5px;
color: var(--text-muted);
}
.app-credit a { color: var(--text-secondary); text-decoration: none; }
.app-credit a:hover { color: var(--accent); text-decoration: underline; }
.sidebar-foot .app-credit { margin-top: 10px; padding: 0 4px; text-align: center; }
.app-footer { max-width: 560px; margin: 20px auto 0; text-align: center; }
.login-container .app-credit { margin-top: 26px; text-align: center; }
.focus-card .app-credit { margin-top: 22px; text-align: center; }
.wrap .app-credit { margin-top: 18px; text-align: center; }
/* Sprungmarke zum Inhalt (nur bei Tastaturfokus sichtbar) */
.skip-link {
position: absolute;
left: 12px;
top: -60px;
z-index: 999;
padding: 10px 16px;
background: var(--bg-card);
border: 1px solid var(--accent);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
color: var(--accent);
font-size: 13.5px;
font-weight: 600;
transition: top .15s;
}
.skip-link:focus { top: 12px; }
/* Tabellen, die auf schmalen Geräten zu Karten werden.
Die Spaltenüberschrift steht dann als data-label vor dem Wert. */
@media (max-width: 720px) {
.table-stack thead { display: none; }
.table-stack, .table-stack tbody, .table-stack tr, .table-stack td { display: block; width: 100%; }
.table-stack tr {
margin-bottom: 10px;
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: var(--r-md);
background: var(--bg-card);
}
.table-stack td {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
text-align: right;
}
.table-stack tr td:last-child { border-bottom: none; }
.table-stack td::before {
content: attr(data-label);
flex-shrink: 0;
color: var(--text-muted);
font-size: 11.5px;
font-weight: 620;
text-transform: uppercase;
letter-spacing: .04em;
text-align: left;
}
.table-stack td:not([data-label])::before { content: ''; }
.table-stack .details-cell, .table-stack .voucher-note { max-width: none; white-space: normal; }
}
/* Nutzer, die Bewegung reduzieren möchten, bekommen keine Animationen. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
scroll-behavior: auto !important;
}
}
/* =========================================================================
16. KIOSK öffentliche Display-Seite
Große Typografie: der Code muss aus einigen Metern Entfernung lesbar sein.
========================================================================= */
.kiosk-body {
min-height: 100vh;
display: flex;
flex-direction: column;
background:
radial-gradient(900px 500px at 12% -10%, var(--accent-soft), transparent 62%),
radial-gradient(700px 460px at 100% 0%, rgba(139,92,246,.10), transparent 64%),
var(--bg-body);
}
.kiosk-stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
.kiosk-card {
width: 100%;
max-width: 680px;
padding: 48px 44px;
text-align: center;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--r-xl);
box-shadow: var(--shadow-xl);
}
.kiosk-logo { max-height: 84px; max-width: 320px; margin: 0 auto 26px; display: block; }
.kiosk-logo-sm { max-height: 52px; margin-bottom: 20px; }
/* Eigenes Hintergrundbild der Display-Seite: Bild formatfüllend, darüber
eine abdunkelnde Ebene, damit die Karte sich abhebt. */
.kiosk-body.has-background {
--kiosk-overlay: 0.45;
position: relative;
background: var(--kiosk-bg) center / cover no-repeat fixed;
}
.kiosk-body.has-background::before {
content: '';
position: fixed;
inset: 0;
background: rgba(8, 11, 18, var(--kiosk-overlay));
pointer-events: none;
}
.kiosk-body.has-background > * { position: relative; z-index: 1; }
.kiosk-body.has-background .kiosk-card { box-shadow: 0 40px 80px -30px rgba(0,0,0,.6); }
.kiosk-body.has-background .kiosk-footer .app-credit,
.kiosk-body.has-background .kiosk-footer .app-credit a { color: rgba(255,255,255,.65); }
/* Dunkle Karte wirkt auf Fotos oft ruhiger als eine weiße Fläche. */
.kiosk-dark .kiosk-card {
background: rgba(12,15,22,.86);
border-color: rgba(255,255,255,.12);
backdrop-filter: blur(6px);
color: #f4f6fb;
}
.kiosk-dark .kiosk-card h1,
.kiosk-dark .kiosk-code { color: #ffffff; }
.kiosk-dark .kiosk-subline,
.kiosk-dark .kiosk-qr-label { color: rgba(255,255,255,.76); }
.kiosk-dark .kiosk-countdown { color: rgba(255,255,255,.5); }
.kiosk-dark .kiosk-meta span {
background: rgba(255,255,255,.10);
border-color: rgba(255,255,255,.16);
color: rgba(255,255,255,.88);
}
.kiosk-dark .kiosk-phone { border-top-color: rgba(255,255,255,.14); color: rgba(255,255,255,.7); }
.kiosk-dark .kiosk-eyebrow { background: rgba(74,222,128,.16); border-color: rgba(74,222,128,.32); color: #86efac; }
.kiosk-dark .btn-secondary {
background: rgba(255,255,255,.10);
border-color: rgba(255,255,255,.18);
color: #f4f6fb;
}
.kiosk-dark .btn-secondary:hover { background: rgba(255,255,255,.18); color: #fff; }
.kiosk-mark { width: 62px; height: 62px; margin: 0 auto 26px; font-size: 26px; }
.kiosk-headline {
font-size: clamp(30px, 4.6vw, 46px);
line-height: 1.15;
letter-spacing: -0.03em;
}
.kiosk-subline {
margin-top: 14px;
font-size: clamp(16px, 1.8vw, 20px);
color: var(--text-secondary);
}
.kiosk-alert { justify-content: center; margin: 24px 0 0; text-align: left; }
.kiosk-button {
width: 100%;
margin-top: 34px;
padding: 26px 32px;
gap: 14px;
font-size: clamp(20px, 2.4vw, 26px);
font-weight: 620;
border-radius: var(--r-lg);
}
.kiosk-button i { font-size: 0.95em; }
.kiosk-phone {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 34px;
padding-top: 26px;
border-top: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 13.5px;
}
.kiosk-phone-qr { line-height: 0; }
.kiosk-phone-qr img, .kiosk-phone-qr canvas { border-radius: var(--r-sm); }
.kiosk-result { max-width: 880px; }
.kiosk-eyebrow {
display: inline-flex; align-items: center; gap: 9px;
padding: 6px 16px;
border-radius: var(--r-pill);
background: var(--success-soft);
border: 1px solid var(--success-border);
color: var(--success);
font-size: 14px; font-weight: 600;
}
.kiosk-code {
margin: 26px 0 18px;
font-family: var(--font-mono);
/* Muss aus einigen Metern lesbar sein, aber in einer Zeile bleiben. */
font-size: clamp(38px, 7vw, 76px);
font-weight: 700;
letter-spacing: .06em;
white-space: nowrap;
line-height: 1.05;
color: var(--text-primary);
word-break: break-word;
}
.kiosk-meta {
display: flex; flex-wrap: wrap; justify-content: center; gap: 10px;
font-size: 15px; color: var(--text-secondary);
}
.kiosk-meta span {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 14px;
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-radius: var(--r-pill);
}
.kiosk-qr { margin: 30px 0 8px; }
.kiosk-qr #qrcode {
display: inline-block;
padding: 16px;
background: #fff;
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
line-height: 0;
}
.kiosk-qr-label { margin-top: 14px; font-size: 15px; color: var(--text-secondary); }
.kiosk-countdown { margin: 18px 0 22px; font-size: 13.5px; color: var(--text-muted); }
.kiosk-footer { padding: 0 24px 22px; text-align: center; }
/* Link-Zeile in der Kiosk-Verwaltung */
.kiosk-link-row { display: flex; gap: 8px; align-items: center; }
.kiosk-link-row .input { font-family: var(--font-mono); font-size: 12px; }
@media (max-width: 560px) {
.kiosk-card { padding: 32px 22px; }
.kiosk-phone { flex-direction: column; }
}
/* ========================================================================= /* =========================================================================
13. RESPONSIVE 13. RESPONSIVE
========================================================================= */ ========================================================================= */

View file

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

@ -66,34 +66,6 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` (
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `kiosks` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL,
`template_id` INT NULL,
`name` VARCHAR(255) NOT NULL,
`token` VARCHAR(64) NOT NULL,
`headline` VARCHAR(255) NULL,
`subline` VARCHAR(500) NULL,
`logo_url` VARCHAR(500) NULL,
`background_url` VARCHAR(500) NULL,
`bg_overlay` TINYINT NOT NULL DEFAULT 45,
`accent_color` VARCHAR(7) NULL,
`card_style` ENUM('light','dark') NOT NULL DEFAULT 'light',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`daily_limit` INT NOT NULL DEFAULT 100,
`cooldown_seconds` INT NOT NULL DEFAULT 20,
`display_seconds` INT NOT NULL DEFAULT 90,
`last_used_at` TIMESTAMP NULL,
`created_by` INT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uniq_token` (`token`),
INDEX `idx_site` (`site_id`),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`template_id`) REFERENCES `voucher_templates`(`id`) ON DELETE SET NULL,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `api_keys` ( CREATE TABLE IF NOT EXISTS `api_keys` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL, `name` VARCHAR(255) NOT NULL,
@ -121,7 +93,6 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL, `site_id` INT NOT NULL,
`user_id` INT, `user_id` INT,
`kiosk_id` INT NULL,
`voucher_code` VARCHAR(50) NOT NULL, `voucher_code` VARCHAR(50) NOT NULL,
`voucher_name` VARCHAR(255) NOT NULL, `voucher_name` VARCHAR(255) NOT NULL,
`max_uses` INT NOT NULL, `max_uses` INT NOT NULL,
@ -138,8 +109,7 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
INDEX `idx_site` (`site_id`), INDEX `idx_site` (`site_id`),
INDEX `idx_created` (`created_at`), INDEX `idx_created` (`created_at`),
INDEX `idx_unifi_id` (`unifi_voucher_id`), INDEX `idx_unifi_id` (`unifi_voucher_id`),
INDEX `idx_status` (`status`), INDEX `idx_status` (`status`)
INDEX `idx_kiosk` (`kiosk_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `sessions` ( CREATE TABLE IF NOT EXISTS `sessions` (

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

After

Width:  |  Height:  |  Size: 377 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

After

Width:  |  Height:  |  Size: 377 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 306 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

After

Width:  |  Height:  |  Size: 464 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 244 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

After

Width:  |  Height:  |  Size: 315 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 KiB

After

Width:  |  Height:  |  Size: 750 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 411 KiB

Before After
Before After

View file

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

View file

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

View file

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

@ -172,56 +172,6 @@ class Mailer {
} }
// Vordefinierte E-Mail-Templates // Vordefinierte E-Mail-Templates
/**
* Legt den Nachrichtentext in ein schlichtes, markentreues HTML-Gerüst.
* Bewusst Tabellen + Inline-Styles: nur so rendern Outlook & Co. zuverlässig.
*/
private function brandedHtml(string $title, string $contentHtml, string $footerNote = ''): string
{
$accent = $this->db->getSetting('brand_gradient_from', '') ?: '#5b5bd6';
$accent2 = $this->db->getSetting('brand_gradient_to', '') ?: '#8b5cf6';
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent)) { $accent = '#5b5bd6'; }
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent2)) { $accent2 = '#8b5cf6'; }
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$year = date('Y');
$footer = $footerNote !== '' ? '<div style="margin-top:8px;">' . htmlspecialchars($footerNote, ENT_QUOTES, 'UTF-8') . '</div>' : '';
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
. '<title>' . $safeTitle . '</title></head>'
. '<body style="margin:0;padding:0;background:#f6f7f9;">'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f7f9;padding:28px 12px;">'
. '<tr><td align="center">'
. '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border:1px solid #e5e8ee;border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;">'
. '<tr><td style="background:' . $accent . ';background-image:linear-gradient(135deg,' . $accent . ' 0%,' . $accent2 . ' 100%);padding:22px 26px;">'
. '<div style="color:#ffffff;font-size:16px;font-weight:600;letter-spacing:-0.01em;">' . $safeTitle . '</div>'
. '</td></tr>'
. '<tr><td style="padding:26px;color:#101625;font-size:15px;line-height:1.6;">' . $contentHtml . '</td></tr>'
. '<tr><td style="padding:16px 26px;background:#f8f9fb;border-top:1px solid #e5e8ee;color:#6b7280;font-size:12px;">'
. '&copy; ' . $year . ' ' . $safeTitle . $footer
. '</td></tr>'
. '</table></td></tr></table></body></html>';
}
/**
* Voucher-Code als hervorgehobene Karte für die E-Mail.
*/
private function voucherCardHtml(string $code, string $siteName, $maxUses): string
{
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
. 'style="margin:18px 0;background:#f8f9fb;border:1px solid #e5e8ee;border-radius:12px;">'
. '<tr><td align="center" style="padding:22px;">'
. '<div style="font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">'
. htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div style="margin:10px 0;font-family:Consolas,Menlo,monospace;font-size:28px;font-weight:700;letter-spacing:.12em;color:#101625;">'
. htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div style="font-size:13px;color:#525c6e;">'
. htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' '
. htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '</div>'
. '</td></tr></table>';
}
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) { public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
$instructionHeader = $this->db->getSetting('instruction_header', ''); $instructionHeader = $this->db->getSetting('instruction_header', '');
@ -239,7 +189,7 @@ class Mailer {
// Template aus Datenbank laden // Template aus Datenbank laden
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'); $subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang');
$bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\n<strong>Maximale Geräte:</strong> {MAX_USES}<br>\n<strong>Standort:</strong> {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}"); $bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nIhr WLAN-Zugangscode lautet:\n\n<strong>{VOUCHER_CODE}</strong>\n\nGültigkeit: 8 Stunden ab Erstellung\nMaximale Geräte: {MAX_USES}\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
// Anleitung formatieren // Anleitung formatieren
$instructions = ''; $instructions = '';
@ -249,7 +199,6 @@ class Mailer {
// Platzhalter ersetzen // Platzhalter ersetzen
$placeholders = [ $placeholders = [
'{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses),
'{VOUCHER_CODE}' => $voucherCode, '{VOUCHER_CODE}' => $voucherCode,
'{SITE_NAME}' => $siteName, '{SITE_NAME}' => $siteName,
'{MAX_USES}' => $maxUses, '{MAX_USES}' => $maxUses,
@ -259,16 +208,10 @@ class Mailer {
]; ];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
// Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln, // HTML oder Plain Text prüfen
// sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren. $isHtml = strip_tags($body) !== $body;
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate || strpos($bodyTemplate, '{VOUCHER_CARD}') !== false;
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
if ($isHtml) {
$body = $this->brandedHtml($appTitle, $body);
}
return $this->send($to, $subject, $body, $isHtml); return $this->send($to, $subject, $body, $isHtml);
} }
@ -312,28 +255,11 @@ class Mailer {
]; ];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate; // HTML oder Plain Text prüfen
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate; $isHtml = strip_tags($body) !== $body;
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
if ($isHtml) {
$body = $this->brandedHtml($appTitle, $body);
}
return $this->send($to, $subject, $body, $isHtml); return $this->send($to, $subject, $body, $isHtml);
} }
/**
* Zeilenumbrüche aus dem Vorlagentext in HTML übernehmen, ohne bereits
* vorhandenes Markup (z. B. aus dem WYSIWYG-Editor) zu zerstören.
*/
private function textToHtml(string $body): string
{
if (preg_match('#<(p|div|ul|ol|h[1-6])[\s>]#i', $body)) {
return $body;
}
return nl2br($body, false);
}
} }

View file

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

View file

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

133
index.php
View file

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

View file

@ -185,8 +185,11 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UniFi Voucher System - Installation</title> <title>UniFi Voucher System - Installation</title>
<?php require_once __DIR__ . '/includes/Ui.php'; ?> <link rel="preconnect" href="https://fonts.googleapis.com">
<?= Ui::head(null) ?> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/global.css">
<style> <style>
/* Installer-spezifisch: Fortschrittsanzeige und Abschnitte */ /* Installer-spezifisch: Fortschrittsanzeige und Abschnitte */
.install-head { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; } .install-head { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; }
@ -224,7 +227,7 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
<body class="app-body focus-page"> <body class="app-body focus-page">
<div class="focus-card card"> <div class="focus-card card">
<div class="install-head"> <div class="install-head">
<span class="focus-icon"><i class="fas fa-rocket" aria-hidden="true"></i></span> <span class="focus-icon"><i class="fas fa-rocket"></i></span>
<div> <div>
<h1 style="font-size:20px;">UniFi Voucher System</h1> <h1 style="font-size:20px;">UniFi Voucher System</h1>
<p class="sub">Installation in fünf Schritten</p> <p class="sub">Installation in fünf Schritten</p>
@ -280,7 +283,7 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
<input type="password" name="db_pass"> <input type="password" name="db_pass">
</div> </div>
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button> <button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right"></i></button>
</form> </form>
<?php elseif ($step === 2): ?> <?php elseif ($step === 2): ?>
@ -309,7 +312,7 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
<input type="password" name="admin_password_confirm" required> <input type="password" name="admin_password_confirm" required>
</div> </div>
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button> <button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right"></i></button>
</form> </form>
<?php elseif ($step === 3): ?> <?php elseif ($step === 3): ?>
@ -359,7 +362,7 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
<div class="help-text">Leer lassen, wenn M365-Login nicht verwendet werden soll</div> <div class="help-text">Leer lassen, wenn M365-Login nicht verwendet werden soll</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button> <button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right"></i></button>
</form> </form>
<?php elseif ($step === 4): ?> <?php elseif ($step === 4): ?>
@ -375,7 +378,6 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
<button type="submit" class="btn btn-primary btn-lg btn-block">Installation abschließen</button> <button type="submit" class="btn btn-primary btn-lg btn-block">Installation abschließen</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?= Ui::credit() ?>
</div> </div>
</body> </body>
</html> </html>

245
kiosk.php
View file

@ -1,245 +0,0 @@
<?php
/**
* Öffentliche Display-Seite ("Kiosk").
*
* Aufruf: kiosk.php?k=<token>
*
* Gedacht für ein Tablet oder einen Bildschirm im Empfangsbereich: ein großer
* Knopf, ein Klick, ein Zugangscode. Gäste ohne Zugriff auf den Bildschirm
* können denselben Link über den QR-Code am Handy öffnen.
*/
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/VoucherService.php';
I18n::init();
try {
$db = Database::getInstance();
$auth = new Auth();
} catch (Exception $e) {
http_response_code(500);
die('Datenbankfehler');
}
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$token = Kiosk::sanitizeToken($_GET['k'] ?? '');
$kiosk = $token !== '' ? Kiosk::findByToken($db, $token) : null;
if (!$kiosk) {
http_response_code(404);
$notFound = true;
} else {
$notFound = false;
}
$voucher = null; // erzeugter Code
$error = '';
$waitSecs = 0;
if (!$notFound && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
$limits = Kiosk::checkLimits($db, $kiosk);
if (!$limits['allowed']) {
$waitSecs = (int)$limits['wait'];
$error = $limits['reason'] === 'cooldown'
? str_replace('{seconds}', (string)$waitSecs, __('kiosk_error_cooldown'))
: __('kiosk_error_limit');
} else {
try {
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [(int)$kiosk['site_id']]);
if (!$site) {
throw new Exception(__('error_site_not_found'));
}
$settings = Kiosk::voucherSettings($db, $kiosk);
$voucher = VoucherService::create(
$db,
$site,
$kiosk['name'],
$settings['max_uses'],
$settings['expire_minutes'],
null,
$settings['qos'],
(int)$kiosk['id']
);
Kiosk::markUsed($db, (int)$kiosk['id']);
$auth->writeAuditLog(null, 'voucher_kiosk', 'kiosk', (int)$kiosk['id'],
$kiosk['name'] . ' · ' . $voucher['code']);
} catch (Exception $e) {
error_log('Kiosk-Fehler: ' . $e->getMessage());
$error = __('kiosk_error_generic');
}
}
}
}
$headline = trim((string)($kiosk['headline'] ?? '')) ?: __('kiosk_default_headline');
$subline = trim((string)($kiosk['subline'] ?? '')) ?: __('kiosk_default_subline');
$display = max(10, (int)($kiosk['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS));
$selfUrl = $kiosk ? Kiosk::publicUrl($kiosk['token']) : '';
// Gestaltung dieser Display-Seite (eigene Werte, sonst die des Systems)
$look = $kiosk ? Kiosk::appearance($db, $kiosk) : ['logo'=>'','background'=>'','overlay'=>0.45,'accent'=>'','card'=>'light'];
$logoUrl = $look['logo'];
$bodyClass = 'kiosk-body';
$bodyStyle = '';
if ($look['background'] !== '') {
$bodyClass .= ' has-background';
$bodyStyle .= "--kiosk-bg:url('" . htmlspecialchars(Ui::mediaUrl($look['background']), ENT_QUOTES) . "');"
. '--kiosk-overlay:' . $look['overlay'] . ';';
}
if ($look['card'] === 'dark') {
$bodyClass .= ' kiosk-dark';
}
if ($look['accent'] !== '') {
// Nur die Akzentfarbe dieser Seite überschreiben der Rest bleibt Design-System.
$bodyStyle .= '--accent:' . $look['accent'] . ';'
. '--accent-hover:color-mix(in srgb, ' . $look['accent'] . ' 84%, #000);'
. '--accent-soft:color-mix(in srgb, ' . $look['accent'] . ' 14%, #fff);'
. '--accent-border:color-mix(in srgb, ' . $look['accent'] . ' 32%, #fff);';
}
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title><?= htmlspecialchars($appTitle) ?></title>
<?= Ui::head($db) ?>
<?php if (!$notFound): ?>
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js') ?>
<?php endif; ?>
</head>
<body class="<?= $bodyClass ?>"<?= $bodyStyle !== '' ? ' style="' . $bodyStyle . '"' : '' ?>>
<?php if ($notFound): ?>
<main class="kiosk-stage">
<div class="kiosk-card">
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-link-slash" aria-hidden="true"></i></div>
<h1><?= __('kiosk_unknown') ?></h1>
<p><?= __('kiosk_unknown_hint') ?></p>
</div>
</div>
</main>
<?php elseif ($voucher): ?>
<main class="kiosk-stage">
<div class="kiosk-card kiosk-result">
<?php if ($logoUrl): ?>
<img class="kiosk-logo kiosk-logo-sm" src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="<?= htmlspecialchars($appTitle) ?>">
<?php endif; ?>
<p class="kiosk-eyebrow"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('kiosk_ready') ?></p>
<div class="kiosk-code" id="voucherCode"><?= htmlspecialchars($voucher['code']) ?></div>
<div class="kiosk-meta">
<span><i class="fas fa-location-dot" aria-hidden="true"></i> <?= htmlspecialchars($voucher['site_name']) ?></span>
<span><i class="fas fa-clock" aria-hidden="true"></i> <?= (int)$voucher['expire_min'] ?> <?= __('minutes_short') ?></span>
<span><i class="fas fa-mobile-screen" aria-hidden="true"></i> <?= (int)$voucher['max_uses'] ?> <?= __('label_devices') ?></span>
</div>
<div class="kiosk-qr">
<div id="qrcode"></div>
<p class="kiosk-qr-label"><?= __('kiosk_scan_code') ?></p>
</div>
<p class="kiosk-countdown">
<?= str_replace('{seconds}', '<span id="countdown">' . $display . '</span>', __('kiosk_reset_in')) ?>
</p>
<a class="btn btn-secondary btn-lg" href="?k=<?= htmlspecialchars($kiosk['token']) ?>"><?= __('kiosk_done') ?></a>
</div>
</main>
<?php else: ?>
<main class="kiosk-stage">
<div class="kiosk-card">
<?php if ($logoUrl): ?>
<img class="kiosk-logo" src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="<?= htmlspecialchars($appTitle) ?>">
<?php else: ?>
<span class="brand-mark kiosk-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
<?php endif; ?>
<h1 class="kiosk-headline"><?= htmlspecialchars($headline) ?></h1>
<p class="kiosk-subline"><?= htmlspecialchars($subline) ?></p>
<?php if ($error): ?>
<div class="alert alert-error kiosk-alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post" id="kioskForm">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
<button type="submit" class="btn btn-primary kiosk-button" id="kioskButton"
<?= $waitSecs > 0 ? 'disabled' : '' ?>>
<i class="fas fa-wifi" aria-hidden="true"></i>
<span><?= __('kiosk_button') ?></span>
</button>
</form>
<div class="kiosk-phone">
<div id="selfQr" class="kiosk-phone-qr"></div>
<p><?= __('kiosk_phone_hint') ?></p>
</div>
</div>
</main>
<?php endif; ?>
<footer class="kiosk-footer"><?= Ui::credit() ?></footer>
<?php if (!$notFound): ?>
<script>
(function () {
<?php if ($voucher): ?>
// Code als QR lokal erzeugt, ohne externen Dienst.
new QRCode(document.getElementById('qrcode'), {
text: <?= json_encode(str_replace('-', '', $voucher['code'])) ?>,
width: 240, height: 240,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
// Nach der Anzeigedauer zurück zum Startbildschirm, damit der nächste
// Gast nicht den Code seines Vorgängers sieht.
var left = <?= $display ?>;
var out = document.getElementById('countdown');
setInterval(function () {
left -= 1;
if (out) out.textContent = left > 0 ? left : 0;
if (left <= 0) location.href = '?k=<?= htmlspecialchars($kiosk['token'], ENT_QUOTES) ?>';
}, 1000);
<?php else: ?>
// QR auf diese Seite selbst: Gäste öffnen sie am eigenen Handy.
new QRCode(document.getElementById('selfQr'), {
text: <?= json_encode($selfUrl) ?>,
width: 150, height: 150,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
// Doppelklicks auf dem Touch-Display verhindern.
var form = document.getElementById('kioskForm');
var button = document.getElementById('kioskButton');
if (form && button) {
form.addEventListener('submit', function () {
button.disabled = true;
button.querySelector('span').textContent = <?= json_encode(__('kiosk_working')) ?>;
});
}
<?php if ($waitSecs > 0): ?>
// Nach der Wartezeit wieder freigeben.
setTimeout(function () { location.href = '?k=<?= htmlspecialchars($kiosk['token'], ENT_QUOTES) ?>'; }, <?= $waitSecs * 1000 ?>);
<?php endif; ?>
<?php endif; ?>
})();
</script>
<?php endif; ?>
</body>
</html>

View file

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

View file

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

View file

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

View file

@ -31,7 +31,6 @@ try {
throw new Exception('includes/Auth.php nicht gefunden'); throw new Exception('includes/Auth.php nicht gefunden');
} }
require_once __DIR__ . '/includes/Auth.php'; require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/Ui.php';
} catch (Exception $e) { } catch (Exception $e) {
$loadErrors[] = "Auth: " . $e->getMessage(); $loadErrors[] = "Auth: " . $e->getMessage();
} }
@ -121,7 +120,11 @@ try {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - <?= htmlspecialchars($appTitle) ?></title> <title>Login - <?= htmlspecialchars($appTitle) ?></title>
<?= Ui::head($db) ?> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/global.css">
<style> <style>
/* Diagnose-Ausgabe am Seitenende */ /* Diagnose-Ausgabe am Seitenende */
.debug-info { .debug-info {
@ -134,7 +137,7 @@ try {
<body class="app-body focus-page"> <body class="app-body focus-page">
<div class="focus-card card login-container"> <div class="focus-card card login-container">
<?php if ($logoUrl): ?> <?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo"> <img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<?php else: ?> <?php else: ?>
<h1><?= htmlspecialchars($appTitle) ?></h1> <h1><?= htmlspecialchars($appTitle) ?></h1>
<?php endif; ?> <?php endif; ?>
@ -166,12 +169,12 @@ try {
<?php if ($m365Enabled): ?> <?php if ($m365Enabled): ?>
<div class="divider"><span>oder</span></div> <div class="divider"><span>oder</span></div>
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft"> <a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft">
<i class="fab fa-microsoft" aria-hidden="true"></i> Mit Microsoft 365 anmelden <i class="fab fa-microsoft"></i> Mit Microsoft 365 anmelden
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if ($publicAccess): ?> <?php if ($publicAccess): ?>
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> Zurück zur Code-Erstellung</a></div> <div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left"></i> Zurück zur Code-Erstellung</a></div>
<?php endif; ?> <?php endif; ?>
<!-- Debug Info (kann nach erfolgreicher Einrichtung entfernt werden) --> <!-- Debug Info (kann nach erfolgreicher Einrichtung entfernt werden) -->

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