diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7e20944 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aaaad9..e88d61a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,9 +29,40 @@ jobs: php -l "$f" done - - name: Validate JSON language/migration assets + - name: Validate language files run: | - php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";' + php -r ' + $de = require "lang/de.php"; $en = require "lang/en.php"; + if (!is_array($de) || !is_array($en)) { fwrite(STDERR, "Bad lang file\n"); exit(1); } + $missingEn = array_diff(array_keys($de), array_keys($en)); + $missingDe = array_diff(array_keys($en), array_keys($de)); + if ($missingEn || $missingDe) { + fwrite(STDERR, "Fehlend in en: " . implode(", ", $missingEn) . "\n"); + fwrite(STDERR, "Fehlend in de: " . implode(", ", $missingDe) . "\n"); + exit(1); + } + echo "lang OK (" . count($de) . " Schluessel)\n";' + + - name: Check that every used translation key exists + run: | + php -r ' + $de = require "lang/de.php"; + $missing = []; + $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(".", FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + $path = $file->getPathname(); + if (substr($path, -4) !== ".php") continue; + if (strpos($path, "/vendor/") !== false || strpos($path, "/tools/") !== false) continue; + preg_match_all("/__\(\s*\x27([a-z0-9_]+)\x27/", file_get_contents($path), $m); + foreach ($m[1] as $key) { + if (!isset($de[$key]) && substr($key, -1) !== "_") { $missing[$key] = $path; } + } + } + if ($missing) { + foreach ($missing as $key => $path) { fwrite(STDERR, "Unbekannter Schluessel $key in $path\n"); } + exit(1); + } + echo "Alle verwendeten Schluessel vorhanden\n";' test: name: Unit Tests & Static Analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3fc3001 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,152 @@ +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}" diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..2654739 --- /dev/null +++ b/.htaccess @@ -0,0 +1,26 @@ +# --------------------------------------------------------------------------- +# Sicherheits-Header und Zugriffsschutz (Apache) +# Nginx-Entsprechung siehe Readme.md, Abschnitt "Sicherheit". +# --------------------------------------------------------------------------- + + + 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'" + + +# Kein Verzeichnislisting +Options -Indexes + +# Dateien, die nie direkt ausgeliefert werden sollen + + Require all denied + + +# Interne Ordner schuetzen sich ueber eigene .htaccess-Dateien +# (funktioniert auch bei Installation in einem Unterverzeichnis). diff --git a/Dockerfile b/Dockerfile index 3d0f28c..9696aa2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,11 +16,14 @@ RUN { \ echo 'post_max_size=8M'; \ } > /usr/local/etc/php/conf.d/zz-voucher.ini +# .htaccess auswerten (Sicherheits-Header, Schutz des uploads-Ordners) +RUN sed -ri 's!!\n\tAllowOverride All!g' /etc/apache2/apache2.conf + WORKDIR /var/www/html COPY . /var/www/html -# Laufzeit-Verzeichnis des Updaters beschreibbar machen -RUN mkdir -p /var/www/html/updater/storage \ +# Laufzeit-Verzeichnisse beschreibbar machen +RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \ && chown -R www-data:www-data /var/www/html COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/Readme.md b/Readme.md index 7019def..46a8a84 100644 --- a/Readme.md +++ b/Readme.md @@ -4,12 +4,15 @@ **Webbasiertes WLAN-Voucher-Management für UniFi OS** – mit Multi-Site-Support, Benutzerverwaltung, Microsoft-365-Login und integriertem Auto-Updater. +Entwickelt von **[Loheide.eu](https://loheide.eu)** + ![PHP](https://img.shields.io/badge/PHP-7.4%2B-777BB4?logo=php&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white) ![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white) ![License](https://img.shields.io/badge/Lizenz-MIT-green) -![Version](https://img.shields.io/badge/Version-2.4.0-blueviolet) -![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg) +![Version](https://img.shields.io/badge/Version-2.8.0-blueviolet) +![Tests](https://img.shields.io/badge/Tests-PHPUnit%20%2B%20PHPStan-brightgreen) +[![Repository](https://img.shields.io/badge/Code-git.loheide.cloud-4b3ec4)](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool) @@ -25,6 +28,8 @@ ## ✨ Features - 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand +- 🖥️ **Display-Seiten (Kiosk)** – öffentliche Seite je Site, an der Gäste sich mit einem Klick selbst einen Zugang holen +- 🎨 **Jede Display-Seite eigenständig gestaltbar** – Logo, Hintergrundbild, Akzentfarbe, helle oder dunkle Karte - 📦 **Bulk-Erstellung** – bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout - 🧩 **Voucher-Profile/Templates** – vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl - 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten @@ -45,6 +50,13 @@ - 💾 **Config-Backup & -Restore** (JSON Export/Import) - 🐳 **Docker** – Dockerfile + docker-compose (MariaDB) - 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle) +- 🎨 **Einheitliches Design-System** – ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark) +- 🏷️ **Login-Seite individualisierbar** – Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf +- 🖌️ **Eigene Markenfarben** – Akzentfarbe, Verlauf und Eckenradius wirken auf die gesamte Oberfläche +- ⬆️ **Bild-Upload** für Logo, Favicon und Login-Hintergrund (kein externes Hosting nötig) +- 🔒 **Keine externen CDNs** – Schrift, Icons, Diagramme und Editor werden lokal ausgeliefert (DSGVO, Offline-Netze) +- ♿ **Barrierearm** – Kontraste nach WCAG AA, Sprungmarke, aria-Beschriftungen, `prefers-reduced-motion` +- 📱 **Mobil nutzbar** – Tabellen werden auf schmalen Geräten zu Karten - 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert - 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`) - 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay @@ -59,31 +71,52 @@ ## 📸 Screenshots +> Alle Screenshots stammen aus der Oberfläche in Version 2.5.0 (neues Design-System). + ### Anmeldung & Voucher-Erstellung
- Login mit Microsoft 365 - Voucher erstellen - Voucher-Ergebnis + Anmeldung + Anmeldung mit eigenem Branding
-### Bulk-Erstellung & Dark Mode -
- Bulk-Voucher-Erstellung - Dashboard im Dark Mode + Voucher erstellen + Einstellungen der Login-Seite
-### Administration & Updater +### Display-Seite für Gäste + +
+ Display-Seite im Ruhezustand + Display-Seite mit eigenem Bild und Farben +
+ +
+ Ausgegebener Zugangscode auf dem Display + Display-Seite einrichten +
+ +
+ Voucher-Ergebnis mit QR-Code + Bulk-Voucher-Erstellung +
+ +### Administration
Dashboard - Auto-Updater + Dashboard im Dark Mode
- Updater – Ausgangszustand - Wartungsmodus + Live-Voucher-Verwaltung + Einstellungen +
+ +
+ Markenfarben einstellen + Ansicht auf dem Smartphone
### REST-API, 2FA & Integrationen @@ -97,6 +130,17 @@ Zwei-Faktor-Authentifizierung +### Updater & Wartungsmodus + +
+ Updater – Ausgangszustand + Auto-Updater mit verfügbarem Update +
+ +
+ Wartungsmodus +
+ --- ## 📋 Anforderungen @@ -113,8 +157,8 @@ ## 🚀 Installation ```bash -git clone https://github.com/friloo/unifi-voucher-tool.git -cd unifi-voucher-tool +git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git +cd Unifi-Voucher-Tool ``` 1. Dateien auf den Webserver hochladen @@ -184,6 +228,64 @@ 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* + +
+ Verwaltung der Display-Seiten +
+ +| 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 | 0–90 % dunkle Ebene über dem Bild, damit die Karte lesbar bleibt | +| Akzentfarbe | färbt den Knopf dieser Seite (leer = Farbe aus dem Design-Tab) | +| Karte | hell oder dunkel – auf Fotos wirkt die dunkle Karte meist ruhiger | + +Logo und Hintergrund lassen sich direkt hochladen (PNG, JPG, WEBP, GIF, SVG bis +3 MB) oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden +die hochgeladenen Dateien mit. + +Die ausgegebenen Codes erscheinen normal in *Live Vouchers*, im *Reporting* und +im *Audit-Log* (Aktion „Voucher am Display geholt"), sodass jederzeit +nachvollziehbar bleibt, woher ein Zugang stammt. + +> Display-Seiten funktionieren unabhängig vom globalen öffentlichen Modus – der +> geheime Link ist der Zugang. Webhook-Benachrichtigungen werden für diese Codes +> bewusst **nicht** ausgelöst, sonst wäre der Slack-Kanal voll. + +--- + ## ⚙️ Konfiguration ### `config.php` @@ -220,6 +322,80 @@ Den Token finden Sie unter **Administration → Einstellungen → Cron**. --- +## 🎨 Design-System + +Frontend, Login-Seiten, Installer, Updater und der gesamte Admin-Bereich nutzen ein +gemeinsames Stylesheet: **`assets/global.css`**. + +- **Design-Tokens** (`:root` bzw. `[data-theme="dark"]`) für Flächen, Text, Linien, + Markenfarbe, Statusfarben, Radien, Schatten und Layout-Maße +- **Komponenten** darauf aufgebaut: Buttons, Formularfelder, Cards, Tabellen, Badges, + Alerts, Tabs, Pagination, Modals, Toasts, Statistik-Kacheln, Sidebar/Topbar +- **Dark Mode** ausschließlich über Tokens – keine `!important`-Overrides mehr +- **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback + +### Markenfarben ohne Code + +Unter **Administration → Einstellungen → Design** lassen sich Akzentfarbe +(hell und dunkel), Markenverlauf und Eckenradius setzen. Abgeleitete Töne – +Hover, weiche Flächen, Rahmen, Fokusring – berechnet das System per `color-mix` +aus der Grundfarbe; eine Farbe genügt also. Eine Live-Vorschau zeigt Button, +Badge, Chip und Logo-Kachel sofort im neuen Ton. + +Die Werte landen als schlanker `:root`-Override im Seitenkopf und gelten überall, +auch auf Login-Seite, Installer und Updater. Wer lieber in CSS arbeitet, kann +dieselben Variablen weiterhin in `assets/global.css` überschreiben: + +```css +:root { + --accent: #0f766e; /* Primärfarbe (Buttons, aktive Navigation) */ + --brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%); + --r-lg: 14px; /* Eckenradius für Cards */ +} +``` + +### Bilder hochladen + +Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich direkt hochladen – +alternativ bleibt das URL-Feld bestehen. Die Dateien landen unter `uploads/` +(Docker: eigenes Volume, siehe unten). Erlaubt sind PNG, JPG, WEBP, GIF und SVG +bis 3 MB; SVGs werden vor dem Speichern von Skripten und externen Verweisen +befreit, und im Upload-Ordner sperrt eine `.htaccess` die PHP-Ausführung. + +### Assets ohne Drittanbieter + +Schrift (Inter), Icons (Font Awesome), Diagramme (Chart.js), QR-Codes und der +WYSIWYG-Editor (TinyMCE) liegen unter `assets/vendor/` und kommen vom eigenen +Server. Das hält Besucher-IPs bei Ihnen – und die Oberfläche funktioniert auch +dort, wo das Netz keinen Weg nach außen hat. Details und Aktualisierungs-Hinweise: +[`assets/vendor/README.md`](assets/vendor/README.md). + +Alle Asset-URLs tragen einen Versionsstempel (`?v=…`), damit Browser nach einem +Update nicht die alten Dateien aus dem Cache verwenden. + +### Login-Seite individualisieren + +Unter **Administration → Einstellungen → Login-Seite** lässt sich die Anmeldeseite +ohne Code-Änderung an das eigene Haus anpassen. Leere Felder verwenden jeweils den +Standardwert – eine frische Installation sieht also unverändert aus. + +| Einstellung | Wirkung | +|---|---| +| Linke Bildspalte anzeigen | Split-Screen an/aus. Aus = zentrierte Anmeldekarte | +| Firmenname | Name neben dem Logo bzw. in der Fußzeile (leer = Anwendungstitel) | +| Logo (URL) | Eigenes Logo in der Bildspalte (leer = allgemeines Logo) | +| Überschrift / Beschreibungstext | Claim in der Bildspalte | +| Stichpunkte | Liste mit Haken – ein Stichpunkt pro Zeile, leer = keine Liste | +| Fußzeile | z. B. `© 2026 Muster GmbH · Datenschutz · Impressum` | +| Hintergrundbild (URL) | Formatfüllendes Bild der Bildspalte | +| Verlauf Start-/Endfarbe | Farbverlauf, wenn kein Bild gesetzt ist | +| Abdunklung (%) | Dunkle Ebene über dem Bild, damit der Text lesbar bleibt | + +Über **„Vorschau öffnen"** lässt sich die Login-Seite als angemeldeter Administrator +ansehen (`login.php?preview=1`), ohne sich abzumelden. + +--- + ## 🛡️ Sicherheit Das Tool ist auf einen sicheren Standardbetrieb ausgelegt: @@ -235,13 +411,30 @@ Das Tool ist auf einen sicheren Standardbetrieb ausgelegt: | **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout | | **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) | -Empfohlene zusätzliche Härtung am Server: +Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern +(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, +`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal +liegen, erlaubt die CSP nur noch die eigene Herkunft – externe Verbindungen +bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie +`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene +`.htaccess`-Dateien. -```apache -# .htaccess – sensible Dateien sperren (wird vom Installer erzeugt) - - Require all denied - +> **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst +> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image +> erledigt das bereits. + +Für **Nginx** entspricht das: + +```nginx +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always; + +location ~ ^/(includes|tools|tests)/ { deny all; } +location ~ ^/updater/(storage|migrations)/ { deny all; } +location ~ ^/(config\.php|database\.sql)$ { deny all; } +location ^~ /uploads/ { location ~ \.php$ { deny all; } } ``` ```sql @@ -364,6 +557,108 @@ Das Schema wird beim ersten Start automatisch in MariaDB geladen; danach den Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV setzen (`DB_*`, `APP_KEY`). +Hochgeladene Logos und Hintergründe liegen im Volume `uploads` und überstehen +damit ein Image-Update. Bei eigener Apache-/Nginx-Installation muss `uploads/` +für den Webserver beschreibbar sein: + +```bash +chown -R www-data:www-data uploads updater/storage +``` + +--- + +## 🧪 Entwicklung + +Für Screenshots und einen schnellen Durchlauf aller Seiten gibt es eine +Demo-Instanz **ohne Datenbank** – `Database` und `Auth` werden durch Stubs mit +festen Beispieldaten ersetzt: + +```bash +python3 tools/demo/build.py /tmp/uvt-demo +php -S 127.0.0.1:8123 -t /tmp/uvt-demo & + +# Bilder in docs/screenshots neu erzeugen (benötigt headless Chromium) +CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py +``` + +Details und die verfügbaren Demo-Zustände: [`tools/README.md`](tools/README.md). + +Tests und statische Analyse: + +```bash +composer install +vendor/bin/phpunit # 29 Tests (Crypto, TOTP, API-Keys, Upload, Ui) +vendor/bin/phpstan analyse # Level 5 +``` + +Die Versionsnummer steht in der Datei **`VERSION`** im Projektstamm. Sie wird +im Admin-Bereich unten in der Seitenleiste angezeigt und benennt das +Release-Paket – für eine neue Version also dort (und im Badge oben) anheben. + +Die Pipeline (`.github/workflows/ci.yml`) führt zusätzlich einen +Syntax-Check über alle PHP-Dateien aus und prüft, ob `lang/de.php` und +`lang/en.php` dieselben Schlüssel enthalten und jeder im Code verwendete +Schlüssel existiert. Dieselben Schritte lassen sich lokal ausführen. + +--- + +## 📦 Repository & Mitwirken + +Der Quellcode liegt auf der eigenen Forgejo-Instanz – **nicht** auf GitHub: + +**** + +```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`**: + +**** + +Das Paket enthält nur die Laufzeit-Dateien – `docs/`, `tests/`, `tools/` und die +CI-Konfiguration bleiben draußen (rund 1,4 MB). Wird ein Tag `v*` gepusht, +entsteht daraus ein reguläres Release mit derselben Mechanik. + +> Beim **Update einer bestehenden Installation** `config.php`, `uploads/` und +> `updater/storage/` nicht überschreiben – oder gleich den eingebauten Updater +> verwenden, der genau diese Pfade schützt. + +Voraussetzung ist ein registrierter **Forgejo-Actions-Runner**; ohne Runner +bleiben die Workflows in der Warteschlange stehen. Compose-Datei und Anleitung +dafür liegen in [`tools/runner/`](tools/runner/README.md). + +### Mitwirken + +Fehlerberichte und Änderungsvorschläge laufen über die **Issues** und **Pull +Requests** dort. Für die Kommandozeile eignet sich [`tea`](https://gitea.com/gitea/tea), +die Gitea-/Forgejo-CLI: + +```bash +tea pr create # Pull Request öffnen +tea issues ls # offene Tickets ansehen +``` + +> Die Workflows unter `.github/workflows/` werden von Forgejo Actions +> mitgelesen; das Badge oben ist bewusst statisch, solange kein Runner +> registriert ist. `docker-publish.yml` veröffentlicht nach `ghcr.io` und +> stammt noch aus der GitHub-Zeit – für den Forgejo-Betrieb entweder auf die +> eigene Registry umstellen oder entfernen. + +Der **Auto-Updater** ist davon unabhängig: er zieht seine Pakete über +`update.loheide.eu` (Channels `stable` und `development`) und nicht direkt aus +dem Git-Hoster. + +--- + ## 🗺️ Roadmap - [x] Voucher-Templates (vordefinierte Laufzeiten) @@ -378,11 +673,18 @@ setzen (`DB_*`, `APP_KEY`). - [x] Docker-Container - [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint - [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan) +- [x] Gemeinsames Design-System für Frontend, Login und Backend +- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite) +- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs) +- [x] Vollständige englische Übersetzung des Admin-Bereichs +- [x] Display-Seiten: Selbstbedienung für Gäste am Bildschirm ---
-**Version 2.4.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** +**Version 2.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** + +Entwickelt von **[Loheide.eu](https://loheide.eu)**
diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..834f262 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.8.0 diff --git a/admin/api_keys.php b/admin/api_keys.php index a2bbbfb..ccc0c24 100644 --- a/admin/api_keys.php +++ b/admin/api_keys.php @@ -37,7 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) { ); $auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt"); $newKey = $k['plain']; - $success = 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!'; + $success = __('api_created_once'); } } } @@ -46,14 +46,14 @@ if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken( $row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]); if ($row) { $db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]); - $success = 'Status aktualisiert.'; + $success = __('api_status_updated'); } } if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { $db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]); $auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht'); - $success = 'API-Schlüssel gelöscht.'; + $success = __('api_deleted'); } $keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC"); @@ -66,94 +66,80 @@ $adminBase = ''; -API-Schlüssel – <?= htmlspecialchars($appTitle) ?> +<?= __('api_title') ?> – <?= htmlspecialchars($appTitle) ?> - - - -

🔑 API-Schlüssel

+
-

Neuer Schlüssel

-

Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.

+

+

-

Neuen API-Schlüssel erstellen

+

- - + +
- +
- - + +
- +
-

Vorhandene Schlüssel

+

-

Noch keine API-Schlüssel angelegt.

+

- - +
+
NamePräfixScopeLimitStatusZuletzt genutztErstellt von
+ - - - - - - - + + + + + + +
uvt_uvt_ - - Löschen + +
+
-

Verwendung

-

Authentifizierung per Header Authorization: Bearer <key> oder X-API-Key: <key>.

+

+

Authorization: Bearer <key> oder X-API-Key: <key>.

# Voucher erstellen
 curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
   -H "Authorization: Bearer uvt_…" \
@@ -162,10 +148,10 @@ curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
 
 # Sites auflisten
 curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"
-

OpenAPI-Spezifikation (Import in Postman/Swagger): /api/openapi.php

+

/api/openapi.php

- + diff --git a/admin/audit_log.php b/admin/audit_log.php index 0af636a..8edff87 100644 --- a/admin/audit_log.php +++ b/admin/audit_log.php @@ -45,23 +45,15 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY $currentPage = 'audit_log'; $adminBase = ''; -$actionLabels = [ - 'voucher_created' => '🎫 Voucher erstellt', - 'voucher_bulk' => '🎫 Bulk Voucher', - 'user_login' => '🔐 Login', - 'user_logout' => '🚪 Logout', - 'user_created' => '👤 Benutzer erstellt', - 'user_updated' => '👤 Benutzer geändert', - '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', -]; +// Aktionsnamen uebersetzt anzeigen; unbekannte Aktionen bleiben technisch. +$actionLabels = []; +foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created', + 'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted', + 'settings_saved', 'password_reset', 'template_created', 'template_updated', + 'template_deleted', 'voucher_kiosk', 'kiosk_created', 'kiosk_updated', + 'kiosk_deleted'] as $action) { + $actionLabels[$action] = __('audit_action_' . $action); +} ?> @@ -70,47 +62,17 @@ $actionLabels = [ <?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?> - -
-
+
@@ -127,7 +89,7 @@ $actionLabels = [
- - Zurücksetzen + + Zurücksetzen
@@ -144,14 +106,15 @@ $actionLabels = [
- + Einträge
-

+

- +
+
@@ -165,54 +128,55 @@ $actionLabels = [ - - - - - - +
+
+ +
- System/Anonym +
+ : - +
+
1): ?> @@ -221,7 +185,7 @@ $actionLabels = [
- + diff --git a/admin/backup.php b/admin/backup.php index 0faf942..bc4bfa6 100644 --- a/admin/backup.php +++ b/admin/backup.php @@ -43,12 +43,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { $error = __('error_csrf'); } elseif (empty($_FILES['backup']['tmp_name'])) { - $error = 'Bitte eine Backup-Datei auswählen.'; + $error = __('backup_choose_file'); } else { $raw = file_get_contents($_FILES['backup']['tmp_name']); $data = json_decode($raw, true); if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') { - $error = 'Ungültige oder fremde Backup-Datei.'; + $error = __('backup_invalid_file'); } else { $importSites = isset($_POST['import_sites']); $importTemplates = isset($_POST['import_templates']); @@ -95,7 +95,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) { } } $auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert'); - $success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile."; + $success = str_replace(['{settings}', '{sites}', '{templates}'], + [(string)$counts['settings'], (string)$counts['sites'], (string)$counts['templates']], + __('backup_imported')); } catch (Exception $e) { $error = 'Import-Fehler: ' . $e->getMessage(); } @@ -112,48 +114,38 @@ $adminBase = ''; -Backup & Restore – <?= htmlspecialchars($appTitle) ?> +<?= __('backup_title') ?> – <?= htmlspecialchars($appTitle) ?> - - - -

💾 Backup & Restore

+
-

Export

-

Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem APP_KEY dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.

- Konfiguration exportieren +

+

APP_KEY

+
-

Import / Restore

-

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.

+

+


- - - - + + + +
- + diff --git a/admin/import.php b/admin/import.php index dfb9725..d32b357 100644 --- a/admin/import.php +++ b/admin/import.php @@ -74,7 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['do_import'])) { Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null); $auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert"); } - $success = "$created Voucher erstellt."; + $success = str_replace('{count}', (string)$created, __('import_created')); } catch (Exception $e) { $error = $e->getMessage(); } @@ -93,48 +93,38 @@ $adminBase = ''; CSV-Import – <?= htmlspecialchars($appTitle) ?> - - - -

📥 Voucher-Import (CSV)

+
-

Mehrere Voucher erstellen

-

Eine Zeile pro Voucher: Name,MaxGeräte,Minuten – MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:
+

+

Name,MaxGeräte,Minuten
Gast Müller,1,480 · Konferenzraum A,5,240 · Tagespass

- + - + - + - +
-

Ergebnis

- +

+
NameCode / FehlerStatus
@@ -142,7 +132,7 @@ code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; borde - + diff --git a/admin/index.php b/admin/index.php index e432205..4f3ee40 100644 --- a/admin/index.php +++ b/admin/index.php @@ -6,6 +6,7 @@ ini_set('log_errors', 1); require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Ui.php'; require_once __DIR__ . '/../includes/UniFiController.php'; require_once __DIR__ . '/../includes/I18n.php'; @@ -90,76 +91,8 @@ $currentPage = 'dashboard'; <?= __('dashboard_title') ?> – <?= htmlspecialchars($appTitle) ?> - + - @@ -181,43 +114,43 @@ $currentPage = 'dashboard';
-
+
-
+
-
🟢
-
+
+
-
🟡
-
+
+
-
🔴
-
+
+
-
📊
-
+
+
@@ -225,16 +158,16 @@ $currentPage = 'dashboard';
-

🔴

+

-
+
0,'valid'=>0,'used'=>0,'expired'=>0]; ?>
-
+
@@ -249,10 +182,10 @@ $currentPage = 'dashboard';
-
- +
+

- +
@@ -260,18 +193,18 @@ $currentPage = 'dashboard';
-

📊

+

-
-
-

🏆

-
+
+
+

+
-

+

    @@ -291,21 +224,22 @@ $currentPage = 'dashboard';
-
-

📋

Live
+
+
-

+

-
+
+
- - - - + + + @@ -313,17 +247,26 @@ $currentPage = 'dashboard';
+
+
- + -
+
diff --git a/admin/integrations.php b/admin/integrations.php index 6f6d1bd..cfc7c83 100644 --- a/admin/integrations.php +++ b/admin/integrations.php @@ -49,13 +49,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) { $db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0))); $db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30))); $auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert'); - $success = 'Einstellungen gespeichert.'; + $success = __('settings_saved'); } } if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']); - $success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).'; + $success = __('int_webhook_test_sent'); } $enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1'; @@ -92,24 +92,14 @@ $adminBase = ''; -Integration & Wartung – <?= htmlspecialchars($appTitle) ?> +<?= __('int_title') ?> – <?= htmlspecialchars($appTitle) ?> - - - -

🔧 Integration & Wartung

+
@@ -118,64 +108,64 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
-

Sicherheitsrichtlinie

-

Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.

- - +

+

+ + - - + + - - + +
-
+
-

Reverse-Proxy

-

IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus X-Forwarded-For für Rate-Limit & Audit verwendet.

+

+

X-Forwarded-For

-

Webhook-Benachrichtigungen

-

Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.

- - +

+

+ +
-

SMS-Versand (Twilio)

-

Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.

- +

+

+
-
-
+
+
-

Single Sign-On (OpenID Connect)

-

Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI:

- +

+

+
-
+
-
+
@@ -184,21 +174,21 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
-

Datenhaltung & Cleanup (DSGVO)

-

Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per cron_cleanup.php (täglich empfohlen). -
Letzter Lauf: +

+

cron_cleanup.php +

-
-
-
+
+
+
- + - + diff --git a/admin/kiosks.php b/admin/kiosks.php new file mode 100644 index 0000000..50019af --- /dev/null +++ b/admin/kiosks.php @@ -0,0 +1,500 @@ +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 = ''; +?> + + + + + +<?= __('kiosks_title') ?> – <?= htmlspecialchars($appTitle) ?> + + + + + +
+
+ + +
+
+

+ + + +
+ +
+
+

+ +
+ +
+ + +
+
+
+
+
+
+ + + +
+ +
+
+ + +
+
+ + 0 ? ' / ' . (int)$k['daily_limit'] : '' ?> + +
+
+ + +
+
+ + + + +
+ + + + + +
+ + + +
+ + + +
+
+ +
+ + + + + + + + + + +
+ + + + diff --git a/admin/reports.php b/admin/reports.php index e43b056..7b2aebf 100644 --- a/admin/reports.php +++ b/admin/reports.php @@ -6,6 +6,7 @@ ini_set('log_errors', 1); require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Ui.php'; require_once __DIR__ . '/../includes/I18n.php'; $auth = new Auth(); @@ -95,56 +96,46 @@ $adminBase = ''; -Reporting – <?= htmlspecialchars($appTitle) ?> - +<?= __('rep_title') ?> – <?= htmlspecialchars($appTitle) ?> + - - - -

📊 Reporting

+
- +
- ⬇️ CSV (täglich) - ⬇️ CSV (pro Site) - ⬇️ CSV (pro Nutzer) - + + + +
-
Vouchers gesamt
-
Gültig
-
Verwendet
-
In Tagen erstellt
+
+
+
+
-

Erstellte Voucher ( Tage)

+

-

Pro Site

- +

+
SiteGesamtGültigVerwendetAbgelaufen
@@ -152,22 +143,32 @@ select.input { padding:9px; border:2px solid var(--border-color); border-radius:
-

Top-Nutzer

-
+

+
BenutzerVoucher erstellt
- +
Keine Daten
- + diff --git a/admin/security.php b/admin/security.php index b1934d0..3de454c 100644 --- a/admin/security.php +++ b/admin/security.php @@ -6,6 +6,10 @@ ini_set('log_errors', 1); require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/I18n.php'; +require_once __DIR__ . '/../includes/Ui.php'; + +I18n::init(); $auth = new Auth(); $auth->requireLogin(); @@ -24,20 +28,20 @@ $setupRequired = isset($_GET['setup_required']); // 2FA aktivieren (Code bestaetigen) if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = 'Ungültiges Sicherheits-Token'; + $error = __('sec_token_invalid'); } else { $secret = $_SESSION['totp_setup_secret'] ?? ''; $code = trim($_POST['code'] ?? ''); if ($secret === '') { - $error = 'Setup abgelaufen, bitte erneut starten.'; + $error = __('sec_setup_expired'); } elseif (!Totp::verify($secret, $code)) { - $error = 'Code ungültig. Bitte erneut versuchen.'; + $error = __('sec_code_invalid'); } else { $backupCodes = $auth->enableTotp($user['id'], $secret); unset($_SESSION['totp_setup_secret']); $totpEnabled = true; $user = $auth->getCurrentUser(); - $success = 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!'; + $success = __('sec_enabled'); } } } @@ -45,32 +49,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) { // Überall abmelden (andere Sessions beenden) if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = 'Ungültiges Sicherheits-Token'; + $error = __('sec_token_invalid'); } else { $auth->logoutOtherSessions(); - $success = 'Alle anderen Sitzungen wurden beendet.'; + $success = __('sec_sessions_closed'); } } // Recovery-Codes neu erzeugen if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = 'Ungültiges Sicherheits-Token'; + $error = __('sec_token_invalid'); } elseif (!empty($user['totp_enabled'])) { $backupCodes = $auth->regenerateBackupCodes($user['id']); $user = $auth->getCurrentUser(); - $success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.'; + $success = __('sec_codes_new'); } } // 2FA deaktivieren if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = 'Ungültiges Sicherheits-Token'; + $error = __('sec_token_invalid'); } else { $auth->disableTotp($user['id']); $totpEnabled = false; - $success = 'Zwei-Faktor-Authentifizierung wurde deaktiviert.'; + $success = __('sec_disabled'); } } @@ -87,54 +91,36 @@ $dbSessions = $db->getSetting('session_driver', 'php') === 'db'; $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0; ?> - + -Zwei-Faktor-Authentifizierung – <?= htmlspecialchars($appTitle) ?> +<?= __('sec_title') ?> – <?= htmlspecialchars($appTitle) ?> - + - + - -
-

🔐 Zwei-Faktor-Authentifizierung

-

Konto:

+ +
+
+ +
+

+

+
+
-
Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.
+
- 🔑 Recovery-Codes -

Bewahren Sie diese sicher auf. Jeder Code funktioniert einmal, falls Sie keinen Zugriff auf Ihre App haben.

+ +

@@ -142,53 +128,54 @@ input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-ra -
● Nicht verfügbar
-

Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.

+
+

-
● Aktiv
-

Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.
- Verbleibende Recovery-Codes: backupCodesRemaining($user) ?>

+
+


+ backupCodesRemaining($user) ?>

- +
-
+ - +
-
● Inaktiv
+
    -
  1. Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)
  2. -
  3. QR-Code scannen oder Secret manuell eingeben
  4. -
  5. Den angezeigten 6-stelligen Code unten eingeben
  6. +
  7. +
  8. +
- - - + + +
-
-

Aktive Sitzungen:

-
+
+

+ - +
- ← Zurück +
diff --git a/admin/settings.php b/admin/settings.php index fbe7e77..d5763aa 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -8,6 +8,8 @@ require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Mailer.php'; require_once __DIR__ . '/../includes/I18n.php'; +require_once __DIR__ . '/../includes/Ui.php'; +require_once __DIR__ . '/../includes/Upload.php'; $auth = new Auth(); $auth->requireAdmin(); @@ -51,13 +53,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($formType === 'general') { $settings['app_title'] = trim($_POST['app_title'] ?? ''); - $settings['logo_url'] = trim($_POST['logo_url'] ?? ''); - $settings['favicon_url'] = trim($_POST['favicon_url'] ?? ''); + $settings['logo_url'] = Upload::resolveField('logo_url', (string)$db->getSetting('logo_url', ''), 'image'); + $settings['favicon_url'] = Upload::resolveField('favicon_url', (string)$db->getSetting('favicon_url', ''), 'favicon'); $settings['instruction_header'] = trim($_POST['instruction_header'] ?? ''); $settings['instruction_text'] = $_POST['instruction_text'] ?? ''; $settings['public_access'] = isset($_POST['public_access']) ? '1' : '0'; } + if ($formType === 'branding') { + foreach (['brand_accent', 'brand_accent_dark', 'brand_gradient_from', 'brand_gradient_to'] as $key) { + $value = strtolower(trim($_POST[$key] ?? '')); + $settings[$key] = preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : ''; + } + $radius = (int)($_POST['brand_radius'] ?? Ui::DEFAULT_RADIUS); + $settings['brand_radius'] = (string)max(0, min(28, $radius)); + } + + if ($formType === 'login') { + $settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0'; + $settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? ''); + $settings['login_logo_url'] = Upload::resolveField('login_logo_url', (string)$db->getSetting('login_logo_url', ''), 'image'); + $settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? ''); + $settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? ''); + $settings['login_features'] = trim($_POST['login_features'] ?? ''); + $settings['login_footer'] = trim($_POST['login_footer'] ?? ''); + $settings['login_bg_image'] = Upload::resolveField('login_bg_image', (string)$db->getSetting('login_bg_image', ''), 'image'); + $settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? ''); + $settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? ''); + $overlay = (int)($_POST['login_bg_overlay'] ?? 40); + $settings['login_bg_overlay'] = (string)max(0, min(90, $overlay)); + } + if ($formType === 'defaults') { $expMin = (int)($_POST['default_expire_minutes'] ?? 480); $defDev = (int)($_POST['default_max_uses'] ?? 1); @@ -98,7 +124,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { } if ($formType === 'system') { - $settings['tinymce_api_key'] = trim($_POST['tinymce_api_key'] ?? ''); $settings['print_template'] = $_POST['print_template'] ?? ''; } @@ -107,6 +132,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { } $success = __('settings_saved'); + } catch (RuntimeException $e) { + $error = $e->getMessage(); } catch (Exception $e) { $error = 'Fehler: ' . $e->getMessage(); } @@ -185,11 +212,26 @@ $cs = [ 'smtp_from_name' => $db->getSetting('smtp_from_name', ''), 'system_url' => $db->getSetting('system_url', $autoDetectedUrl), 'email_voucher_subject' => $db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'), - 'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nIhr Code: {VOUCHER_CODE}\n\nGültigkeit: 8h\nGeräte: {MAX_USES}\nSite: {SITE_NAME}"), + 'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\nMaximale Geräte: {MAX_USES}
\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}"), 'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'), 'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"), - 'tinymce_api_key' => $db->getSetting('tinymce_api_key', ''), - 'print_template' => $db->getSetting('print_template', '

{APP_TITLE}

WLAN Code

{VOUCHER_CODE}

Gültig bis: {EXPIRY_DATE} {EXPIRY_TIME}

Site: {SITE_NAME}

Geräte: {MAX_USES}


{INSTRUCTIONS}
'), + 'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()), + 'brand_accent' => $db->getSetting('brand_accent', '') ?: Ui::DEFAULT_ACCENT, + 'brand_accent_dark' => $db->getSetting('brand_accent_dark', '') ?: Ui::DEFAULT_ACCENT_DARK, + 'brand_gradient_from' => $db->getSetting('brand_gradient_from', '') ?: Ui::DEFAULT_GRADIENT_FROM, + 'brand_gradient_to' => $db->getSetting('brand_gradient_to', '') ?: Ui::DEFAULT_GRADIENT_TO, + 'brand_radius' => $db->getSetting('brand_radius', (string)Ui::DEFAULT_RADIUS), + 'login_panel_enabled' => $db->getSetting('login_panel_enabled', '1'), + 'login_brand_name' => $db->getSetting('login_brand_name', ''), + 'login_logo_url' => $db->getSetting('login_logo_url', ''), + 'login_claim_title' => $db->getSetting('login_claim_title', ''), + 'login_claim_text' => $db->getSetting('login_claim_text', ''), + 'login_features' => $db->getSetting('login_features', ''), + 'login_footer' => $db->getSetting('login_footer', ''), + 'login_bg_image' => $db->getSetting('login_bg_image', ''), + 'login_bg_from' => $db->getSetting('login_bg_from', '#3b2f8f'), + 'login_bg_to' => $db->getSetting('login_bg_to', '#6d5ce7'), + 'login_bg_overlay' => $db->getSetting('login_bg_overlay', '40'), 'cron_token' => $db->getSetting('cron_token', ''), 'last_cron_sync' => $db->getSetting('last_cron_sync', ''), ]; @@ -204,101 +246,63 @@ $adminBase = ''; <?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?> - - - - - + + + - - - -
+
-
+
- - - - - - - - + + + + + + + + + +
-
-

-
+
+

+
-
-
+ +

>
- +
-
-

-

Diese Werte werden als Vorgabe im Voucher-Formular verwendet.

+
+

+

@@ -320,37 +324,184 @@ $adminBase = '';
-

Gültigkeits-Referenz

+

Gültigkeits-Referenz

60 Min = 1 Stunde  |  480 Min = 8 Stunden  |  1440 Min = 1 Tag  |  10080 Min = 1 Woche  |  43200 Min = 30 Tage

- + + +
+ + +
+

+

+
+ + + +
+
+ +
+ + +
+
+
+
+ +
+ + +
+
+
+
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+
+ +
+

+
+ + + + + + +
+
+ + +
+
+ + +
+

+

+
+ + + +
+ > + +
+ +
+
+ + +
+
+ +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+
+ +
+ + + + +
-
-

+
+

-

Was macht der Cron-Job?

+

Der Cron-Job synchronisiert automatisch alle Voucher von Ihren UniFi Controllern in die lokale Datenbank.


-

Kein Token konfiguriert.

+

Kein Token konfiguriert.

- +
- -
-
+ +
+
Cron-URL
- +
@@ -371,7 +522,7 @@ $adminBase = '';
- +
@@ -380,9 +531,9 @@ $adminBase = '';
-

Microsoft 365

+

Microsoft 365

-

Azure AD App

+

Azure AD App

Redirect URI: /m365_callback.php

@@ -391,13 +542,13 @@ $adminBase = '';
- +
-
-

SMTP

+
+

SMTP

@@ -409,60 +560,60 @@ $adminBase = '';
-
+
-
+
- +

SMTP testen

- +
-
-

E-Mail Templates

+
+

E-Mail Templates

Auto:

-

Voucher E-Mail

-

Platzhalter:

{VOUCHER_CODE}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}
+

+

{VOUCHER_CARD}{VOUCHER_CODE}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}


-

Benutzer-Benachrichtigung

-

Platzhalter:

{USER_NAME}{CHANGES}{APP_TITLE}{SYSTEM_URL}
+

+

{USER_NAME}{CHANGES}{APP_TITLE}{SYSTEM_URL}
- +
-
-

System & Erweitert

+
+

System & Erweitert

-

TinyMCE API Key

-

Kostenlosen API Key: tiny.cloud/signup

+

WYSIWYG-Editor

+

assets/vendor/

-
Für WYSIWYG-Editor in Anleitungen
+

Druck-Template

-

Platzhalter:

{VOUCHER_CODE}{EXPIRY_DATE}{EXPIRY_TIME}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}
-
- +

{QR_CODE}{VOUCHER_CODE}{EXPIRY_DATE}{EXPIRY_TIME}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}

+
+

System-Information

@@ -474,19 +625,19 @@ $adminBase = '';
-
-

+
+

- +
-
+ diff --git a/admin/sites.php b/admin/sites.php index 83c37e8..23b0e14 100644 --- a/admin/sites.php +++ b/admin/sites.php @@ -101,68 +101,24 @@ $currentPage = 'sites'; <?= __('sites_title') ?> – <?= htmlspecialchars($appTitle) ?> - -
+
-
+
- +

@@ -176,43 +132,43 @@ $currentPage = 'sites';
- + - + - +
- +
- +
- +
@@ -220,7 +176,7 @@ $currentPage = 'sites';
-
+
- +
@@ -313,7 +269,7 @@ $currentPage = 'sites';
@@ -322,7 +278,7 @@ $currentPage = 'sites'; -
+
- + - -
+ +
- +

@@ -129,11 +110,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- + - +
diff --git a/includes/.htaccess b/includes/.htaccess new file mode 100644 index 0000000..2ac81c4 --- /dev/null +++ b/includes/.htaccess @@ -0,0 +1,2 @@ +# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert. +Require all denied diff --git a/includes/Kiosk.php b/includes/Kiosk.php new file mode 100644 index 0000000..ef25a56 --- /dev/null +++ b/includes/Kiosk.php @@ -0,0 +1,169 @@ +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; + } +} diff --git a/includes/Mailer.php b/includes/Mailer.php index 866b2eb..7f5d1ad 100644 --- a/includes/Mailer.php +++ b/includes/Mailer.php @@ -1,265 +1,339 @@ -db = Database::getInstance(); - $this->loadSettings(); - } - - private function loadSettings() { - $this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1'; - $this->smtpHost = $this->db->getSetting('smtp_host', ''); - $this->smtpPort = (int)$this->db->getSetting('smtp_port', '587'); - $this->smtpUsername = $this->db->getSetting('smtp_username', ''); - $this->smtpPassword = $this->db->getSetting('smtp_password', ''); - $this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls'); - $this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']); - $this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System')); - } - - public function sendRaw($to, $subject, $plainBody) { - return $this->send($to, $subject, $plainBody, false); - } - - public function send($to, $subject, $body, $isHtml = false) { - // Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry). - $attempts = 2; - for ($i = 1; $i <= $attempts; $i++) { - if (!$this->smtpEnabled || empty($this->smtpHost)) { - $ok = $this->sendWithPhpMail($to, $subject, $body); - } else { - $ok = $this->sendWithSmtp($to, $subject, $body, $isHtml); - } - if ($ok) { - return true; - } - if ($i < $attempts) { - usleep(500000); // 0,5s vor erneutem Versuch - } - } - error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen."); - return false; - } - - private function sendWithPhpMail($to, $subject, $body) { - $headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; - $headers .= "Reply-To: {$this->fromEmail}\r\n"; - $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; - - return mail($to, $subject, $body, $headers); - } - - private function sendWithSmtp($to, $subject, $body, $isHtml = false) { - try { - // Verbindung aufbauen - $socket = $this->connectToSmtp(); - - // EHLO - $this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); - - // STARTTLS wenn nötig - if ($this->smtpEncryption === 'tls') { - $this->smtpCommand($socket, "STARTTLS"); - stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); - $this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); - } - - // AUTH LOGIN - $this->smtpCommand($socket, "AUTH LOGIN"); - $this->smtpCommand($socket, base64_encode($this->smtpUsername)); - $this->smtpCommand($socket, base64_encode($this->smtpPassword)); - - // MAIL FROM - $this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>"); - - // RCPT TO - $this->smtpCommand($socket, "RCPT TO:<{$to}>"); - - // DATA - $this->smtpCommand($socket, "DATA"); - - // Headers - $message = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; - $message .= "To: {$to}\r\n"; - $message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n"; - $message .= "MIME-Version: 1.0\r\n"; - - if ($isHtml) { - $message .= "Content-Type: text/html; charset=UTF-8\r\n"; - } else { - $message .= "Content-Type: text/plain; charset=UTF-8\r\n"; - } - - $message .= "\r\n"; - - // Body - bei Plain Text Zeilenumbrüche konvertieren - if (!$isHtml) { - $body = nl2br($body, false); // Für Plain Text - $body = str_replace('
', "\r\n", $body); - } - - $message .= $body; - $message .= "\r\n.\r\n"; - - fwrite($socket, $message); - $response = fgets($socket); - - // QUIT - $this->smtpCommand($socket, "QUIT"); - fclose($socket); - - return strpos($response, '250') === 0; - - } catch (Exception $e) { - error_log("SMTP Error: " . $e->getMessage()); - return false; - } - } - - private function connectToSmtp() { - $context = stream_context_create([ - 'ssl' => [ - 'verify_peer' => false, - 'verify_peer_name' => false, - 'allow_self_signed' => true - ] - ]); - - if ($this->smtpEncryption === 'ssl') { - $host = 'ssl://' . $this->smtpHost; - } else { - $host = $this->smtpHost; - } - - $socket = stream_socket_client( - $host . ':' . $this->smtpPort, - $errno, - $errstr, - 30, - STREAM_CLIENT_CONNECT, - $context - ); - - if (!$socket) { - throw new Exception("SMTP Connection failed: $errstr ($errno)"); - } - - // Willkommensnachricht lesen - fgets($socket); - - return $socket; - } - - private function smtpCommand($socket, $command) { - fwrite($socket, $command . "\r\n"); - $response = fgets($socket); - - // Prüfen auf Fehler (4xx oder 5xx) - if (preg_match('/^[45]/', $response)) { - throw new Exception("SMTP Error: $response"); - } - - return $response; - } - - // Vordefinierte E-Mail-Templates - public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) { - $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); - $instructionHeader = $this->db->getSetting('instruction_header', ''); - $instructionText = $this->db->getSetting('instruction_text', ''); - - // System-URL aus Einstellungen oder automatisch erkennen - $systemUrl = $this->db->getSetting('system_url', ''); - if (empty($systemUrl)) { - $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST']; - $scriptPath = dirname($_SERVER['SCRIPT_NAME']); - $scriptPath = $scriptPath === '/' ? '' : $scriptPath; - $systemUrl = $protocol . '://' . $host . $scriptPath; - } - - // Template aus Datenbank laden - $subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'); - $bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nIhr WLAN-Zugangscode lautet:\n\n{VOUCHER_CODE}\n\nGültigkeit: 8 Stunden ab Erstellung\nMaximale Geräte: {MAX_USES}\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); - - // Anleitung formatieren - $instructions = ''; - if ($instructionText) { - $instructions = $instructionHeader . "\n" . $instructionText; - } - - // Platzhalter ersetzen - $placeholders = [ - '{VOUCHER_CODE}' => $voucherCode, - '{SITE_NAME}' => $siteName, - '{MAX_USES}' => $maxUses, - '{APP_TITLE}' => $appTitle, - '{INSTRUCTIONS}' => $instructions, - '{SYSTEM_URL}' => $systemUrl - ]; - - $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); - $body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate); - - // HTML oder Plain Text prüfen - $isHtml = strip_tags($body) !== $body; - - return $this->send($to, $subject, $body, $isHtml); - } - - public function sendTestEmail($to) { - $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); - $subject = '[Test] E-Mail-Konfiguration – ' . $appTitle; - $body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet."; - return $this->send($to, $subject, $body, false); - } - - public function sendUserNotification($to, $userName, $changes) { - $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); - - // System-URL aus Einstellungen oder automatisch erkennen - $systemUrl = $this->db->getSetting('system_url', ''); - if (empty($systemUrl)) { - $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST']; - $scriptPath = dirname($_SERVER['SCRIPT_NAME']); - $scriptPath = $scriptPath === '/' ? '' : $scriptPath; - $systemUrl = $protocol . '://' . $host . $scriptPath; - } - - // Template aus Datenbank laden - $subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert'); - $bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); - - // Änderungen formatieren - $changesText = ''; - foreach ($changes as $change) { - $changesText .= "• $change\n"; - } - - // Platzhalter ersetzen - $placeholders = [ - '{USER_NAME}' => $userName, - '{CHANGES}' => $changesText, - '{APP_TITLE}' => $appTitle, - '{SYSTEM_URL}' => $systemUrl - ]; - - $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); - $body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate); - - // HTML oder Plain Text prüfen - $isHtml = strip_tags($body) !== $body; - - return $this->send($to, $subject, $body, $isHtml); - } +db = Database::getInstance(); + $this->loadSettings(); + } + + private function loadSettings() { + $this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1'; + $this->smtpHost = $this->db->getSetting('smtp_host', ''); + $this->smtpPort = (int)$this->db->getSetting('smtp_port', '587'); + $this->smtpUsername = $this->db->getSetting('smtp_username', ''); + $this->smtpPassword = $this->db->getSetting('smtp_password', ''); + $this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls'); + $this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']); + $this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System')); + } + + public function sendRaw($to, $subject, $plainBody) { + return $this->send($to, $subject, $plainBody, false); + } + + public function send($to, $subject, $body, $isHtml = false) { + // Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry). + $attempts = 2; + for ($i = 1; $i <= $attempts; $i++) { + if (!$this->smtpEnabled || empty($this->smtpHost)) { + $ok = $this->sendWithPhpMail($to, $subject, $body); + } else { + $ok = $this->sendWithSmtp($to, $subject, $body, $isHtml); + } + if ($ok) { + return true; + } + if ($i < $attempts) { + usleep(500000); // 0,5s vor erneutem Versuch + } + } + error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen."); + return false; + } + + private function sendWithPhpMail($to, $subject, $body) { + $headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; + $headers .= "Reply-To: {$this->fromEmail}\r\n"; + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + + return mail($to, $subject, $body, $headers); + } + + private function sendWithSmtp($to, $subject, $body, $isHtml = false) { + try { + // Verbindung aufbauen + $socket = $this->connectToSmtp(); + + // EHLO + $this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); + + // STARTTLS wenn nötig + if ($this->smtpEncryption === 'tls') { + $this->smtpCommand($socket, "STARTTLS"); + stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + $this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); + } + + // AUTH LOGIN + $this->smtpCommand($socket, "AUTH LOGIN"); + $this->smtpCommand($socket, base64_encode($this->smtpUsername)); + $this->smtpCommand($socket, base64_encode($this->smtpPassword)); + + // MAIL FROM + $this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>"); + + // RCPT TO + $this->smtpCommand($socket, "RCPT TO:<{$to}>"); + + // DATA + $this->smtpCommand($socket, "DATA"); + + // Headers + $message = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; + $message .= "To: {$to}\r\n"; + $message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n"; + $message .= "MIME-Version: 1.0\r\n"; + + if ($isHtml) { + $message .= "Content-Type: text/html; charset=UTF-8\r\n"; + } else { + $message .= "Content-Type: text/plain; charset=UTF-8\r\n"; + } + + $message .= "\r\n"; + + // Body - bei Plain Text Zeilenumbrüche konvertieren + if (!$isHtml) { + $body = nl2br($body, false); // Für Plain Text + $body = str_replace('
', "\r\n", $body); + } + + $message .= $body; + $message .= "\r\n.\r\n"; + + fwrite($socket, $message); + $response = fgets($socket); + + // QUIT + $this->smtpCommand($socket, "QUIT"); + fclose($socket); + + return strpos($response, '250') === 0; + + } catch (Exception $e) { + error_log("SMTP Error: " . $e->getMessage()); + return false; + } + } + + private function connectToSmtp() { + $context = stream_context_create([ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ] + ]); + + if ($this->smtpEncryption === 'ssl') { + $host = 'ssl://' . $this->smtpHost; + } else { + $host = $this->smtpHost; + } + + $socket = stream_socket_client( + $host . ':' . $this->smtpPort, + $errno, + $errstr, + 30, + STREAM_CLIENT_CONNECT, + $context + ); + + if (!$socket) { + throw new Exception("SMTP Connection failed: $errstr ($errno)"); + } + + // Willkommensnachricht lesen + fgets($socket); + + return $socket; + } + + private function smtpCommand($socket, $command) { + fwrite($socket, $command . "\r\n"); + $response = fgets($socket); + + // Prüfen auf Fehler (4xx oder 5xx) + if (preg_match('/^[45]/', $response)) { + throw new Exception("SMTP Error: $response"); + } + + return $response; + } + + // Vordefinierte E-Mail-Templates + /** + * Legt den Nachrichtentext in ein schlichtes, markentreues HTML-Gerüst. + * Bewusst Tabellen + Inline-Styles: nur so rendern Outlook & Co. zuverlässig. + */ + private function brandedHtml(string $title, string $contentHtml, string $footerNote = ''): string + { + $accent = $this->db->getSetting('brand_gradient_from', '') ?: '#5b5bd6'; + $accent2 = $this->db->getSetting('brand_gradient_to', '') ?: '#8b5cf6'; + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent)) { $accent = '#5b5bd6'; } + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent2)) { $accent2 = '#8b5cf6'; } + + $safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); + $year = date('Y'); + $footer = $footerNote !== '' ? '
' . htmlspecialchars($footerNote, ENT_QUOTES, 'UTF-8') . '
' : ''; + + return '' + . '' + . '' . $safeTitle . '' + . '' + . '' + . '
' + . '' + . '' + . '' + . '' + . '
' + . '
' . $safeTitle . '
' + . '
' . $contentHtml . '
' + . '© ' . $year . ' ' . $safeTitle . $footer + . '
'; + } + + /** + * Voucher-Code als hervorgehobene Karte für die E-Mail. + */ + private function voucherCardHtml(string $code, string $siteName, $maxUses): string + { + return '' + . '
' + . '
' + . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '
' + . '
' + . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '
' + . '
' + . htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' ' + . htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '
' + . '
'; + } + + public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) { + $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); + $instructionHeader = $this->db->getSetting('instruction_header', ''); + $instructionText = $this->db->getSetting('instruction_text', ''); + + // System-URL aus Einstellungen oder automatisch erkennen + $systemUrl = $this->db->getSetting('system_url', ''); + if (empty($systemUrl)) { + $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST']; + $scriptPath = dirname($_SERVER['SCRIPT_NAME']); + $scriptPath = $scriptPath === '/' ? '' : $scriptPath; + $systemUrl = $protocol . '://' . $host . $scriptPath; + } + + // Template aus Datenbank laden + $subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'); + $bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\nMaximale Geräte: {MAX_USES}
\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}"); + + // Anleitung formatieren + $instructions = ''; + if ($instructionText) { + $instructions = $instructionHeader . "\n" . $instructionText; + } + + // Platzhalter ersetzen + $placeholders = [ + '{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses), + '{VOUCHER_CODE}' => $voucherCode, + '{SITE_NAME}' => $siteName, + '{MAX_USES}' => $maxUses, + '{APP_TITLE}' => $appTitle, + '{INSTRUCTIONS}' => $instructions, + '{SYSTEM_URL}' => $systemUrl + ]; + + $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); + + // Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln, + // sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren. + $isHtml = strip_tags($bodyTemplate) !== $bodyTemplate || strpos($bodyTemplate, '{VOUCHER_CARD}') !== false; + $template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate; + $body = str_replace(array_keys($placeholders), array_values($placeholders), $template); + + if ($isHtml) { + $body = $this->brandedHtml($appTitle, $body); + } + + return $this->send($to, $subject, $body, $isHtml); + } + + public function sendTestEmail($to) { + $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); + $subject = '[Test] E-Mail-Konfiguration – ' . $appTitle; + $body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet."; + return $this->send($to, $subject, $body, false); + } + + public function sendUserNotification($to, $userName, $changes) { + $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); + + // System-URL aus Einstellungen oder automatisch erkennen + $systemUrl = $this->db->getSetting('system_url', ''); + if (empty($systemUrl)) { + $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST']; + $scriptPath = dirname($_SERVER['SCRIPT_NAME']); + $scriptPath = $scriptPath === '/' ? '' : $scriptPath; + $systemUrl = $protocol . '://' . $host . $scriptPath; + } + + // Template aus Datenbank laden + $subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert'); + $bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); + + // Änderungen formatieren + $changesText = ''; + foreach ($changes as $change) { + $changesText .= "• $change\n"; + } + + // Platzhalter ersetzen + $placeholders = [ + '{USER_NAME}' => $userName, + '{CHANGES}' => $changesText, + '{APP_TITLE}' => $appTitle, + '{SYSTEM_URL}' => $systemUrl + ]; + + $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); + + $isHtml = strip_tags($bodyTemplate) !== $bodyTemplate; + $template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate; + $body = str_replace(array_keys($placeholders), array_values($placeholders), $template); + + if ($isHtml) { + $body = $this->brandedHtml($appTitle, $body); + } + + return $this->send($to, $subject, $body, $isHtml); + } + + /** + * Zeilenumbrüche aus dem Vorlagentext in HTML übernehmen, ohne bereits + * vorhandenes Markup (z. B. aus dem WYSIWYG-Editor) zu zerstören. + */ + private function textToHtml(string $body): string + { + if (preg_match('#<(p|div|ul|ol|h[1-6])[\s>]#i', $body)) { + return $body; + } + + return nl2br($body, false); + } } \ No newline at end of file diff --git a/includes/Ui.php b/includes/Ui.php new file mode 100644 index 0000000..b7faa7a --- /dev/null +++ b/includes/Ui.php @@ -0,0 +1,259 @@ + mit Versionsstempel. */ + public static function script(string $path, string $base = '', bool $defer = false): string + { + return ''; + } + + /** + * Theme-Bootstrap: gespeicherte Auswahl, sonst Systemeinstellung. + * Muss im stehen, damit nichts hell aufblitzt. + */ + public static function themeScript(): string + { + return ''; + } + + /** 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 ''; + } + + /** + * 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 !== '' + ? '' + : ''; + + $remove = $value !== '' + ? '' + : ''; + + return '
' + . '' + . '
' + . '
' . $thumb . '
' + . '
' + . '' + . '' + . $remove + . '
' + . ($hint !== '' ? '
' . $esc($hint) . '
' : '') + . '
'; + } + + /** + * 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 '

' . $prefix . htmlspecialchars($label) . ' ' + . '' + . self::CREDIT_NAME . '

'; + } + + /** + * 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 '
' + . '
{APP_TITLE}
' + . '
{SITE_NAME}
' + . '{QR_CODE}' + . '
{VOUCHER_CODE}
' + . '
' . $validUntil . ' {EXPIRY_DATE} {EXPIRY_TIME} · {MAX_USES} ' . $devices . '
' + . '
{INSTRUCTIONS}
' + . '
'; + } + + /** + * 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[] = ''; + } + + $out[] = ''; + $out[] = ''; + $out[] = ''; + $out[] = ''; + $out[] = self::themeScript(); + + $branding = self::brandingStyle($db); + if ($branding !== '') { + $out[] = $branding; + } + + return implode("\n ", $out); + } +} diff --git a/includes/Upload.php b/includes/Upload.php new file mode 100644 index 0000000..2a97b90 --- /dev/null +++ b/includes/Upload.php @@ -0,0 +1,175 @@ + ['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\n Require all denied\n\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 , _file, _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, ']*>.*?<\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('#]*>#i', '', $svg); + + return (string)$svg; + } +} diff --git a/includes/VoucherService.php b/includes/VoucherService.php new file mode 100644 index 0000000..9ddcaa7 --- /dev/null +++ b/includes/VoucherService.php @@ -0,0 +1,63 @@ + 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), + ]; + } +} diff --git a/includes/admin_nav.php b/includes/admin_nav.php index 2c39349..167c9ff 100644 --- a/includes/admin_nav.php +++ b/includes/admin_nav.php @@ -4,142 +4,122 @@ * Expects $currentPage (string), $appTitle (string), $auth, $db to be set before include. * Expects I18n to be initialized. */ +require_once __DIR__ . '/Ui.php'; + $currentPage = $currentPage ?? ''; -$faviconUrl = isset($db) ? $db->getSetting('favicon_url', '') : ''; $currentUser = isset($auth) ? $auth->getCurrentUser() : null; $lang = I18n::getLanguage(); -?> - - - - - - +$base = $adminBase ?? ''; // Prefix bis zum admin/-Ordner +$rootBase = $base === '' ? '../' : ''; // Prefix bis zum Projekt-Root - +/** Navigation als Datenstruktur – Reihenfolge = Darstellung. */ +$navGroups = [ + 'nav_group_overview' => [ + ['dashboard', 'index.php', 'fa-chart-pie', 'nav_dashboard'], + ['reports', 'reports.php', 'fa-chart-line', 'nav_reports'], + ['audit_log', 'audit_log.php', 'fa-clock-rotate-left','nav_audit_log'], + ], + 'nav_group_manage' => [ + ['vouchers', 'vouchers.php', 'fa-ticket', 'nav_vouchers'], + ['templates', 'templates.php', 'fa-layer-group', 'nav_templates'], + ['import', 'import.php', 'fa-file-arrow-up', 'nav_import'], + ['kiosks', 'kiosks.php', 'fa-display', 'nav_kiosks'], + ['sites', 'sites.php', 'fa-location-dot', 'nav_sites'], + ['users', 'users.php', 'fa-users', 'nav_users'], + ], + 'nav_group_system' => [ + ['settings', 'settings.php', 'fa-sliders', 'nav_settings'], + ['integrations', 'integrations.php', 'fa-plug', 'nav_integrations'], + ['api_keys', 'api_keys.php', 'fa-key', 'nav_api_keys'], + ['security', 'security.php', 'fa-shield-halved','nav_security'], + ['backup', 'backup.php', 'fa-database', 'nav_backup'], + ['update', 'update.php', 'fa-rotate', 'nav_update'], + ], +]; + +/** Aktuellen Seitentitel für die Breadcrumb finden. */ +$currentLabel = __('nav_dashboard'); +foreach ($navGroups as $items) { + foreach ($items as $item) { + if ($item[0] === $currentPage) { $currentLabel = __($item[3]); } + } +} +?> + - + -
+ + +
- -
- - +
- -
+
$label): ?>
- - - - - + + + - - -
-
-
-
-
-
+
- - -
- +
diff --git a/index.php b/index.php index 2cfafc0..020c796 100644 --- a/index.php +++ b/index.php @@ -19,6 +19,8 @@ require_once __DIR__ . '/includes/Mailer.php'; require_once __DIR__ . '/includes/Notifier.php'; require_once __DIR__ . '/includes/Captcha.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'; $auth = new Auth(); @@ -74,7 +76,7 @@ $instructionHeader = $db->getSetting('instruction_header', ''); $instructionText = $db->getSetting('instruction_text', ''); $publicAccess = $db->getSetting('public_access', 0); $smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1'; -$printTemplate = $db->getSetting('print_template', '

{APP_TITLE}

WLAN Zugangscode

{VOUCHER_CODE}

Gültig bis: {EXPIRY_DATE} {EXPIRY_TIME}

Standort: {SITE_NAME}

Maximale Geräte: {MAX_USES}


{INSTRUCTIONS}
'); +$printTemplate = $db->getSetting('print_template', Ui::defaultPrintTemplate()); $defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480)); $defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1)); $maxUsesLimit = max(1, (int)$db->getSetting('max_uses_limit', 10)); @@ -114,34 +116,9 @@ if ($auth->isLoggedIn()) { $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0; -// Helper: create one voucher and save to DB +// Voucher-Erstellung liegt gebuendelt in includes/VoucherService.php. function doCreateVoucher($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), - ]; + return VoucherService::create($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos); } // Single voucher @@ -271,9 +248,12 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, $instructions = $instructionHeader || $instructionText ? htmlspecialchars($instructionHeader) . "\n" . $instructionText : ''; + // {QR_CODE} wird erst im Browser gefuellt (siehe renderPrintQr()). + $qr = ''; + return str_replace( - ['{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'], - [$data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']], + ['{QR_CODE}', '{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']], $template ); } @@ -284,120 +264,76 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, <?= htmlspecialchars($appTitle) ?> - + - - - + + - + -
-
- $label): ?> - - -
- -
- - -
-
👋 htmlspecialchars($currentUser['name'])]) ?>
-
- isAdmin()): ?> - ⚙️ + +
+ + + + +
+
+ $label): ?> + + +
+ + + isAdmin()): ?> + + + + +
+
+
+ + + +
+ + + + -
-
- -
-
-
- 🔐 -
-
- + -
+
- + -

+
+

+

+
@@ -410,13 +346,19 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
-
-
+
+
- +
+
+ + + +
+
@@ -430,15 +372,22 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
-
- -
- +
+
+ +
+ +
-

+
+

+

+
$bv): ?> @@ -463,8 +412,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, - + @@ -472,21 +421,21 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, -

Code anklicken zum Kopieren

+

-
- +
-
📶
+


isAdmin()): ?> @@ -570,7 +519,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,

@@ -646,7 +595,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
- @@ -660,23 +609,42 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, -
+
-
+
+ +
+ + + diff --git a/lang/de.php b/lang/de.php index 6e71868..7b07a87 100644 --- a/lang/de.php +++ b/lang/de.php @@ -1,6 +1,9 @@ 'Übersicht', + 'nav_group_manage' => 'Verwaltung', + 'nav_group_system' => 'System', 'nav_dashboard' => 'Dashboard', 'nav_sites' => 'Sites verwalten', 'nav_users' => 'Benutzer verwalten', @@ -98,7 +101,9 @@ return [ 'voucher_email_hint' => 'gast@example.com', 'voucher_create_btn' => 'Voucher erstellen', 'voucher_creating' => 'Erstelle Voucher...', - 'voucher_success_title' => '✓ Ihr Zugangs-Code', + 'voucher_success_title' => 'Zugangs-Code erstellt', + 'voucher_copy_hint' => 'Code antippen zum Kopieren', + 'app_subtitle' => 'WLAN-Zugangscodes in Sekunden erstellen – für Gäste, Teams und Events.', 'voucher_validity' => 'Gültig für {minutes} Minuten ab Erstellung', 'voucher_qr_label' => 'QR-Code scannen zum Verbinden', 'voucher_print_btn' => 'Code ausdrucken', @@ -119,6 +124,7 @@ return [ 'bulk_success' => '{count} Vouchers erfolgreich erstellt!', 'bulk_print_all' => 'Alle ausdrucken', 'bulk_results' => 'Erstellte Vouchers ({count})', + 'bulk_results_hint' => 'Alle Codes sind sofort gültig und können gedruckt oder kopiert werden.', // Templates 'templates_title' => 'Voucher-Profile', @@ -214,6 +220,323 @@ return [ // Settings 'settings_title' => 'Einstellungen', '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_login_intro' => 'Aussehen und Texte der Anmeldeseite. Leere Felder verwenden die Standardwerte.', + 'settings_login_panel' => 'Linke Bildspalte (Split-Screen) anzeigen', + 'settings_login_brand' => 'Firmenname', + 'settings_login_brand_hint' => 'Leer = Anwendungstitel wird verwendet.', + 'settings_login_logo' => 'Logo (URL)', + 'settings_login_logo_hint' => 'Leer = allgemeines Logo aus dem Tab „Allgemein".', + 'settings_login_claim_title' => 'Überschrift', + 'settings_login_claim_text' => 'Beschreibungstext', + 'settings_login_features' => 'Stichpunkte', + 'settings_login_features_hint'=> 'Ein Stichpunkt pro Zeile. Leer lassen, um keine Liste anzuzeigen.', + 'settings_login_footer' => 'Fußzeile', + 'settings_login_bg_image' => 'Hintergrundbild (URL)', + 'settings_login_bg_image_hint'=> 'Optional. Wird formatfüllend angezeigt; ohne Bild gilt der Farbverlauf.', + 'settings_login_bg_from' => 'Verlauf: Startfarbe', + 'settings_login_bg_to' => 'Verlauf: Endfarbe', + 'settings_login_overlay' => 'Abdunklung des Bildes (%)', + 'settings_login_overlay_hint' => '0–90 %. Höhere Werte machen den Text auf hellen Bildern besser lesbar.', + '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' => '0–90 %. 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_defaults' => 'Voucher-Standards', 'settings_tab_cron' => 'Cron-Sync', @@ -224,8 +547,8 @@ return [ 'settings_tab_password' => 'Passwort', 'settings_saved' => 'Einstellungen erfolgreich gespeichert!', 'settings_app_title' => 'Anwendungs-Titel *', - 'settings_logo_url' => 'Logo-URL', - 'settings_favicon_url' => 'Favicon-URL', + 'settings_logo_url' => 'Logo', + 'settings_favicon_url' => 'Favicon', 'settings_favicon_hint' => 'Icon im Browser-Tab (.ico, .png, .svg)', 'settings_instr_header' => 'Anleitung - Überschrift', 'settings_instr_text' => 'Anleitung - Text', @@ -244,13 +567,18 @@ return [ // Login / Auth 'login_title' => 'Anmelden', + 'auth_claim_title' => 'WLAN-Gastzugänge, zentral verwaltet.', + 'auth_claim_text' => 'Vouchers für alle UniFi-Sites erstellen, versenden und auswerten – aus einer Oberfläche.', + 'auth_feature_1' => 'Voucher per E-Mail, SMS oder QR-Code', + 'auth_feature_2' => 'Rollen, 2FA und vollständiges Audit-Log', + 'auth_feature_3' => 'Live-Statistiken je Site und Benutzer', 'login_subtitle' => 'Melden Sie sich an, um fortzufahren', 'login_email' => 'E-Mail', 'login_password' => 'Passwort', 'login_btn' => 'Anmelden', 'login_ms' => 'Mit Microsoft anmelden', 'login_local' => 'Mit Benutzername und Passwort anmelden', - 'login_back' => '← Zurück zur Code-Erstellung', + 'login_back' => 'Zurück zur Code-Erstellung', 'login_forgot' => 'Passwort vergessen?', 'login_error_empty' => 'Bitte E-Mail und Passwort eingeben', 'login_error_rate' => 'Zu viele Fehlversuche. Bitte warten Sie 10 Minuten.', @@ -262,7 +590,7 @@ return [ 'reset_email_label' => 'E-Mail-Adresse', 'reset_send_btn' => 'Reset-Link senden', 'reset_success' => 'Falls ein Konto mit dieser E-Mail existiert, erhalten Sie in Kürze eine E-Mail.', - 'reset_back_login' => '← Zurück zum Login', + 'reset_back_login' => 'Zurück zum Login', 'reset_new_pw' => 'Neues Passwort festlegen', 'reset_new_pw_label'=> 'Neues Passwort', 'reset_confirm_label'=> 'Passwort bestätigen', diff --git a/lang/en.php b/lang/en.php index 4651fb9..b3bf315 100644 --- a/lang/en.php +++ b/lang/en.php @@ -1,6 +1,9 @@ 'Overview', + 'nav_group_manage' => 'Management', + 'nav_group_system' => 'System', 'nav_dashboard' => 'Dashboard', 'nav_sites' => 'Manage Sites', 'nav_users' => 'Manage Users', @@ -98,7 +101,9 @@ return [ 'voucher_email_hint' => 'guest@example.com', 'voucher_create_btn' => 'Create Voucher', 'voucher_creating' => 'Creating Voucher...', - 'voucher_success_title' => '✓ Your Access Code', + 'voucher_success_title' => 'Access code created', + 'voucher_copy_hint' => 'Tap the code to copy it', + 'app_subtitle' => 'Create Wi-Fi access codes in seconds – for guests, teams and events.', 'voucher_validity' => 'Valid for {minutes} minutes from creation', 'voucher_qr_label' => 'Scan QR code to connect', 'voucher_print_btn' => 'Print Code', @@ -119,6 +124,7 @@ return [ 'bulk_success' => '{count} vouchers created successfully!', 'bulk_print_all' => 'Print All', 'bulk_results' => 'Created Vouchers ({count})', + 'bulk_results_hint' => 'All codes are valid immediately and can be printed or copied.', // Templates 'templates_title' => 'Voucher Profiles', @@ -214,6 +220,323 @@ return [ // Settings 'settings_title' => 'Settings', '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_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_brand' => 'Company name', + 'settings_login_brand_hint' => 'Empty = application title is used.', + 'settings_login_logo' => 'Logo (URL)', + 'settings_login_logo_hint' => 'Empty = general logo from the "General" tab.', + 'settings_login_claim_title' => 'Headline', + 'settings_login_claim_text' => 'Description', + 'settings_login_features' => 'Bullet points', + 'settings_login_features_hint'=> 'One bullet per line. Leave empty to hide the list.', + 'settings_login_footer' => 'Footer', + 'settings_login_bg_image' => 'Background image (URL)', + 'settings_login_bg_image_hint'=> 'Optional. Displayed full-bleed; without an image the gradient is used.', + 'settings_login_bg_from' => 'Gradient: start colour', + 'settings_login_bg_to' => 'Gradient: end colour', + 'settings_login_overlay' => 'Image dimming (%)', + 'settings_login_overlay_hint' => '0–90%. Higher values keep text readable on bright images.', + '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' => '0–90%. 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_defaults' => 'Voucher Defaults', 'settings_tab_cron' => 'Cron Sync', @@ -224,8 +547,8 @@ return [ 'settings_tab_password' => 'Password', 'settings_saved' => 'Settings saved successfully!', 'settings_app_title' => 'Application Title *', - 'settings_logo_url' => 'Logo URL', - 'settings_favicon_url' => 'Favicon URL', + 'settings_logo_url' => 'Logo', + 'settings_favicon_url' => 'Favicon', 'settings_favicon_hint' => 'Browser tab icon (.ico, .png, .svg)', 'settings_instr_header' => 'Instructions - Headline', 'settings_instr_text' => 'Instructions - Text', @@ -244,13 +567,18 @@ return [ // Login / Auth 'login_title' => 'Sign In', + 'auth_claim_title' => 'Wi-Fi guest access, centrally managed.', + 'auth_claim_text' => 'Create, deliver and analyse vouchers for every UniFi site from a single interface.', + 'auth_feature_1' => 'Vouchers via email, SMS or QR code', + 'auth_feature_2' => 'Roles, 2FA and a complete audit log', + 'auth_feature_3' => 'Live statistics per site and user', 'login_subtitle' => 'Sign in to continue', 'login_email' => 'Email', 'login_password' => 'Password', 'login_btn' => 'Sign In', 'login_ms' => 'Sign in with Microsoft', 'login_local' => 'Sign in with username and password', - 'login_back' => '← Back to Code Creation', + 'login_back' => 'Back to Code Creation', 'login_forgot' => 'Forgot password?', 'login_error_empty' => 'Please enter email and password', 'login_error_rate' => 'Too many failed attempts. Please wait 10 minutes.', @@ -262,7 +590,7 @@ return [ 'reset_email_label' => 'Email Address', 'reset_send_btn' => 'Send Reset Link', 'reset_success' => 'If an account with this email exists, you will receive an email shortly.', - 'reset_back_login' => '← Back to Login', + 'reset_back_login' => 'Back to Login', 'reset_new_pw' => 'Set New Password', 'reset_new_pw_label'=> 'New Password', 'reset_confirm_label'=> 'Confirm Password', diff --git a/login.php b/login.php index 1530eef..ccc325d 100644 --- a/login.php +++ b/login.php @@ -6,11 +6,15 @@ ini_set('log_errors', 1); require_once __DIR__ . '/config.php'; require_once __DIR__ . '/includes/Database.php'; require_once __DIR__ . '/includes/Auth.php'; +require_once __DIR__ . '/includes/Ui.php'; require_once __DIR__ . '/includes/I18n.php'; try { $auth = new Auth(); - if ($auth->isLoggedIn()) { header('Location: index.php'); exit; } + // Angemeldete Admins koennen die Login-Seite mit ?preview=1 ansehen + // (Vorschau aus den Einstellungen), ohne abgemeldet zu werden. + $isPreview = isset($_GET['preview']) && $auth->isLoggedIn() && $auth->isAdmin(); + if ($auth->isLoggedIn() && !$isPreview) { header('Location: index.php'); exit; } } catch (Exception $e) { die('Fehler beim Initialisieren: ' . $e->getMessage()); } @@ -117,6 +121,34 @@ try { $showLocalLogin = isset($_GET['local']) && $_GET['local'] === '1'; + // --- Individualisierung der Login-Seite ------------------------------- + // Alle Werte sind optional; leer bedeutet "Standard verwenden". + $loginBrand = $db->getSetting('login_brand_name', '') ?: $appTitle; + $loginLogo = $db->getSetting('login_logo_url', '') ?: $logoUrl; + $showPanel = $db->getSetting('login_panel_enabled', '1') === '1'; + $claimTitle = $db->getSetting('login_claim_title', '') ?: __('auth_claim_title'); + $claimText = $db->getSetting('login_claim_text', '') ?: __('auth_claim_text'); + + $featureRaw = trim((string)$db->getSetting('login_features', '')); + if ($featureRaw !== '') { + $loginFeatures = array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $featureRaw)))); + } else { + $loginFeatures = [__('auth_feature_1'), __('auth_feature_2'), __('auth_feature_3')]; + } + + $loginFooter = $db->getSetting('login_footer', '') ?: ('© ' . date('Y') . ' ' . $loginBrand); + $loginBgImage = trim((string)$db->getSetting('login_bg_image', '')); + $loginBgFrom = $db->getSetting('login_bg_from', '') ?: '#3b2f8f'; + $loginBgTo = $db->getSetting('login_bg_to', '') ?: '#6d5ce7'; + $loginOverlay = max(0, min(90, (int)$db->getSetting('login_bg_overlay', '40'))); + + $visualStyle = '--login-from:' . htmlspecialchars($loginBgFrom, ENT_QUOTES) + . ';--login-to:' . htmlspecialchars($loginBgTo, ENT_QUOTES) + . ';--login-overlay:' . ($loginOverlay / 100); + if ($loginBgImage !== '') { + $visualStyle .= ";--login-image:url('" . htmlspecialchars(Ui::mediaUrl($loginBgImage), ENT_QUOTES) . "')"; + } + } catch (Exception $e) { die('Datenbankfehler: ' . $e->getMessage()); } @@ -127,57 +159,60 @@ try { <?= __('login_title') ?> – <?= htmlspecialchars($appTitle) ?> - - - + - -
-
- $label): ?> - - + + + +
+
+ + + + + + +
+
+

+

+ +
    + +
  • + +
+ +
+
+
+ + +
+
+
+ $label): ?> + + +
+
- -