Compare commits
25 commits
claude/too
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a09e35097 | |||
| 4159b92268 | |||
| 943e427150 | |||
| ee3b85add4 | |||
| 61810eb050 | |||
| 5d72febadc | |||
| 83f4223d89 | |||
| ad197ecf90 | |||
| 8facc71455 | |||
| b7f13d8fac | |||
| 5f7c503dff | |||
| 068f6c08f9 | |||
| 7fa423a74c | |||
| 7dc60c5bb0 | |||
| 2fe7e9522d | |||
| 0716311ff6 | |||
| 36e06ac817 | |||
| e28527ed91 | |||
| 7f1d93debd | |||
| 850a04d628 | |||
| 30d0ce3a23 | |||
| 6da46f040a | |||
| ba90ffef03 | |||
| 9a4b2e1cd4 | |||
| 498c7e28e0 |
15
.gitattributes
vendored
Normal file
|
|
@ -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
|
||||||
35
.github/workflows/ci.yml
vendored
|
|
@ -29,9 +29,40 @@ jobs:
|
||||||
php -l "$f"
|
php -l "$f"
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Validate JSON language/migration assets
|
- name: Validate language files
|
||||||
run: |
|
run: |
|
||||||
php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";'
|
php -r '
|
||||||
|
$de = require "lang/de.php"; $en = require "lang/en.php";
|
||||||
|
if (!is_array($de) || !is_array($en)) { fwrite(STDERR, "Bad lang file\n"); exit(1); }
|
||||||
|
$missingEn = array_diff(array_keys($de), array_keys($en));
|
||||||
|
$missingDe = array_diff(array_keys($en), array_keys($de));
|
||||||
|
if ($missingEn || $missingDe) {
|
||||||
|
fwrite(STDERR, "Fehlend in en: " . implode(", ", $missingEn) . "\n");
|
||||||
|
fwrite(STDERR, "Fehlend in de: " . implode(", ", $missingDe) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo "lang OK (" . count($de) . " Schluessel)\n";'
|
||||||
|
|
||||||
|
- name: Check that every used translation key exists
|
||||||
|
run: |
|
||||||
|
php -r '
|
||||||
|
$de = require "lang/de.php";
|
||||||
|
$missing = [];
|
||||||
|
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(".", FilesystemIterator::SKIP_DOTS));
|
||||||
|
foreach ($it as $file) {
|
||||||
|
$path = $file->getPathname();
|
||||||
|
if (substr($path, -4) !== ".php") continue;
|
||||||
|
if (strpos($path, "/vendor/") !== false || strpos($path, "/tools/") !== false) continue;
|
||||||
|
preg_match_all("/__\(\s*\x27([a-z0-9_]+)\x27/", file_get_contents($path), $m);
|
||||||
|
foreach ($m[1] as $key) {
|
||||||
|
if (!isset($de[$key]) && substr($key, -1) !== "_") { $missing[$key] = $path; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($missing) {
|
||||||
|
foreach ($missing as $key => $path) { fwrite(STDERR, "Unbekannter Schluessel $key in $path\n"); }
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo "Alle verwendeten Schluessel vorhanden\n";'
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Unit Tests & Static Analysis
|
name: Unit Tests & Static Analysis
|
||||||
|
|
|
||||||
152
.github/workflows/release.yml
vendored
Normal file
|
|
@ -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}"
|
||||||
26
.htaccess
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sicherheits-Header und Zugriffsschutz (Apache)
|
||||||
|
# Nginx-Entsprechung siehe Readme.md, Abschnitt "Sicherheit".
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
Header always set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
|
|
||||||
|
# Alle Frontend-Assets liegen lokal; externe Quellen nur fuer hCaptcha,
|
||||||
|
# falls es in den Einstellungen aktiviert wurde.
|
||||||
|
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline' https://*.hcaptcha.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.hcaptcha.com; frame-src https://*.hcaptcha.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Kein Verzeichnislisting
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
# Dateien, die nie direkt ausgeliefert werden sollen
|
||||||
|
<FilesMatch "^(config\.php|composer\.(json|lock)|phpunit\.xml\.dist|phpstan\.neon|database\.sql)$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Interne Ordner schuetzen sich ueber eigene .htaccess-Dateien
|
||||||
|
# (funktioniert auch bei Installation in einem Unterverzeichnis).
|
||||||
|
|
@ -16,11 +16,14 @@ RUN { \
|
||||||
echo 'post_max_size=8M'; \
|
echo 'post_max_size=8M'; \
|
||||||
} > /usr/local/etc/php/conf.d/zz-voucher.ini
|
} > /usr/local/etc/php/conf.d/zz-voucher.ini
|
||||||
|
|
||||||
|
# .htaccess auswerten (Sicherheits-Header, Schutz des uploads-Ordners)
|
||||||
|
RUN sed -ri 's!<Directory /var/www/>!<Directory /var/www/>\n\tAllowOverride All!g' /etc/apache2/apache2.conf
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
COPY . /var/www/html
|
COPY . /var/www/html
|
||||||
|
|
||||||
# Laufzeit-Verzeichnis des Updaters beschreibbar machen
|
# Laufzeit-Verzeichnisse beschreibbar machen
|
||||||
RUN mkdir -p /var/www/html/updater/storage \
|
RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \
|
||||||
&& chown -R www-data:www-data /var/www/html
|
&& chown -R www-data:www-data /var/www/html
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
|
||||||
346
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.
|
**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)**
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
[](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -25,6 +28,8 @@
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand
|
- 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand
|
||||||
|
- 🖥️ **Display-Seiten (Kiosk)** – öffentliche Seite je Site, an der Gäste sich mit einem Klick selbst einen Zugang holen
|
||||||
|
- 🎨 **Jede Display-Seite eigenständig gestaltbar** – Logo, Hintergrundbild, Akzentfarbe, helle oder dunkle Karte
|
||||||
- 📦 **Bulk-Erstellung** – bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout
|
- 📦 **Bulk-Erstellung** – bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout
|
||||||
- 🧩 **Voucher-Profile/Templates** – vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl
|
- 🧩 **Voucher-Profile/Templates** – vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl
|
||||||
- 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten
|
- 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten
|
||||||
|
|
@ -45,6 +50,13 @@
|
||||||
- 💾 **Config-Backup & -Restore** (JSON Export/Import)
|
- 💾 **Config-Backup & -Restore** (JSON Export/Import)
|
||||||
- 🐳 **Docker** – Dockerfile + docker-compose (MariaDB)
|
- 🐳 **Docker** – Dockerfile + docker-compose (MariaDB)
|
||||||
- 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
|
- 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
|
||||||
|
- 🎨 **Einheitliches Design-System** – ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark)
|
||||||
|
- 🏷️ **Login-Seite individualisierbar** – Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf
|
||||||
|
- 🖌️ **Eigene Markenfarben** – Akzentfarbe, Verlauf und Eckenradius wirken auf die gesamte Oberfläche
|
||||||
|
- ⬆️ **Bild-Upload** für Logo, Favicon und Login-Hintergrund (kein externes Hosting nötig)
|
||||||
|
- 🔒 **Keine externen CDNs** – Schrift, Icons, Diagramme und Editor werden lokal ausgeliefert (DSGVO, Offline-Netze)
|
||||||
|
- ♿ **Barrierearm** – Kontraste nach WCAG AA, Sprungmarke, aria-Beschriftungen, `prefers-reduced-motion`
|
||||||
|
- 📱 **Mobil nutzbar** – Tabellen werden auf schmalen Geräten zu Karten
|
||||||
- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert
|
- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert
|
||||||
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
|
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
|
||||||
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
|
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
|
||||||
|
|
@ -59,31 +71,52 @@
|
||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
|
> Alle Screenshots stammen aus der Oberfläche in Version 2.5.0 (neues Design-System).
|
||||||
|
|
||||||
### Anmeldung & Voucher-Erstellung
|
### Anmeldung & Voucher-Erstellung
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/login.png" alt="Login mit Microsoft 365" width="32%">
|
<img src="docs/screenshots/login.png" alt="Anmeldung" width="48%">
|
||||||
<img src="docs/screenshots/voucher-form.png" alt="Voucher erstellen" width="32%">
|
<img src="docs/screenshots/login-branding.png" alt="Anmeldung mit eigenem Branding" width="48%">
|
||||||
<img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis" width="32%">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Bulk-Erstellung & Dark Mode
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%">
|
<img src="docs/screenshots/voucher-form.png" alt="Voucher erstellen" width="48%">
|
||||||
<img src="docs/screenshots/admin-dashboard-dark.png" alt="Dashboard im Dark Mode" width="48%">
|
<img src="docs/screenshots/settings-login.png" alt="Einstellungen der Login-Seite" width="48%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Administration & Updater
|
### Display-Seite für Gäste
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/kiosk-display.png" alt="Display-Seite im Ruhezustand" width="48%">
|
||||||
|
<img src="docs/screenshots/kiosk-branded.png" alt="Display-Seite mit eigenem Bild und Farben" width="48%">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/kiosk-code.png" alt="Ausgegebener Zugangscode auf dem Display" width="48%">
|
||||||
|
<img src="docs/screenshots/kiosks-form.png" alt="Display-Seite einrichten" width="48%">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis mit QR-Code" width="48%">
|
||||||
|
<img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Administration
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/admin-dashboard.png" alt="Dashboard" width="48%">
|
<img src="docs/screenshots/admin-dashboard.png" alt="Dashboard" width="48%">
|
||||||
<img src="docs/screenshots/updater-available.png" alt="Auto-Updater" width="48%">
|
<img src="docs/screenshots/admin-dashboard-dark.png" alt="Dashboard im Dark Mode" width="48%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/updater.png" alt="Updater – Ausgangszustand" width="48%">
|
<img src="docs/screenshots/vouchers.png" alt="Live-Voucher-Verwaltung" width="48%">
|
||||||
<img src="docs/screenshots/maintenance.png" alt="Wartungsmodus" width="48%">
|
<img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/settings-branding.png" alt="Markenfarben einstellen" width="48%">
|
||||||
|
<img src="docs/screenshots/mobile-vouchers.png" alt="Ansicht auf dem Smartphone" width="22%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### REST-API, 2FA & Integrationen
|
### REST-API, 2FA & Integrationen
|
||||||
|
|
@ -97,6 +130,17 @@
|
||||||
<img src="docs/screenshots/two-factor.png" alt="Zwei-Faktor-Authentifizierung" width="60%">
|
<img src="docs/screenshots/two-factor.png" alt="Zwei-Faktor-Authentifizierung" width="60%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
### Updater & Wartungsmodus
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/updater.png" alt="Updater – Ausgangszustand" width="48%">
|
||||||
|
<img src="docs/screenshots/updater-available.png" alt="Auto-Updater mit verfügbarem Update" width="48%">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/maintenance.png" alt="Wartungsmodus" width="60%">
|
||||||
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Anforderungen
|
## 📋 Anforderungen
|
||||||
|
|
@ -113,8 +157,8 @@
|
||||||
## 🚀 Installation
|
## 🚀 Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/friloo/unifi-voucher-tool.git
|
git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git
|
||||||
cd unifi-voucher-tool
|
cd Unifi-Voucher-Tool
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Dateien auf den Webserver hochladen
|
1. Dateien auf den Webserver hochladen
|
||||||
|
|
@ -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*
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/kiosks-admin.png" alt="Verwaltung der Display-Seiten" width="80%">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
| Einstellung | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| Site | für welchen Standort die Codes erzeugt werden |
|
||||||
|
| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) |
|
||||||
|
| Überschrift / Text | was auf dem Bildschirm steht |
|
||||||
|
| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) |
|
||||||
|
| Wartezeit | Abstand zwischen zwei Codes an diesem Display |
|
||||||
|
| Anzeigedauer | danach springt der Bildschirm automatisch zurück |
|
||||||
|
|
||||||
|
Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich
|
||||||
|
kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und
|
||||||
|
jederzeit erneuern – der alte Link ist dann sofort ungültig. Den Link nicht
|
||||||
|
öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen.
|
||||||
|
|
||||||
|
Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite
|
||||||
|
zeigt. Gäste können sie damit **am eigenen Handy** öffnen – praktisch bei
|
||||||
|
Bildschirmen ohne Touch.
|
||||||
|
|
||||||
|
### Jede Seite eigenständig gestalten
|
||||||
|
|
||||||
|
Jede Display-Seite bringt ihr eigenes Erscheinungsbild mit – das Hotel am
|
||||||
|
Empfang sieht anders aus als der Tagungsraum nebenan:
|
||||||
|
|
||||||
|
| Einstellung | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| Logo | eigenes Logo auf der Karte (leer = Logo aus den Einstellungen) |
|
||||||
|
| Hintergrundbild | formatfüllend hinter der Karte, z. B. ein Foto des Hauses |
|
||||||
|
| Abdunklung | 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
|
## ⚙️ Konfiguration
|
||||||
|
|
||||||
### `config.php`
|
### `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
|
## 🛡️ Sicherheit
|
||||||
|
|
||||||
Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
|
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 |
|
| **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout |
|
||||||
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
|
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
|
||||||
|
|
||||||
Empfohlene zusätzliche Härtung am Server:
|
Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern
|
||||||
|
(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
|
||||||
|
`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal
|
||||||
|
liegen, erlaubt die CSP nur noch die eigene Herkunft – externe Verbindungen
|
||||||
|
bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie
|
||||||
|
`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene
|
||||||
|
`.htaccess`-Dateien.
|
||||||
|
|
||||||
```apache
|
> **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst
|
||||||
# .htaccess – sensible Dateien sperren (wird vom Installer erzeugt)
|
> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image
|
||||||
<FilesMatch "^(config\.php|database\.sql|install\.php|test\.php|m365_debug\.php|.*\.md)$">
|
> erledigt das bereits.
|
||||||
Require all denied
|
|
||||||
</FilesMatch>
|
Für **Nginx** entspricht das:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always;
|
||||||
|
|
||||||
|
location ~ ^/(includes|tools|tests)/ { deny all; }
|
||||||
|
location ~ ^/updater/(storage|migrations)/ { deny all; }
|
||||||
|
location ~ ^/(config\.php|database\.sql)$ { deny all; }
|
||||||
|
location ^~ /uploads/ { location ~ \.php$ { deny all; } }
|
||||||
```
|
```
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
|
@ -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
|
Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV
|
||||||
setzen (`DB_*`, `APP_KEY`).
|
setzen (`DB_*`, `APP_KEY`).
|
||||||
|
|
||||||
|
Hochgeladene Logos und Hintergründe liegen im Volume `uploads` und überstehen
|
||||||
|
damit ein Image-Update. Bei eigener Apache-/Nginx-Installation muss `uploads/`
|
||||||
|
für den Webserver beschreibbar sein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chown -R www-data:www-data uploads updater/storage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Entwicklung
|
||||||
|
|
||||||
|
Für Screenshots und einen schnellen Durchlauf aller Seiten gibt es eine
|
||||||
|
Demo-Instanz **ohne Datenbank** – `Database` und `Auth` werden durch Stubs mit
|
||||||
|
festen Beispieldaten ersetzt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/demo/build.py /tmp/uvt-demo
|
||||||
|
php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
|
||||||
|
|
||||||
|
# Bilder in docs/screenshots neu erzeugen (benötigt headless Chromium)
|
||||||
|
CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Details und die verfügbaren Demo-Zustände: [`tools/README.md`](tools/README.md).
|
||||||
|
|
||||||
|
Tests und statische Analyse:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
vendor/bin/phpunit # 29 Tests (Crypto, TOTP, API-Keys, Upload, Ui)
|
||||||
|
vendor/bin/phpstan analyse # Level 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Versionsnummer steht in der Datei **`VERSION`** im Projektstamm. Sie wird
|
||||||
|
im Admin-Bereich unten in der Seitenleiste angezeigt und benennt das
|
||||||
|
Release-Paket – für eine neue Version also dort (und im Badge oben) anheben.
|
||||||
|
|
||||||
|
Die Pipeline (`.github/workflows/ci.yml`) führt zusätzlich einen
|
||||||
|
Syntax-Check über alle PHP-Dateien aus und prüft, ob `lang/de.php` und
|
||||||
|
`lang/en.php` dieselben Schlüssel enthalten und jeder im Code verwendete
|
||||||
|
Schlüssel existiert. Dieselben Schritte lassen sich lokal ausführen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Repository & Mitwirken
|
||||||
|
|
||||||
|
Der Quellcode liegt auf der eigenen Forgejo-Instanz – **nicht** auf GitHub:
|
||||||
|
|
||||||
|
**<https://git.loheide.cloud/friloo/Unifi-Voucher-Tool>**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# HTTPS
|
||||||
|
git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git
|
||||||
|
|
||||||
|
# SSH (Port 2222)
|
||||||
|
git clone ssh://git@git.loheide.cloud:2222/friloo/Unifi-Voucher-Tool.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fertige Pakete
|
||||||
|
|
||||||
|
Jeder Merge nach `main` erzeugt automatisch ein installierbares ZIP
|
||||||
|
(`.github/workflows/release.yml`) und hängt es an das rollende Vorab-Release
|
||||||
|
**`latest-main`**:
|
||||||
|
|
||||||
|
**<https://git.loheide.cloud/friloo/Unifi-Voucher-Tool/releases>**
|
||||||
|
|
||||||
|
Das Paket enthält nur die Laufzeit-Dateien – `docs/`, `tests/`, `tools/` und die
|
||||||
|
CI-Konfiguration bleiben draußen (rund 1,4 MB). Wird ein Tag `v*` gepusht,
|
||||||
|
entsteht daraus ein reguläres Release mit derselben Mechanik.
|
||||||
|
|
||||||
|
> Beim **Update einer bestehenden Installation** `config.php`, `uploads/` und
|
||||||
|
> `updater/storage/` nicht überschreiben – oder gleich den eingebauten Updater
|
||||||
|
> verwenden, der genau diese Pfade schützt.
|
||||||
|
|
||||||
|
Voraussetzung ist ein registrierter **Forgejo-Actions-Runner**; ohne Runner
|
||||||
|
bleiben die Workflows in der Warteschlange stehen. Compose-Datei und Anleitung
|
||||||
|
dafür liegen in [`tools/runner/`](tools/runner/README.md).
|
||||||
|
|
||||||
|
### Mitwirken
|
||||||
|
|
||||||
|
Fehlerberichte und Änderungsvorschläge laufen über die **Issues** und **Pull
|
||||||
|
Requests** dort. Für die Kommandozeile eignet sich [`tea`](https://gitea.com/gitea/tea),
|
||||||
|
die Gitea-/Forgejo-CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr create # Pull Request öffnen
|
||||||
|
tea issues ls # offene Tickets ansehen
|
||||||
|
```
|
||||||
|
|
||||||
|
> Die Workflows unter `.github/workflows/` werden von Forgejo Actions
|
||||||
|
> mitgelesen; das Badge oben ist bewusst statisch, solange kein Runner
|
||||||
|
> registriert ist. `docker-publish.yml` veröffentlicht nach `ghcr.io` und
|
||||||
|
> stammt noch aus der GitHub-Zeit – für den Forgejo-Betrieb entweder auf die
|
||||||
|
> eigene Registry umstellen oder entfernen.
|
||||||
|
|
||||||
|
Der **Auto-Updater** ist davon unabhängig: er zieht seine Pakete über
|
||||||
|
`update.loheide.eu` (Channels `stable` und `development`) und nicht direkt aus
|
||||||
|
dem Git-Hoster.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
- [x] Voucher-Templates (vordefinierte Laufzeiten)
|
- [x] Voucher-Templates (vordefinierte Laufzeiten)
|
||||||
|
|
@ -378,11 +673,18 @@ setzen (`DB_*`, `APP_KEY`).
|
||||||
- [x] Docker-Container
|
- [x] Docker-Container
|
||||||
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
|
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
|
||||||
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
|
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
|
||||||
|
- [x] Gemeinsames Design-System für Frontend, Login und Backend
|
||||||
|
- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite)
|
||||||
|
- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
|
||||||
|
- [x] Vollständige englische Übersetzung des Admin-Bereichs
|
||||||
|
- [x] Display-Seiten: Selbstbedienung für Gäste am Bildschirm
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**Version 2.4.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
**Version 2.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
||||||
|
|
||||||
|
Entwickelt von **[Loheide.eu](https://loheide.eu)**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
2.8.0
|
||||||
|
|
@ -37,7 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) {
|
||||||
);
|
);
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt");
|
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt");
|
||||||
$newKey = $k['plain'];
|
$newKey = $k['plain'];
|
||||||
$success = 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!';
|
$success = __('api_created_once');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -46,14 +46,14 @@ if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken(
|
||||||
$row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]);
|
$row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]);
|
||||||
if ($row) {
|
if ($row) {
|
||||||
$db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]);
|
$db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]);
|
||||||
$success = 'Status aktualisiert.';
|
$success = __('api_status_updated');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
||||||
$db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]);
|
$db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]);
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht');
|
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht');
|
||||||
$success = 'API-Schlüssel gelöscht.';
|
$success = __('api_deleted');
|
||||||
}
|
}
|
||||||
|
|
||||||
$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC");
|
$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC");
|
||||||
|
|
@ -66,94 +66,80 @@ $adminBase = '';
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>API-Schlüssel – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('api_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
<div class="page-header">
|
||||||
.card { background: var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); }
|
<div>
|
||||||
.card h2 { font-size:16px; margin-bottom:16px; color:var(--text-primary); }
|
<h1 class="page-title"><?= __('api_title') ?></h1>
|
||||||
table { width:100%; border-collapse:collapse; }
|
<p class="page-subtitle"><?= __('api_subtitle') ?></p>
|
||||||
th,td { text-align:left; padding:11px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
|
</div>
|
||||||
th { color:var(--text-muted); font-weight:600; }
|
</div>
|
||||||
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
|
|
||||||
.btn { padding:10px 16px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
|
||||||
.btn-primary { background:var(--accent,#667eea); color:#fff; }
|
|
||||||
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
|
|
||||||
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
|
|
||||||
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
|
||||||
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
|
||||||
.keybox { background:#0f1117; color:#7CFFB2; font-family:monospace; padding:14px; border-radius:8px; word-break:break-all; font-size:15px; margin-top:10px; }
|
|
||||||
.badge { padding:3px 9px; border-radius:6px; font-size:12px; font-weight:600; }
|
|
||||||
.b-on { background:#e3f6ea; color:#2a7; } .b-off { background:#fdeaea; color:#c33; }
|
|
||||||
.a-link { color:var(--accent,#667eea); text-decoration:none; margin-right:10px; font-size:13px; }
|
|
||||||
.muted { color:var(--text-muted); font-size:13px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔑 API-Schlüssel</h1>
|
|
||||||
|
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
<?php if ($newKey): ?>
|
<?php if ($newKey): ?>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Neuer Schlüssel</h2>
|
<h2><?= __('api_new_key') ?></h2>
|
||||||
<p class="muted">Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.</p>
|
<p class="muted"><?= __('api_new_key_hint') ?></p>
|
||||||
<div class="keybox"><?= htmlspecialchars($newKey) ?></div>
|
<div class="keybox"><?= htmlspecialchars($newKey) ?></div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Neuen API-Schlüssel erstellen</h2>
|
<h2><?= __('api_create_title') ?></h2>
|
||||||
<form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
<form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<div style="flex:2;min-width:200px;">
|
<div style="flex:2;min-width:200px;">
|
||||||
<label class="muted" style="display:block;margin-bottom:6px;">Bezeichnung</label>
|
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_name') ?></label>
|
||||||
<input class="input" type="text" name="name" placeholder="z.B. Buchungssystem, Terminal Foyer" required>
|
<input class="input" type="text" name="name" placeholder="<?= __('api_name_placeholder') ?>" required>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:130px;">
|
<div style="flex:1;min-width:130px;">
|
||||||
<label class="muted" style="display:block;margin-bottom:6px;">Berechtigung</label>
|
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_scope') ?></label>
|
||||||
<select class="input" name="scope">
|
<select class="input" name="scope">
|
||||||
<option value="write">Lesen + Erstellen</option>
|
<option value="write"><?= __('api_scope_write') ?></option>
|
||||||
<option value="read">Nur Lesen</option>
|
<option value="read"><?= __('api_scope_read') ?></option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:120px;">
|
<div style="flex:1;min-width:120px;">
|
||||||
<label class="muted" style="display:block;margin-bottom:6px;">Limit (Anfr./min)</label>
|
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_limit') ?></label>
|
||||||
<input class="input" type="number" name="rate_limit" min="0" value="0" title="0 = unbegrenzt">
|
<input class="input" type="number" name="rate_limit" min="0" value="0" title="<?= __('api_limit_title') ?>">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" type="submit" name="create_key">Erstellen</button>
|
<button class="btn btn-primary" type="submit" name="create_key"><?= __('btn_create') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Vorhandene Schlüssel</h2>
|
<h2><?= __('api_existing') ?></h2>
|
||||||
<?php if (empty($keys)): ?>
|
<?php if (empty($keys)): ?>
|
||||||
<p class="muted">Noch keine API-Schlüssel angelegt.</p>
|
<p class="muted"><?= __('api_none') ?></p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<table>
|
<div class="table-container">
|
||||||
<tr><th>Name</th><th>Präfix</th><th>Scope</th><th>Limit</th><th>Status</th><th>Zuletzt genutzt</th><th>Erstellt von</th><th></th></tr>
|
<table class="table-stack">
|
||||||
|
<tr><th><?= __('label_name') ?></th><th><?= __('api_col_prefix') ?></th><th><?= __('api_col_scope') ?></th><th><?= __('api_col_limit') ?></th><th><?= __('label_status') ?></th><th><?= __('api_col_last_used') ?></th><th><?= __('api_col_created_by') ?></th><th></th></tr>
|
||||||
<?php foreach ($keys as $k): ?>
|
<?php foreach ($keys as $k): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= htmlspecialchars($k['name']) ?></td>
|
<td data-label="<?= __('label_name') ?>"><?= htmlspecialchars($k['name']) ?></td>
|
||||||
<td><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
|
<td data-label="<?= __('api_col_prefix') ?>"><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
|
||||||
<td><?= ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?></td>
|
<td data-label="<?= __('api_col_scope') ?>"><?= ($k['scope'] ?? 'write') === 'read' ? __('api_scope_read_short') : __('api_scope_write_short') ?></td>
|
||||||
<td><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
|
<td data-label="<?= __('api_col_limit') ?>"><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
|
||||||
<td><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? 'aktiv' : 'gesperrt' ?></span></td>
|
<td data-label="<?= __('label_status') ?>"><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? __('api_state_active') : __('api_state_blocked') ?></span></td>
|
||||||
<td class="muted"><?= $k['last_used_at'] ? htmlspecialchars($k['last_used_at']) : '–' ?></td>
|
<td class="muted" data-label="<?= __('api_col_last_used') ?>"><?= $k['last_used_at'] ? date('d.m.Y H:i', strtotime($k['last_used_at'])) : '–' ?></td>
|
||||||
<td class="muted"><?= htmlspecialchars($k['creator'] ?? '–') ?></td>
|
<td class="muted" data-label="<?= __('api_col_created_by') ?>"><?= htmlspecialchars($k['creator'] ?? '–') ?></td>
|
||||||
<td style="text-align:right;white-space:nowrap;">
|
<td style="text-align:right;white-space:nowrap;">
|
||||||
<a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? 'Sperren' : 'Aktivieren' ?></a>
|
<a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? __('api_action_block') : __('api_action_unblock') ?></a>
|
||||||
<a class="a-link" style="color:#e25555;" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('Schlüssel löschen?');">Löschen</a>
|
<a class="a-link" style="color:var(--danger);" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('<?= __('api_delete_confirm') ?>');"><?= __('btn_delete') ?></a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Verwendung</h2>
|
<h2><?= __('api_usage') ?></h2>
|
||||||
<p class="muted" style="margin-bottom:10px;">Authentifizierung per Header <code>Authorization: Bearer <key></code> oder <code>X-API-Key: <key></code>.</p>
|
<p class="muted" style="margin-bottom:10px;"><?= __('api_usage_hint') ?> <code>Authorization: Bearer <key></code> oder <code>X-API-Key: <key></code>.</p>
|
||||||
<pre class="keybox" style="color:#cdd3e0;white-space:pre-wrap;"># Voucher erstellen
|
<pre class="keybox" style="color:#cdd3e0;white-space:pre-wrap;"># Voucher erstellen
|
||||||
curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
|
curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
|
||||||
-H "Authorization: Bearer uvt_…" \
|
-H "Authorization: Bearer uvt_…" \
|
||||||
|
|
@ -162,10 +148,10 @@ curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
|
||||||
|
|
||||||
# Sites auflisten
|
# Sites auflisten
|
||||||
curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"</pre>
|
curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"</pre>
|
||||||
<p class="muted" style="margin-top:12px;">OpenAPI-Spezifikation (Import in Postman/Swagger): <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p>
|
<p class="muted" style="margin-top:12px;"><?= __('api_openapi') ?> <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /main-content -->
|
</main>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -45,23 +45,15 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY
|
||||||
$currentPage = 'audit_log';
|
$currentPage = 'audit_log';
|
||||||
$adminBase = '';
|
$adminBase = '';
|
||||||
|
|
||||||
$actionLabels = [
|
// Aktionsnamen uebersetzt anzeigen; unbekannte Aktionen bleiben technisch.
|
||||||
'voucher_created' => '🎫 Voucher erstellt',
|
$actionLabels = [];
|
||||||
'voucher_bulk' => '🎫 Bulk Voucher',
|
foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created',
|
||||||
'user_login' => '🔐 Login',
|
'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted',
|
||||||
'user_logout' => '🚪 Logout',
|
'settings_saved', 'password_reset', 'template_created', 'template_updated',
|
||||||
'user_created' => '👤 Benutzer erstellt',
|
'template_deleted', 'voucher_kiosk', 'kiosk_created', 'kiosk_updated',
|
||||||
'user_updated' => '👤 Benutzer geändert',
|
'kiosk_deleted'] as $action) {
|
||||||
'user_deleted' => '👤 Benutzer gelöscht',
|
$actionLabels[$action] = __('audit_action_' . $action);
|
||||||
'site_added' => '🌐 Site hinzugefügt',
|
}
|
||||||
'site_updated' => '🌐 Site geändert',
|
|
||||||
'site_deleted' => '🌐 Site gelöscht',
|
|
||||||
'settings_saved' => '⚙️ Einstellungen gespeichert',
|
|
||||||
'password_reset' => '🔑 Passwort-Reset',
|
|
||||||
'template_created' => '📋 Profil erstellt',
|
|
||||||
'template_updated' => '📋 Profil geändert',
|
|
||||||
'template_deleted' => '📋 Profil gelöscht',
|
|
||||||
];
|
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="<?= I18n::getLanguage() ?>">
|
<html lang="<?= I18n::getLanguage() ?>">
|
||||||
|
|
@ -70,47 +62,17 @@ $actionLabels = [
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
|
||||||
.page-header { margin-bottom: 25px; }
|
|
||||||
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
|
|
||||||
.card { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 20px; }
|
|
||||||
.card-header { padding: 18px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
|
||||||
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
|
||||||
.table { width: 100%; border-collapse: collapse; }
|
|
||||||
.table th { text-align: left; padding: 11px 15px; background: var(--bg-table-head); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
||||||
.table td { padding: 12px 15px; border-bottom: 1px solid var(--border-color); font-size: 13px; color: var(--text-primary); }
|
|
||||||
.table tr:last-child td { border-bottom: none; }
|
|
||||||
.table tr:hover td { background: var(--bg-hover); }
|
|
||||||
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; }
|
|
||||||
.filter-bar { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
|
|
||||||
.filter-bar select { padding: 9px 12px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 13px; background: var(--bg-input); color: var(--text-primary); }
|
|
||||||
.filter-bar select:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.btn-primary { background: var(--accent); color: white; }
|
|
||||||
.btn-primary:hover { background: var(--accent-hover); }
|
|
||||||
.btn-small { padding: 6px 12px; font-size: 12px; }
|
|
||||||
.ip-cell { font-family: monospace; font-size: 12px; color: var(--text-muted); }
|
|
||||||
.details-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); }
|
|
||||||
.pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 25px; border-top: 1px solid var(--border-color); flex-wrap: wrap; gap: 10px; }
|
|
||||||
.page-info { font-size: 13px; color: var(--text-muted); }
|
|
||||||
.page-btns { display: flex; gap: 5px; flex-wrap: wrap; }
|
|
||||||
.page-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-decoration: none; transition: all 0.2s; }
|
|
||||||
.page-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
||||||
.page-btn.active { background: var(--accent); color: white; border-color: var(--accent); }
|
|
||||||
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
|
||||||
.empty-state i { font-size: 42px; margin-bottom: 15px; display: block; opacity: 0.3; }
|
|
||||||
.action-chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; background: var(--bg-hover); color: var(--text-secondary); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title"><?= __('audit_title') ?></h1>
|
<div>
|
||||||
<p style="color: var(--text-muted); font-size: 14px;"><?= __('audit_subtitle') ?></p>
|
<h1 class="page-title"><?= __('audit_title') ?></h1>
|
||||||
|
<p class="page-subtitle"><?= __('audit_subtitle') ?></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter -->
|
<!-- Filter -->
|
||||||
<div class="card" style="margin-bottom: 20px;">
|
<div class="card" style="margin-bottom: 20px;">
|
||||||
<div class="card-header"><span class="card-title"><i class="fas fa-filter"></i> <?= __('audit_filter') ?></span></div>
|
<div class="card-header"><span class="card-title"><i class="fas fa-filter" aria-hidden="true"></i> <?= __('audit_filter') ?></span></div>
|
||||||
<div style="padding: 20px 25px;">
|
<div style="padding: 20px 25px;">
|
||||||
<form method="get" class="filter-bar">
|
<form method="get" class="filter-bar">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -127,7 +89,7 @@ $actionLabels = [
|
||||||
<div>
|
<div>
|
||||||
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:5px;"><?= __('audit_user') ?></label>
|
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:5px;"><?= __('audit_user') ?></label>
|
||||||
<select name="user_id">
|
<select name="user_id">
|
||||||
<option value="">Alle Benutzer</option>
|
<option value=""><?= __('audit_all_users') ?></option>
|
||||||
<?php foreach ($users as $u): ?>
|
<?php foreach ($users as $u): ?>
|
||||||
<option value="<?= $u['id'] ?>" <?= (string)$filterUser === (string)$u['id'] ? 'selected' : '' ?>>
|
<option value="<?= $u['id'] ?>" <?= (string)$filterUser === (string)$u['id'] ? 'selected' : '' ?>>
|
||||||
<?= htmlspecialchars($u['name']) ?>
|
<?= htmlspecialchars($u['name']) ?>
|
||||||
|
|
@ -135,8 +97,8 @@ $actionLabels = [
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search"></i> Filtern</button>
|
<button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search" aria-hidden="true"></i> Filtern</button>
|
||||||
<a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times"></i> Zurücksetzen</a>
|
<a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times" aria-hidden="true"></i> Zurücksetzen</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -144,14 +106,15 @@ $actionLabels = [
|
||||||
<!-- Log-Tabelle -->
|
<!-- Log-Tabelle -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title"><i class="fas fa-history"></i> <?= __('audit_title') ?></span>
|
<span class="card-title"><i class="fas fa-history" aria-hidden="true"></i> <?= __('audit_title') ?></span>
|
||||||
<span style="font-size:13px;color:var(--text-muted);"><?= number_format($total) ?> Einträge</span>
|
<span style="font-size:13px;color:var(--text-muted);"><?= number_format($total) ?> Einträge</span>
|
||||||
</div>
|
</div>
|
||||||
<?php if (empty($logs)): ?>
|
<?php if (empty($logs)): ?>
|
||||||
<div class="empty-state"><i class="fas fa-history"></i><p><?= __('audit_none') ?></p></div>
|
<div class="empty-state"><i class="fas fa-history" aria-hidden="true"></i><p><?= __('audit_none') ?></p></div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="table">
|
<div class="table-container">
|
||||||
|
<table class="table table-stack">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?= __('audit_time') ?></th>
|
<th><?= __('audit_time') ?></th>
|
||||||
|
|
@ -165,54 +128,55 @@ $actionLabels = [
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($logs as $log): ?>
|
<?php foreach ($logs as $log): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="white-space:nowrap;color:var(--text-muted);">
|
<td data-label="<?= __('audit_time') ?>" style="white-space:nowrap;color:var(--text-muted);">
|
||||||
<?= date('d.m.Y', strtotime($log['created_at'])) ?><br>
|
<?= date('d.m.Y', strtotime($log['created_at'])) ?><br>
|
||||||
<small><?= date('H:i:s', strtotime($log['created_at'])) ?></small>
|
<small><?= date('H:i:s', strtotime($log['created_at'])) ?></small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="<?= __('audit_action') ?>">
|
||||||
<span class="action-chip">
|
<span class="action-chip">
|
||||||
<?= htmlspecialchars($actionLabels[$log['action']] ?? $log['action']) ?>
|
<?= htmlspecialchars($actionLabels[$log['action']] ?? $log['action']) ?>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="<?= __('audit_user') ?>">
|
||||||
<?php if ($log['user_name']): ?>
|
<?php if ($log['user_name']): ?>
|
||||||
<strong style="font-size:13px;"><?= htmlspecialchars($log['user_name']) ?></strong><br>
|
<strong style="font-size:13px;"><?= htmlspecialchars($log['user_name']) ?></strong><br>
|
||||||
<small style="color:var(--text-muted);"><?= htmlspecialchars($log['user_email'] ?? '') ?></small>
|
<small style="color:var(--text-muted);"><?= htmlspecialchars($log['user_email'] ?? '') ?></small>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<em style="color:var(--text-muted);">System/Anonym</em>
|
<em style="color:var(--text-muted);"><?= __('audit_system_anon') ?></em>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td style="color:var(--text-secondary);">
|
<td data-label="<?= __('audit_entity') ?>" style="color:var(--text-secondary);">
|
||||||
<?php if ($log['entity_type']): ?>
|
<?php if ($log['entity_type']): ?>
|
||||||
<code style="font-size:11px;"><?= htmlspecialchars($log['entity_type']) ?>:<?= htmlspecialchars($log['entity_id'] ?? '') ?></code>
|
<code style="font-size:11px;"><?= htmlspecialchars($log['entity_type']) ?>:<?= htmlspecialchars($log['entity_id'] ?? '') ?></code>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span style="color:var(--text-muted);">-</span>
|
<span style="color:var(--text-muted);">-</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="details-cell" title="<?= htmlspecialchars($log['details'] ?? '') ?>">
|
<td class="details-cell" data-label="<?= __('audit_details') ?>" title="<?= htmlspecialchars($log['details'] ?? '') ?>">
|
||||||
<?= htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, '…')) ?>
|
<?= htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, '…')) ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="ip-cell"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
<td class="ip-cell" data-label="<?= __('audit_ip') ?>"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($pages > 1): ?>
|
<?php if ($pages > 1): ?>
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<span class="page-info">Seite <?= $page ?> von <?= $pages ?> (<?= $total ?> Einträge)</span>
|
<span class="page-info"><?= str_replace(['{page}', '{pages}', '{total}'], [(string)$page, (string)$pages, number_format((int)$total, 0, ',', '.')], __('audit_page_info')) ?></span>
|
||||||
<div class="page-btns">
|
<div class="page-btns">
|
||||||
<?php
|
<?php
|
||||||
$baseUrl = '?' . http_build_query(array_filter(['action' => $filterAction, 'user_id' => $filterUser]));
|
$baseUrl = '?' . http_build_query(array_filter(['action' => $filterAction, 'user_id' => $filterUser]));
|
||||||
if ($page > 1): ?>
|
if ($page > 1): ?>
|
||||||
<a href="<?= $baseUrl ?>&page=<?= $page - 1 ?>" class="page-btn"><i class="fas fa-chevron-left"></i></a>
|
<a href="<?= $baseUrl ?>&page=<?= $page - 1 ?>" class="page-btn"><i class="fas fa-chevron-left" aria-hidden="true"></i></a>
|
||||||
<?php endif;
|
<?php endif;
|
||||||
for ($p = max(1, $page - 2); $p <= min($pages, $page + 2); $p++): ?>
|
for ($p = max(1, $page - 2); $p <= min($pages, $page + 2); $p++): ?>
|
||||||
<a href="<?= $baseUrl ?>&page=<?= $p ?>" class="page-btn <?= $p === $page ? 'active' : '' ?>"><?= $p ?></a>
|
<a href="<?= $baseUrl ?>&page=<?= $p ?>" class="page-btn <?= $p === $page ? 'active' : '' ?>"><?= $p ?></a>
|
||||||
<?php endfor;
|
<?php endfor;
|
||||||
if ($page < $pages): ?>
|
if ($page < $pages): ?>
|
||||||
<a href="<?= $baseUrl ?>&page=<?= $page + 1 ?>" class="page-btn"><i class="fas fa-chevron-right"></i></a>
|
<a href="<?= $baseUrl ?>&page=<?= $page + 1 ?>" class="page-btn"><i class="fas fa-chevron-right" aria-hidden="true"></i></a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -221,7 +185,7 @@ $actionLabels = [
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- main-content -->
|
</main>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$error = __('error_csrf');
|
$error = __('error_csrf');
|
||||||
} elseif (empty($_FILES['backup']['tmp_name'])) {
|
} elseif (empty($_FILES['backup']['tmp_name'])) {
|
||||||
$error = 'Bitte eine Backup-Datei auswählen.';
|
$error = __('backup_choose_file');
|
||||||
} else {
|
} else {
|
||||||
$raw = file_get_contents($_FILES['backup']['tmp_name']);
|
$raw = file_get_contents($_FILES['backup']['tmp_name']);
|
||||||
$data = json_decode($raw, true);
|
$data = json_decode($raw, true);
|
||||||
if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') {
|
if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') {
|
||||||
$error = 'Ungültige oder fremde Backup-Datei.';
|
$error = __('backup_invalid_file');
|
||||||
} else {
|
} else {
|
||||||
$importSites = isset($_POST['import_sites']);
|
$importSites = isset($_POST['import_sites']);
|
||||||
$importTemplates = isset($_POST['import_templates']);
|
$importTemplates = isset($_POST['import_templates']);
|
||||||
|
|
@ -95,7 +95,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert');
|
$auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert');
|
||||||
$success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile.";
|
$success = str_replace(['{settings}', '{sites}', '{templates}'],
|
||||||
|
[(string)$counts['settings'], (string)$counts['sites'], (string)$counts['templates']],
|
||||||
|
__('backup_imported'));
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = 'Import-Fehler: ' . $e->getMessage();
|
$error = 'Import-Fehler: ' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
@ -112,48 +114,38 @@ $adminBase = '';
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Backup & Restore – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('backup_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
<div class="page-header">
|
||||||
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
|
<div>
|
||||||
.card h2 { font-size:16px; margin-bottom:12px; color:var(--text-primary); }
|
<h1 class="page-title"><?= __('backup_title') ?></h1>
|
||||||
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
|
<p class="page-subtitle"><?= __('backup_subtitle') ?></p>
|
||||||
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
</div>
|
||||||
.btn-primary { background:var(--accent,#667eea); color:#fff; }
|
</div>
|
||||||
.btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
|
||||||
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
|
|
||||||
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
|
||||||
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
|
||||||
label.chk { display:flex; align-items:center; gap:9px; margin:8px 0; color:var(--text-primary); font-size:14px; }
|
|
||||||
input[type=file] { margin:10px 0; color:var(--text-primary); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">💾 Backup & Restore</h1>
|
|
||||||
|
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Export</h2>
|
<h2><?= __('backup_export') ?></h2>
|
||||||
<p class="muted">Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem <code>APP_KEY</code> dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.</p>
|
<p class="muted"><?= __('backup_export_hint') ?> <code>APP_KEY</code> <?= __('backup_export_hint2') ?></p>
|
||||||
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>">Konfiguration exportieren</a>
|
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>"><?= __('backup_export_btn') ?></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Import / Restore</h2>
|
<h2><?= __('backup_import') ?></h2>
|
||||||
<p class="muted">Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.</p>
|
<p class="muted"><?= __('backup_import_hint') ?></p>
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<input type="file" name="backup" accept="application/json,.json" required><br>
|
<input type="file" name="backup" accept="application/json,.json" required><br>
|
||||||
<label class="chk"><input type="checkbox" name="import_settings" checked> Einstellungen</label>
|
<label class="chk"><input type="checkbox" name="import_settings" checked> <?= __('backup_opt_settings') ?></label>
|
||||||
<label class="chk"><input type="checkbox" name="import_sites" checked> Sites</label>
|
<label class="chk"><input type="checkbox" name="import_sites" checked> <?= __('backup_opt_sites') ?></label>
|
||||||
<label class="chk"><input type="checkbox" name="import_templates" checked> Voucher-Profile</label>
|
<label class="chk"><input type="checkbox" name="import_templates" checked> <?= __('backup_opt_templates') ?></label>
|
||||||
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('Import jetzt durchführen?');">Importieren</button>
|
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('<?= __('backup_import_confirm') ?>');"><?= __('import_submit') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /main-content -->
|
</main>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['do_import'])) {
|
||||||
Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null);
|
Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null);
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert");
|
$auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert");
|
||||||
}
|
}
|
||||||
$success = "$created Voucher erstellt.";
|
$success = str_replace('{count}', (string)$created, __('import_created'));
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = $e->getMessage();
|
$error = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
@ -93,48 +93,38 @@ $adminBase = '';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>CSV-Import – <?= htmlspecialchars($appTitle) ?></title>
|
<title>CSV-Import – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
<div class="page-header">
|
||||||
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:20px; box-shadow:0 4px 14px var(--shadow); max-width:760px; }
|
<div>
|
||||||
.card h2 { font-size:15px; margin-bottom:12px; color:var(--text-primary); }
|
<h1 class="page-title"><?= __('import_title') ?></h1>
|
||||||
.muted { color:var(--text-muted); font-size:13px; margin-bottom:12px; }
|
<p class="page-subtitle"><?= __('import_subtitle') ?></p>
|
||||||
label { display:block; font-size:13px; color:var(--text-secondary); margin:12px 0 6px; }
|
</div>
|
||||||
.input,textarea,select { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; font-family:inherit; }
|
</div>
|
||||||
textarea { min-height:140px; font-family:monospace; }
|
|
||||||
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; font-size:14px; cursor:pointer; background:var(--accent,#667eea); color:#fff; }
|
|
||||||
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:760px; }
|
|
||||||
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
|
||||||
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:8px; font-size:13px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
|
|
||||||
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 style="font-size:24px;margin-bottom:18px;color:var(--text-primary);">📥 Voucher-Import (CSV)</h1>
|
|
||||||
|
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Mehrere Voucher erstellen</h2>
|
<h2><?= __('import_card_title') ?></h2>
|
||||||
<p class="muted">Eine Zeile pro Voucher: <code>Name,MaxGeräte,Minuten</code> – MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:<br>
|
<p class="muted"><?= __('import_format_hint') ?> <code>Name,MaxGeräte,Minuten</code> <?= __('import_format_hint2') ?><br>
|
||||||
<code>Gast Müller,1,480</code> · <code>Konferenzraum A,5,240</code> · <code>Tagespass</code></p>
|
<code>Gast Müller,1,480</code> · <code>Konferenzraum A,5,240</code> · <code>Tagespass</code></p>
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<label>Standort</label>
|
<label><?= __('import_site') ?></label>
|
||||||
<select class="input" name="site_id" required>
|
<select class="input" name="site_id" required>
|
||||||
<?php foreach ($sites as $s): ?><option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?>
|
<?php foreach ($sites as $s): ?><option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<label>CSV-Datei (optional)</label>
|
<label><?= __('import_file') ?></label>
|
||||||
<input class="input" type="file" name="csv" accept=".csv,text/csv">
|
<input class="input" type="file" name="csv" accept=".csv,text/csv">
|
||||||
<label>… oder direkt einfügen</label>
|
<label><?= __('import_paste') ?></label>
|
||||||
<textarea name="csv_text" placeholder="Gast Müller,1,480 Konferenzraum A,5,240"></textarea>
|
<textarea name="csv_text" placeholder="Gast Müller,1,480 Konferenzraum A,5,240"></textarea>
|
||||||
<button class="btn" type="submit" name="do_import" style="margin-top:14px;" onclick="return confirm('Import jetzt starten?');">Importieren</button>
|
<button class="btn" type="submit" name="do_import" style="margin-top:14px;" onclick="return confirm('<?= __('import_confirm') ?>');"><?= __('import_submit') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!empty($results)): ?>
|
<?php if (!empty($results)): ?>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Ergebnis</h2>
|
<h2><?= __('import_result') ?></h2>
|
||||||
<table><tr><th>Name</th><th>Code / Fehler</th><th>Status</th></tr>
|
<table><tr><th><?= __('label_name') ?></th><th><?= __('import_col_code') ?></th><th><?= __('label_status') ?></th></tr>
|
||||||
<?php foreach ($results as $r): ?>
|
<?php foreach ($results as $r): ?>
|
||||||
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><code><?= htmlspecialchars($r['code']) ?></code></td><td><?= $r['ok'] ? '✅' : '❌' ?></td></tr>
|
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><code><?= htmlspecialchars($r['code']) ?></code></td><td><?= $r['ok'] ? '✅' : '❌' ?></td></tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
@ -142,7 +132,7 @@ code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; borde
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</div><!-- /main-content -->
|
</main>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
171
admin/index.php
|
|
@ -6,6 +6,7 @@ ini_set('log_errors', 1);
|
||||||
require_once __DIR__ . '/../config.php';
|
require_once __DIR__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/Ui.php';
|
||||||
require_once __DIR__ . '/../includes/UniFiController.php';
|
require_once __DIR__ . '/../includes/UniFiController.php';
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
|
||||||
|
|
@ -90,76 +91,8 @@ $currentPage = 'dashboard';
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('dashboard_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('dashboard_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
|
||||||
.page-header { margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
|
|
||||||
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
|
|
||||||
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
|
|
||||||
.live-badge { display: inline-flex; align-items: center; gap: 8px; background: #d4edda; color: #155724; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 500; }
|
|
||||||
.live-badge .dot { width: 8px; height: 8px; background: #28a745; border-radius: 50%; animation: pulse 2s infinite; }
|
|
||||||
.live-badge.loading { background: #fff3cd; color: #856404; }
|
|
||||||
.live-badge.loading .dot { background: #ffc107; }
|
|
||||||
.live-badge.error { background: #f8d7da; color: #721c24; }
|
|
||||||
.live-badge.error .dot { background: var(--danger); animation: none; }
|
|
||||||
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.5} }
|
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; margin-bottom: 30px; }
|
|
||||||
.stat-card { background: var(--bg-card); padding: 22px; border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); }
|
|
||||||
.stat-card.live { border-color: var(--success); border-width: 2px; }
|
|
||||||
.stat-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
|
||||||
.stat-card-title { color: var(--text-muted); font-size: 13px; font-weight: 500; }
|
|
||||||
.stat-card-icon { width: 38px; height: 38px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 17px; }
|
|
||||||
.stat-card-value { font-size: 30px; font-weight: 700; color: var(--text-primary); }
|
|
||||||
.stat-card-value.valid { color: var(--success); }
|
|
||||||
.stat-card-value.used { color: var(--warning); }
|
|
||||||
.stat-card-value.expired { color: var(--danger); }
|
|
||||||
.stat-card-sub { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
|
||||||
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); margin-bottom: 28px; overflow: hidden; }
|
|
||||||
.card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.card-title { font-size: 17px; font-weight: 600; color: var(--text-primary); }
|
|
||||||
.card-body { padding: 22px; }
|
|
||||||
.table { width: 100%; border-collapse: collapse; }
|
|
||||||
.table th { text-align: left; padding: 11px 14px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
|
|
||||||
.table td { padding: 13px 14px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
|
|
||||||
.table tr:last-child td { border-bottom: none; }
|
|
||||||
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; }
|
|
||||||
.badge-success { background: #d4edda; color: #155724; }
|
|
||||||
.badge-warning { background: #fff3cd; color: #856404; }
|
|
||||||
.badge-danger { background: #f8d7da; color: #721c24; }
|
|
||||||
.badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
|
|
||||||
.btn-primary { background: var(--accent); color: white; }
|
|
||||||
.btn-primary:hover { background: var(--accent-hover); }
|
|
||||||
.btn-success { background: var(--success); color: white; }
|
|
||||||
.btn-success:hover { opacity: .9; }
|
|
||||||
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
|
||||||
.chart-container { position: relative; height: 280px; }
|
|
||||||
.site-status { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 14px; }
|
|
||||||
.site-status-card { background: var(--bg-card); border: 2px solid var(--border-color); border-radius: 12px; padding: 18px; transition: border-color .2s; }
|
|
||||||
.site-status-card:hover { border-color: var(--accent); }
|
|
||||||
.site-status-card.error { border-color: var(--danger); }
|
|
||||||
.site-status-name { font-weight: 600; color: var(--text-primary); font-size: 15px; }
|
|
||||||
.site-voucher-count { background: var(--accent); color: white; padding: 3px 11px; border-radius: 20px; font-size: 13px; font-weight: 600; }
|
|
||||||
.site-voucher-count.zero { background: var(--text-muted); }
|
|
||||||
.site-status-stats { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 12px; }
|
|
||||||
.site-stat { text-align: center; padding: 9px; background: var(--bg-hover); border-radius: 8px; }
|
|
||||||
.site-stat-value { font-size: 18px; font-weight: 700; }
|
|
||||||
.site-stat-value.valid { color: var(--success); }
|
|
||||||
.site-stat-value.used { color: var(--warning); }
|
|
||||||
.site-stat-value.expired { color: var(--danger); }
|
|
||||||
.site-stat-label { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
|
||||||
.site-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
|
||||||
.top-users-list { list-style: none; }
|
|
||||||
.top-users-list li { display: flex; justify-content: space-between; align-items: center; padding: 11px 0; border-bottom: 1px solid var(--border-color); }
|
|
||||||
.top-users-list li:last-child { border-bottom: none; }
|
|
||||||
.user-info-row { display: flex; align-items: center; gap: 10px; }
|
|
||||||
.user-avatar-sm { width: 30px; height: 30px; border-radius: 50%; background: linear-gradient(135deg,#667eea,#764ba2); display: flex; align-items: center; justify-content: center; color: white; font-weight: 700; font-size: 13px; }
|
|
||||||
.user-count { background: var(--bg-hover); padding: 3px 10px; border-radius: 12px; font-weight: 600; color: var(--text-primary); font-size: 13px; }
|
|
||||||
.refresh-indicator { display: flex; align-items: center; gap: 10px; }
|
|
||||||
.last-update { color: var(--text-muted); font-size: 12px; }
|
|
||||||
.empty-state { text-align: center; padding: 50px 20px; color: var(--text-muted); }
|
|
||||||
.empty-state i { font-size: 40px; margin-bottom: 15px; opacity: .3; display: block; }
|
|
||||||
@media(max-width:768px){ .main-content{ margin-left:0!important; } .stats-grid{ grid-template-columns:1fr 1fr; } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -171,8 +104,8 @@ $currentPage = 'dashboard';
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span id="liveStatus">DB</span>
|
<span id="liveStatus">DB</span>
|
||||||
</span>
|
</span>
|
||||||
<button onclick="refreshData('live')" class="btn btn-success btn-sm" id="refreshBtn">
|
<button onclick="refreshData('live')" class="btn btn-secondary btn-sm" id="refreshBtn">
|
||||||
<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>
|
<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,43 +114,43 @@ $currentPage = 'dashboard';
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title"><?= __('dashboard_active_sites') ?></div>
|
<div class="stat-card-title"><?= __('dashboard_active_sites') ?></div>
|
||||||
<div class="stat-card-icon" style="background:#e3f2fd;color:#1976d2;"><i class="fas fa-map-marker-alt"></i></div>
|
<div class="stat-card-icon info"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value"><?= $stats['total_sites'] ?></div>
|
<div class="stat-card-value"><?= $stats['total_sites'] ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title"><?= __('dashboard_users') ?></div>
|
<div class="stat-card-title"><?= __('dashboard_users') ?></div>
|
||||||
<div class="stat-card-icon" style="background:#f3e5f5;color:#7b1fa2;"><i class="fas fa-users"></i></div>
|
<div class="stat-card-icon accent"><i class="fas fa-users" aria-hidden="true"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value"><?= $stats['total_users'] ?></div>
|
<div class="stat-card-value"><?= $stats['total_users'] ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card live">
|
<div class="stat-card live">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title">🟢 <?= __('dashboard_valid') ?></div>
|
<div class="stat-card-title"><?= __('dashboard_valid') ?></div>
|
||||||
<div class="stat-card-icon" style="background:#e8f5e9;color:#388e3c;"><i class="fas fa-check-circle"></i></div>
|
<div class="stat-card-icon success"><i class="fas fa-circle-check" aria-hidden="true"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value valid" id="liveValid"><?= (int)($voucherStats['valid']??0) ?></div>
|
<div class="stat-card-value valid" id="liveValid"><?= (int)($voucherStats['valid']??0) ?></div>
|
||||||
<div class="stat-card-sub" id="subValid"><?= $lastCronSync ? date('H:i', strtotime($lastCronSync)) : __('never') ?></div>
|
<div class="stat-card-sub" id="subValid"><?= $lastCronSync ? date('H:i', strtotime($lastCronSync)) : __('never') ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card live">
|
<div class="stat-card live">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title">🟡 <?= __('dashboard_used') ?></div>
|
<div class="stat-card-title"><?= __('dashboard_used') ?></div>
|
||||||
<div class="stat-card-icon" style="background:#fff3e0;color:#f57c00;"><i class="fas fa-user-check"></i></div>
|
<div class="stat-card-icon warning"><i class="fas fa-user-check" aria-hidden="true"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value used" id="liveUsed"><?= (int)($voucherStats['used']??0) ?></div>
|
<div class="stat-card-value used" id="liveUsed"><?= (int)($voucherStats['used']??0) ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card live">
|
<div class="stat-card live">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title">🔴 <?= __('dashboard_expired') ?></div>
|
<div class="stat-card-title"><?= __('dashboard_expired') ?></div>
|
||||||
<div class="stat-card-icon" style="background:#ffebee;color:#d32f2f;"><i class="fas fa-times-circle"></i></div>
|
<div class="stat-card-icon danger"><i class="fas fa-circle-xmark" aria-hidden="true"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value expired" id="liveExpired"><?= (int)($voucherStats['expired']??0) ?></div>
|
<div class="stat-card-value expired" id="liveExpired"><?= (int)($voucherStats['expired']??0) ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card live">
|
<div class="stat-card live">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title">📊 <?= __('dashboard_total') ?></div>
|
<div class="stat-card-title"><?= __('dashboard_total') ?></div>
|
||||||
<div class="stat-card-icon" style="background:var(--bg-hover);color:var(--text-muted);"><i class="fas fa-ticket-alt"></i></div>
|
<div class="stat-card-icon"><i class="fas fa-ticket" aria-hidden="true"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value" id="liveTotal"><?= (int)($voucherStats['total']??0) ?></div>
|
<div class="stat-card-value" id="liveTotal"><?= (int)($voucherStats['total']??0) ?></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,16 +158,16 @@ $currentPage = 'dashboard';
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">🔴 <?= __('dashboard_vouchers_per_site') ?></h2>
|
<h2 class="card-title"><?= __('dashboard_vouchers_per_site') ?></h2>
|
||||||
<span class="last-update" id="lastUpdate"><?= $lastCronSync ? date('d.m.Y H:i', strtotime($lastCronSync)) : __('never') ?></span>
|
<span class="last-update" id="lastUpdate"><?= $lastCronSync ? date('d.m.Y H:i', strtotime($lastCronSync)) : __('never') ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" style="padding:18px;">
|
<div class="card-body">
|
||||||
<div class="site-status" id="siteStatusContainer">
|
<div class="site-status" id="siteStatusContainer">
|
||||||
<?php foreach ($sites as $site):
|
<?php foreach ($sites as $site):
|
||||||
$ss = $siteStats[$site['id']] ?? ['total'=>0,'valid'=>0,'used'=>0,'expired'=>0];
|
$ss = $siteStats[$site['id']] ?? ['total'=>0,'valid'=>0,'used'=>0,'expired'=>0];
|
||||||
?>
|
?>
|
||||||
<div class="site-status-card" id="site-<?= $site['id'] ?>">
|
<div class="site-status-card" id="site-<?= $site['id'] ?>">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;">
|
||||||
<div class="site-status-name"><?= htmlspecialchars($site['name']) ?></div>
|
<div class="site-status-name"><?= htmlspecialchars($site['name']) ?></div>
|
||||||
<span class="site-voucher-count <?= (int)$ss['total']===0 ? 'zero' : '' ?>" id="site-total-<?= $site['id'] ?>">
|
<span class="site-voucher-count <?= (int)$ss['total']===0 ? 'zero' : '' ?>" id="site-total-<?= $site['id'] ?>">
|
||||||
<?= (int)$ss['total'] ?>
|
<?= (int)$ss['total'] ?>
|
||||||
|
|
@ -249,10 +182,10 @@ $currentPage = 'dashboard';
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($sites)): ?>
|
<?php if (empty($sites)): ?>
|
||||||
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted);">
|
<div class="empty-state" style="grid-column:1/-1;">
|
||||||
<i class="fas fa-map-marker-alt" style="font-size:32px;margin-bottom:15px;opacity:.3;display:block;"></i>
|
<div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
|
||||||
<p><?= __('dashboard_no_data') ?></p>
|
<p><?= __('dashboard_no_data') ?></p>
|
||||||
<a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus"></i> <?= __('sites_add') ?></a>
|
<a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?></a>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -260,18 +193,18 @@ $currentPage = 'dashboard';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h2 class="card-title">📊 <?= __('dashboard_trend') ?></h2></div>
|
<div class="card-header"><h2 class="card-title"><?= __('dashboard_trend') ?></h2></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="chart-container"><canvas id="voucherChart"></canvas></div>
|
<div class="chart-container"><canvas id="voucherChart"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;" class="two-col-grid">
|
<div class="two-col-grid">
|
||||||
<div class="card" style="margin-bottom:0;">
|
<div class="card">
|
||||||
<div class="card-header"><h2 class="card-title">🏆 <?= __('dashboard_top_users') ?></h2></div>
|
<div class="card-header"><h2 class="card-title"><?= __('dashboard_top_users') ?></h2></div>
|
||||||
<div class="card-body" style="padding:18px 22px;">
|
<div class="card-body">
|
||||||
<?php if (empty($topUsers)): ?>
|
<?php if (empty($topUsers)): ?>
|
||||||
<div class="empty-state"><i class="fas fa-users"></i><p><?= __('dashboard_no_data') ?></p></div>
|
<div class="empty-state"><i class="fas fa-users" aria-hidden="true"></i><p><?= __('dashboard_no_data') ?></p></div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<ul class="top-users-list">
|
<ul class="top-users-list">
|
||||||
<?php foreach ($topUsers as $u): ?>
|
<?php foreach ($topUsers as $u): ?>
|
||||||
|
|
@ -291,21 +224,22 @@ $currentPage = 'dashboard';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
<div class="card">
|
||||||
<div class="card-header"><h2 class="card-title">📋 <?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-external-link-alt"></i> Live</a></div>
|
<div class="card-header"><h2 class="card-title"><?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> Live</a></div>
|
||||||
<div class="card-body" style="padding:0;">
|
<div class="card-body" style="padding:0;">
|
||||||
<?php if (empty($recentVouchers)): ?>
|
<?php if (empty($recentVouchers)): ?>
|
||||||
<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('dashboard_no_vouchers') ?></p></div>
|
<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= __('dashboard_no_vouchers') ?></p></div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<table class="table">
|
<div class="table-container">
|
||||||
|
<table class="table table-stack">
|
||||||
<thead><tr><th><?= __('label_created') ?></th><th><?= __('label_code') ?></th><th><?= __('label_site') ?></th><th><?= __('label_status') ?></th></tr></thead>
|
<thead><tr><th><?= __('label_created') ?></th><th><?= __('label_code') ?></th><th><?= __('label_site') ?></th><th><?= __('label_status') ?></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($recentVouchers as $v): ?>
|
<?php foreach ($recentVouchers as $v): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
|
<td data-label="<?= __('label_created') ?>" style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
|
||||||
<td><code style="font-size:12px;background:var(--bg-hover);padding:3px 6px;border-radius:4px;"><?= htmlspecialchars($v['voucher_code']) ?></code></td>
|
<td data-label="<?= __('label_code') ?>"><code><?= htmlspecialchars($v['voucher_code']) ?></code></td>
|
||||||
<td><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
|
<td data-label="<?= __('label_site') ?>"><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
|
||||||
<td>
|
<td data-label="<?= __('label_status') ?>">
|
||||||
<?php $st=$v['status']??'valid'; ?>
|
<?php $st=$v['status']??'valid'; ?>
|
||||||
<span class="badge badge-<?= $st==='valid'?'success':($st==='used'?'warning':'danger') ?>"><?= __('status_'.$st) ?></span>
|
<span class="badge badge-<?= $st==='valid'?'success':($st==='used'?'warning':'danger') ?>"><?= __('status_'.$st) ?></span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -313,17 +247,26 @@ $currentPage = 'dashboard';
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /main-content -->
|
</main>
|
||||||
|
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
const accentColor = rootStyles.getPropertyValue('--accent').trim();
|
||||||
|
const surfaceColor = rootStyles.getPropertyValue('--bg-card').trim();
|
||||||
|
const gridColor = rootStyles.getPropertyValue('--border-color').trim();
|
||||||
|
const mutedColor = rootStyles.getPropertyValue('--text-muted').trim();
|
||||||
const ctx = document.getElementById('voucherChart').getContext('2d');
|
const ctx = document.getElementById('voucherChart').getContext('2d');
|
||||||
|
const accentFill = ctx.createLinearGradient(0, 0, 0, 280);
|
||||||
|
accentFill.addColorStop(0, accentColor + '33');
|
||||||
|
accentFill.addColorStop(1, accentColor + '00');
|
||||||
const chartData = <?= json_encode($chartData) ?>;
|
const chartData = <?= json_encode($chartData) ?>;
|
||||||
new Chart(ctx, {
|
new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
|
|
@ -332,17 +275,20 @@ new Chart(ctx, {
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Vouchers',
|
label: 'Vouchers',
|
||||||
data: chartData.map(d => d.count),
|
data: chartData.map(d => d.count),
|
||||||
borderColor: '#667eea',
|
borderColor: accentColor,
|
||||||
backgroundColor: 'rgba(102,126,234,0.1)',
|
backgroundColor: accentFill,
|
||||||
tension: 0.4, fill: true,
|
tension: 0.4, fill: true,
|
||||||
pointBackgroundColor: '#667eea', pointBorderColor: '#fff',
|
pointBackgroundColor: accentColor, pointBorderColor: surfaceColor,
|
||||||
pointBorderWidth: 2, pointRadius: 5, pointHoverRadius: 7
|
pointBorderWidth: 2, pointRadius: 4, pointHoverRadius: 6
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true, maintainAspectRatio: false,
|
responsive: true, maintainAspectRatio: false,
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1, color: getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim() } }, x: { ticks: { color: getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim() } } }
|
scales: {
|
||||||
|
y: { beginAtZero: true, border: { display: false }, grid: { color: gridColor, drawTicks: false }, ticks: { stepSize: 1, color: mutedColor, padding: 10, font: { size: 11 } } },
|
||||||
|
x: { border: { display: false }, grid: { display: false }, ticks: { color: mutedColor, padding: 8, font: { size: 11 } } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -356,7 +302,7 @@ async function refreshData(mode='db') {
|
||||||
liveBadge.className = 'live-badge loading';
|
liveBadge.className = 'live-badge loading';
|
||||||
liveStatus.textContent = '...';
|
liveStatus.textContent = '...';
|
||||||
refreshBtn.disabled = true;
|
refreshBtn.disabled = true;
|
||||||
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>';
|
||||||
try {
|
try {
|
||||||
const url = `index.php?ajax_stats=1${mode==='live'?'&sync=1':''}`;
|
const url = `index.php?ajax_stats=1${mode==='live'?'&sync=1':''}`;
|
||||||
const result = await fetch(url).then(r=>r.json());
|
const result = await fetch(url).then(r=>r.json());
|
||||||
|
|
@ -394,13 +340,8 @@ async function refreshData(mode='db') {
|
||||||
}
|
}
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
refreshBtn.disabled = false;
|
refreshBtn.disabled = false;
|
||||||
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>';
|
refreshBtn.innerHTML = '<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive two-column grid
|
|
||||||
const twoCol = document.querySelector('.two-col-grid');
|
|
||||||
function checkGrid() { if (twoCol) twoCol.style.gridTemplateColumns = window.innerWidth < 900 ? '1fr' : '1fr 1fr'; }
|
|
||||||
checkGrid(); window.addEventListener('resize', checkGrid);
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -49,13 +49,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
|
||||||
$db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0)));
|
$db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0)));
|
||||||
$db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30)));
|
$db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30)));
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert');
|
$auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert');
|
||||||
$success = 'Einstellungen gespeichert.';
|
$success = __('settings_saved');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
||||||
Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']);
|
Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']);
|
||||||
$success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).';
|
$success = __('int_webhook_test_sent');
|
||||||
}
|
}
|
||||||
|
|
||||||
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
|
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
|
||||||
|
|
@ -92,24 +92,14 @@ $adminBase = '';
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Integration & Wartung – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('int_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
<div class="page-header">
|
||||||
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
|
<div>
|
||||||
.card h2 { font-size:16px; margin-bottom:6px; color:var(--text-primary); }
|
<h1 class="page-title"><?= __('int_title') ?></h1>
|
||||||
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
|
<p class="page-subtitle"><?= __('int_subtitle') ?></p>
|
||||||
label { display:block; font-size:14px; color:var(--text-secondary); margin:14px 0 6px; }
|
</div>
|
||||||
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
|
</div>
|
||||||
.row { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
|
||||||
.chk { display:flex; align-items:center; gap:9px; margin-top:14px; color:var(--text-primary); font-size:14px; }
|
|
||||||
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
|
||||||
.btn-primary { background:var(--accent,#667eea); color:#fff; } .btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
|
||||||
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
|
|
||||||
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔧 Integration & Wartung</h1>
|
|
||||||
|
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
@ -118,64 +108,64 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Sicherheitsrichtlinie</h2>
|
<h2><?= __('int_security') ?></h2>
|
||||||
<p class="muted">Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.</p>
|
<p class="muted"><?= __('int_security_hint') ?></p>
|
||||||
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label>
|
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> <?= __('int_enforce_2fa') ?></label>
|
||||||
<label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</label>
|
<label><?= __('int_daily_limit') ?></label>
|
||||||
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
|
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
|
||||||
<label>Session-Speicher</label>
|
<label><?= __('int_session_driver') ?></label>
|
||||||
<select class="input" name="session_driver" style="max-width:240px;">
|
<select class="input" name="session_driver" style="max-width:340px;">
|
||||||
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>>PHP-Standard (Dateien)</option>
|
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>><?= __('int_session_php') ?></option>
|
||||||
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>>Datenbank (ermöglicht „überall abmelden")</option>
|
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>><?= __('int_session_db') ?></option>
|
||||||
</select>
|
</select>
|
||||||
<label>Captcha im öffentlichen Modus</label>
|
<label><?= __('int_captcha') ?></label>
|
||||||
<select class="input" name="captcha_mode" style="max-width:240px;">
|
<select class="input" name="captcha_mode" style="max-width:340px;">
|
||||||
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option>
|
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>><?= __('int_captcha_off') ?></option>
|
||||||
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>>Rechenaufgabe (ohne externen Dienst)</option>
|
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>><?= __('int_captcha_math') ?></option>
|
||||||
<option value="hcaptcha" <?= $captchaMode==='hcaptcha'?'selected':'' ?>>hCaptcha</option>
|
<option value="hcaptcha" <?= $captchaMode==='hcaptcha'?'selected':'' ?>>hCaptcha</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="row3" style="margin-top:10px;">
|
<div class="row3" style="margin-top:10px;">
|
||||||
<div><label>hCaptcha Site-Key</label><input class="input" type="text" name="captcha_site_key" value="<?= htmlspecialchars($captchaSiteKey) ?>"></div>
|
<div><label>hCaptcha Site-Key</label><input class="input" type="text" name="captcha_site_key" value="<?= htmlspecialchars($captchaSiteKey) ?>"></div>
|
||||||
<div><label>hCaptcha Secret<?= $captchaSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
<div><label>hCaptcha Secret<?= $captchaSecretSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? __('int_secret_placeholder') : '' ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Reverse-Proxy</h2>
|
<h2><?= __('int_proxy') ?></h2>
|
||||||
<p class="muted">IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus <code>X-Forwarded-For</code> für Rate-Limit & Audit verwendet.</p>
|
<p class="muted"><?= __('int_proxy_hint') ?> <code>X-Forwarded-For</code> <?= __('int_proxy_hint2') ?></p>
|
||||||
<input class="input" type="text" name="trusted_proxy" value="<?= htmlspecialchars($trustedProxy) ?>" placeholder="z.B. 10.0.0.1, 172.18.0.1">
|
<input class="input" type="text" name="trusted_proxy" value="<?= htmlspecialchars($trustedProxy) ?>" placeholder="z.B. 10.0.0.1, 172.18.0.1">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Webhook-Benachrichtigungen</h2>
|
<h2><?= __('int_webhook') ?></h2>
|
||||||
<p class="muted">Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.</p>
|
<p class="muted"><?= __('int_webhook_hint') ?></p>
|
||||||
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> Webhook aktiv</label>
|
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> <?= __('int_webhook_active') ?></label>
|
||||||
<label>Webhook-URL</label>
|
<label><?= __('int_webhook_url') ?></label>
|
||||||
<input class="input" type="url" name="webhook_url" value="<?= htmlspecialchars($webhookUrl) ?>" placeholder="https://hooks.slack.com/services/…">
|
<input class="input" type="url" name="webhook_url" value="<?= htmlspecialchars($webhookUrl) ?>" placeholder="https://hooks.slack.com/services/…">
|
||||||
<div style="margin-top:12px;">
|
<div style="margin-top:12px;">
|
||||||
<a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>">Test senden</a>
|
<a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>"><?= __('int_webhook_test') ?></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>SMS-Versand (Twilio)</h2>
|
<h2><?= __('int_sms') ?></h2>
|
||||||
<p class="muted">Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.</p>
|
<p class="muted"><?= __('int_sms_hint') ?></p>
|
||||||
<label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> SMS-Versand aktiv</label>
|
<label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> <?= __('int_sms_active') ?></label>
|
||||||
<div class="row3" style="margin-top:10px;">
|
<div class="row3" style="margin-top:10px;">
|
||||||
<div><label>Account SID</label><input class="input" type="text" name="twilio_sid" value="<?= htmlspecialchars($twilioSid) ?>"></div>
|
<div><label>Account SID</label><input class="input" type="text" name="twilio_sid" value="<?= htmlspecialchars($twilioSid) ?>"></div>
|
||||||
<div><label>Auth Token<?= $twilioTokenSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
<div><label>Auth Token<?= $twilioTokenSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? __('int_secret_placeholder') : '' ?>"></div>
|
||||||
<div><label>Absender (From)</label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div>
|
<div><label><?= __('int_sms_from') ?></label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Single Sign-On (OpenID Connect)</h2>
|
<h2><?= __('int_sso') ?></h2>
|
||||||
<p class="muted">Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI: <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p>
|
<p class="muted"><?= __('int_sso_hint') ?> <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p>
|
||||||
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> OIDC-Login aktiv</label>
|
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> <?= __('int_sso_active') ?></label>
|
||||||
<div class="row3" style="margin-top:10px;">
|
<div class="row3" style="margin-top:10px;">
|
||||||
<div><label>Button-Text</label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div>
|
<div><label><?= __('int_sso_button') ?></label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div>
|
||||||
<div><label>Client ID</label><input class="input" type="text" name="oidc_client_id" value="<?= htmlspecialchars($oidcClientId) ?>"></div>
|
<div><label>Client ID</label><input class="input" type="text" name="oidc_client_id" value="<?= htmlspecialchars($oidcClientId) ?>"></div>
|
||||||
<div><label>Client Secret<?= $oidcSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
<div><label>Client Secret<?= $oidcSecretSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? __('int_secret_placeholder') : '' ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
<label>Authorization Endpoint</label><input class="input" type="url" name="oidc_auth_url" value="<?= htmlspecialchars($oidcAuthUrl) ?>" placeholder="https://idp/authorize">
|
<label>Authorization Endpoint</label><input class="input" type="url" name="oidc_auth_url" value="<?= htmlspecialchars($oidcAuthUrl) ?>" placeholder="https://idp/authorize">
|
||||||
<label>Token Endpoint</label><input class="input" type="url" name="oidc_token_url" value="<?= htmlspecialchars($oidcTokenUrl) ?>" placeholder="https://idp/token">
|
<label>Token Endpoint</label><input class="input" type="url" name="oidc_token_url" value="<?= htmlspecialchars($oidcTokenUrl) ?>" placeholder="https://idp/token">
|
||||||
|
|
@ -184,21 +174,21 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Datenhaltung & Cleanup (DSGVO)</h2>
|
<h2><?= __('int_cleanup') ?></h2>
|
||||||
<p class="muted">Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per <code>cron_cleanup.php</code> (täglich empfohlen).
|
<p class="muted"><?= __('int_cleanup_hint') ?> <code>cron_cleanup.php</code> <?= __('int_cleanup_hint2') ?>
|
||||||
<?php if ($lastCleanup): ?><br>Letzter Lauf: <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
|
<?php if ($lastCleanup): ?><br><?= __('int_cleanup_last') ?> <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div><label>Abgelaufene Voucher</label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
|
<div><label><?= __('int_cleanup_expired') ?></label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
|
||||||
<div><label>Audit-Log</label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
|
<div><label><?= __('int_cleanup_audit') ?></label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
|
||||||
<div><label>Login-Versuche</label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div>
|
<div><label><?= __('int_cleanup_logins') ?></label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary" type="submit" name="save">Speichern</button>
|
<button class="btn btn-primary" type="submit" name="save"><?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div><!-- /main-content -->
|
</main>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
500
admin/kiosks.php
Normal file
|
|
@ -0,0 +1,500 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Verwaltung der öffentlichen Display-Seiten ("Kiosk").
|
||||||
|
*
|
||||||
|
* Jeder Kiosk gehört zu einer Site, hat einen geheimen Link und gibt über
|
||||||
|
* kiosk.php Zugangscodes aus – ohne Anmeldung, aber mit Tageslimit und
|
||||||
|
* Wartezeit zwischen zwei Codes.
|
||||||
|
*/
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
require_once __DIR__ . '/../includes/Ui.php';
|
||||||
|
require_once __DIR__ . '/../includes/Kiosk.php';
|
||||||
|
require_once __DIR__ . '/../includes/Upload.php';
|
||||||
|
|
||||||
|
$auth = new Auth();
|
||||||
|
$auth->requireAdmin();
|
||||||
|
I18n::init();
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
$success = '';
|
||||||
|
|
||||||
|
/** Formularwerte einsammeln – für Anlegen und Bearbeiten identisch. */
|
||||||
|
function kioskInput(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'site_id' => (int)($_POST['site_id'] ?? 0),
|
||||||
|
'template_id' => (int)($_POST['template_id'] ?? 0) ?: null,
|
||||||
|
'name' => trim((string)($_POST['name'] ?? '')),
|
||||||
|
'headline' => trim((string)($_POST['headline'] ?? '')),
|
||||||
|
'subline' => trim((string)($_POST['subline'] ?? '')),
|
||||||
|
'daily_limit' => max(0, (int)($_POST['daily_limit'] ?? Kiosk::DEFAULT_DAILY_LIMIT)),
|
||||||
|
'cooldown_seconds' => max(0, min(3600, (int)($_POST['cooldown_seconds'] ?? Kiosk::DEFAULT_COOLDOWN))),
|
||||||
|
'display_seconds' => max(10, min(600, (int)($_POST['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS))),
|
||||||
|
'is_active' => isset($_POST['is_active']) ? 1 : 0,
|
||||||
|
'bg_overlay' => max(0, min(90, (int)($_POST['bg_overlay'] ?? 45))),
|
||||||
|
'accent_color' => self_accent($_POST['accent_color'] ?? ''),
|
||||||
|
'card_style' => ($_POST['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur echte Hex-Farben durchlassen – der Wert landet in einem style-Attribut. */
|
||||||
|
function self_accent($value): ?string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim((string)$value));
|
||||||
|
|
||||||
|
return preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_kiosk'])) {
|
||||||
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
|
$error = __('error_csrf');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$in = kioskInput();
|
||||||
|
if ($in['name'] === '') throw new Exception(__('error_name_req'));
|
||||||
|
if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
|
||||||
|
|
||||||
|
$logo = Upload::resolveField('logo_url', '', 'image');
|
||||||
|
$bg = Upload::resolveField('background_url', '', 'image');
|
||||||
|
|
||||||
|
$db->execute(
|
||||||
|
"INSERT INTO kiosks (site_id, template_id, name, token, headline, subline,
|
||||||
|
logo_url, background_url, bg_overlay, accent_color, card_style,
|
||||||
|
daily_limit, cooldown_seconds, display_seconds, is_active, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
|
||||||
|
[$in['site_id'], $in['template_id'], $in['name'], Kiosk::newToken(),
|
||||||
|
$in['headline'], $in['subline'], $logo, $bg, $in['bg_overlay'],
|
||||||
|
$in['accent_color'], $in['card_style'], $in['daily_limit'],
|
||||||
|
$in['cooldown_seconds'], $in['display_seconds'], $_SESSION['user_id']]
|
||||||
|
);
|
||||||
|
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_created', 'kiosk', null, $in['name']);
|
||||||
|
$success = __('kiosks_added');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_kiosk'])) {
|
||||||
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
|
$error = __('error_csrf');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$id = (int)($_POST['kiosk_id'] ?? 0);
|
||||||
|
$in = kioskInput();
|
||||||
|
if ($in['name'] === '') throw new Exception(__('error_name_req'));
|
||||||
|
if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
|
||||||
|
|
||||||
|
$current = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [$id]) ?: [];
|
||||||
|
$logo = Upload::resolveField('logo_url', (string)($current['logo_url'] ?? ''), 'image');
|
||||||
|
$bg = Upload::resolveField('background_url', (string)($current['background_url'] ?? ''), 'image');
|
||||||
|
|
||||||
|
$db->execute(
|
||||||
|
"UPDATE kiosks SET site_id=?, template_id=?, name=?, headline=?, subline=?,
|
||||||
|
logo_url=?, background_url=?, bg_overlay=?, accent_color=?, card_style=?,
|
||||||
|
daily_limit=?, cooldown_seconds=?, display_seconds=?, is_active=?
|
||||||
|
WHERE id=?",
|
||||||
|
[$in['site_id'], $in['template_id'], $in['name'], $in['headline'], $in['subline'],
|
||||||
|
$logo, $bg, $in['bg_overlay'], $in['accent_color'], $in['card_style'],
|
||||||
|
$in['daily_limit'], $in['cooldown_seconds'], $in['display_seconds'], $in['is_active'], $id]
|
||||||
|
);
|
||||||
|
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, $in['name']);
|
||||||
|
$success = __('kiosks_updated');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuen Link erzeugen – der alte gilt damit sofort nicht mehr.
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['renew_token'])) {
|
||||||
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
|
$error = __('error_csrf');
|
||||||
|
} else {
|
||||||
|
$id = (int)($_POST['kiosk_id'] ?? 0);
|
||||||
|
$db->execute("UPDATE kiosks SET token = ? WHERE id = ?", [Kiosk::newToken(), $id]);
|
||||||
|
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, 'Link erneuert');
|
||||||
|
$success = __('kiosks_token_renewed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_GET['delete'], $_GET['token'])) {
|
||||||
|
if ($auth->validateCsrfToken($_GET['token'])) {
|
||||||
|
$old = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
|
||||||
|
if ($old) {
|
||||||
|
Upload::delete((string)($old['logo_url'] ?? ''));
|
||||||
|
Upload::delete((string)($old['background_url'] ?? ''));
|
||||||
|
}
|
||||||
|
$db->execute("DELETE FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
|
||||||
|
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_deleted', 'kiosk', (int)$_GET['delete'], '');
|
||||||
|
$success = __('kiosks_deleted');
|
||||||
|
} else {
|
||||||
|
$error = __('error_csrf');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sites = $db->fetchAll("SELECT id, name FROM sites WHERE is_active = 1 ORDER BY name");
|
||||||
|
$templates = $db->fetchAll("SELECT id, name, max_uses, expire_minutes FROM voucher_templates WHERE is_active = 1 ORDER BY name");
|
||||||
|
$kiosks = $db->fetchAll(
|
||||||
|
"SELECT k.*, s.name AS site_name, t.name AS template_name,
|
||||||
|
(SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id) AS total_vouchers,
|
||||||
|
(SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id AND DATE(v.created_at) = CURDATE()) AS today_vouchers
|
||||||
|
FROM kiosks k
|
||||||
|
INNER JOIN sites s ON s.id = k.site_id
|
||||||
|
LEFT JOIN voucher_templates t ON t.id = k.template_id
|
||||||
|
ORDER BY k.is_active DESC, k.name"
|
||||||
|
);
|
||||||
|
|
||||||
|
$csrf = $auth->getCsrfToken();
|
||||||
|
$currentPage = 'kiosks';
|
||||||
|
$adminBase = '';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<?= I18n::getLanguage() ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= __('kiosks_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
|
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
|
||||||
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title"><?= __('kiosks_title') ?></h1>
|
||||||
|
<p class="page-subtitle"><?= __('kiosks_subtitle') ?></p>
|
||||||
|
</div>
|
||||||
|
<button onclick="openAddModal()" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('kiosks_add') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (empty($sites)): ?>
|
||||||
|
<div class="empty-card">
|
||||||
|
<div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
|
||||||
|
<p><?= __('kiosks_no_sites') ?></p>
|
||||||
|
<a href="sites.php" class="btn btn-primary" style="margin-top:16px;">
|
||||||
|
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php elseif (empty($kiosks)): ?>
|
||||||
|
<div class="empty-card">
|
||||||
|
<div class="empty-icon"><i class="fas fa-display" aria-hidden="true"></i></div>
|
||||||
|
<p><?= __('kiosks_empty') ?></p>
|
||||||
|
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top:16px;">
|
||||||
|
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('kiosks_add') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="sites-grid">
|
||||||
|
<?php foreach ($kiosks as $k): ?>
|
||||||
|
<?php $url = Kiosk::publicUrl($k['token']); ?>
|
||||||
|
<div class="site-card">
|
||||||
|
<div class="site-card-header">
|
||||||
|
<div>
|
||||||
|
<div class="site-name"><?= htmlspecialchars($k['name']) ?></div>
|
||||||
|
<div class="site-id-label"><?= htmlspecialchars($k['site_name']) ?></div>
|
||||||
|
</div>
|
||||||
|
<span class="badge <?= $k['is_active'] ? 'badge-success' : 'badge-neutral' ?>">
|
||||||
|
<?= $k['is_active'] ? __('status_active') : __('status_inactive') ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="site-info">
|
||||||
|
<div class="site-info-item">
|
||||||
|
<i class="fas fa-layer-group" aria-hidden="true"></i>
|
||||||
|
<?= $k['template_name'] ? htmlspecialchars($k['template_name']) : __('kiosks_no_template') ?>
|
||||||
|
</div>
|
||||||
|
<div class="site-info-item">
|
||||||
|
<i class="fas fa-gauge-high" aria-hidden="true"></i>
|
||||||
|
<?= (int)$k['today_vouchers'] ?><?= (int)$k['daily_limit'] > 0 ? ' / ' . (int)$k['daily_limit'] : '' ?>
|
||||||
|
<?= __('kiosks_today') ?>
|
||||||
|
</div>
|
||||||
|
<div class="site-info-item">
|
||||||
|
<i class="fas fa-ticket" aria-hidden="true"></i>
|
||||||
|
<?= (int)$k['total_vouchers'] ?> <?= __('kiosks_total') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('kiosks_link') ?></label>
|
||||||
|
<div class="kiosk-link-row">
|
||||||
|
<input type="text" class="input" readonly value="<?= htmlspecialchars($url) ?>"
|
||||||
|
id="link-<?= (int)$k['id'] ?>" onclick="this.select()">
|
||||||
|
<button class="btn btn-secondary" type="button"
|
||||||
|
onclick="copyToClipboard('<?= htmlspecialchars($url, ENT_QUOTES) ?>', '<?= __('js_copied') ?>')"
|
||||||
|
title="<?= __('js_copy') ?>" aria-label="<?= __('js_copy') ?>">
|
||||||
|
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="site-actions">
|
||||||
|
<a class="btn btn-secondary btn-sm" href="<?= htmlspecialchars($url) ?>" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> <?= __('kiosks_open') ?>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
|
onclick="showQr('<?= htmlspecialchars($url, ENT_QUOTES) ?>', '<?= htmlspecialchars($k['name'], ENT_QUOTES) ?>')">
|
||||||
|
<i class="fas fa-qrcode" aria-hidden="true"></i> <?= __('kiosks_qr') ?>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
|
onclick='openEditModal(<?= json_encode([
|
||||||
|
"id" => (int)$k["id"], "site_id" => (int)$k["site_id"],
|
||||||
|
"template_id" => (int)$k["template_id"], "name" => $k["name"],
|
||||||
|
"headline" => $k["headline"], "subline" => $k["subline"],
|
||||||
|
"logo_url" => $k["logo_url"], "background_url" => $k["background_url"],
|
||||||
|
"bg_overlay" => (int)$k["bg_overlay"], "accent_color" => $k["accent_color"],
|
||||||
|
"card_style" => $k["card_style"],
|
||||||
|
"daily_limit" => (int)$k["daily_limit"],
|
||||||
|
"cooldown_seconds" => (int)$k["cooldown_seconds"],
|
||||||
|
"display_seconds" => (int)$k["display_seconds"],
|
||||||
|
"is_active" => (int)$k["is_active"],
|
||||||
|
], JSON_HEX_APOS | JSON_HEX_QUOT) ?>)'>
|
||||||
|
<i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?>
|
||||||
|
</button>
|
||||||
|
<form method="post" style="display:inline;"
|
||||||
|
onsubmit="return confirm('<?= __('kiosks_renew_confirm') ?>');">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
<input type="hidden" name="kiosk_id" value="<?= (int)$k['id'] ?>">
|
||||||
|
<button class="btn btn-secondary btn-sm" type="submit" name="renew_token">
|
||||||
|
<i class="fas fa-rotate" aria-hidden="true"></i> <?= __('kiosks_renew') ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a class="btn btn-danger-soft btn-sm"
|
||||||
|
href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"
|
||||||
|
onclick="return confirm('<?= __('kiosks_delete_confirm') ?>');"
|
||||||
|
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
|
||||||
|
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Anlegen / Bearbeiten -->
|
||||||
|
<div class="modal" id="kioskModal">
|
||||||
|
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="kioskModalTitle">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title" id="kioskModalTitle"><?= __('kiosks_add') ?></h2>
|
||||||
|
<button class="modal-close" type="button" onclick="closeModal()" aria-label="<?= __('btn_cancel') ?>">×</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
<input type="hidden" name="kiosk_id" id="kiosk_id" value="">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name"><?= __('kiosks_name') ?></label>
|
||||||
|
<input type="text" id="name" name="name" required placeholder="<?= __('kiosks_name_placeholder') ?>">
|
||||||
|
<div class="help-text"><?= __('kiosks_name_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="site_id"><?= __('label_site') ?></label>
|
||||||
|
<select id="site_id" name="site_id" required>
|
||||||
|
<?php foreach ($sites as $s): ?>
|
||||||
|
<option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_id"><?= __('kiosks_template') ?></label>
|
||||||
|
<select id="template_id" name="template_id">
|
||||||
|
<option value="0"><?= __('kiosks_no_template') ?></option>
|
||||||
|
<?php foreach ($templates as $t): ?>
|
||||||
|
<option value="<?= (int)$t['id'] ?>">
|
||||||
|
<?= htmlspecialchars($t['name']) ?> – <?= (int)$t['max_uses'] ?> <?= __('label_devices') ?>,
|
||||||
|
<?= (int)$t['expire_minutes'] ?> <?= __('minutes_short') ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<div class="help-text"><?= __('kiosks_template_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="headline"><?= __('kiosks_headline') ?></label>
|
||||||
|
<input type="text" id="headline" name="headline" placeholder="<?= htmlspecialchars(__('kiosk_default_headline')) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subline"><?= __('kiosks_subline') ?></label>
|
||||||
|
<textarea id="subline" name="subline" rows="2" placeholder="<?= htmlspecialchars(__('kiosk_default_subline')) ?>"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider">
|
||||||
|
|
||||||
|
<h3 style="font-size:14px;margin-bottom:14px;"><?= __('kiosks_appearance') ?></h3>
|
||||||
|
|
||||||
|
<?= Ui::imageField('logo_url', __('kiosks_logo'), '', __('kiosks_logo_hint')) ?>
|
||||||
|
<?= Ui::imageField('background_url', __('kiosks_background'), '', __('kiosks_background_hint')) ?>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="bg_overlay"><?= __('kiosks_overlay') ?></label>
|
||||||
|
<input type="number" id="bg_overlay" name="bg_overlay" min="0" max="90" value="45">
|
||||||
|
<div class="help-text"><?= __('kiosks_overlay_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="accent_color"><?= __('kiosks_accent') ?></label>
|
||||||
|
<div class="color-field">
|
||||||
|
<input type="color" class="color-swatch" data-target="accent_color" value="<?= Ui::DEFAULT_ACCENT ?>">
|
||||||
|
<input type="text" id="accent_color" name="accent_color" placeholder="<?= __('kiosks_accent_default') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="help-text"><?= __('kiosks_accent_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="card_style"><?= __('kiosks_card_style') ?></label>
|
||||||
|
<select id="card_style" name="card_style">
|
||||||
|
<option value="light"><?= __('kiosks_card_light') ?></option>
|
||||||
|
<option value="dark"><?= __('kiosks_card_dark') ?></option>
|
||||||
|
</select>
|
||||||
|
<div class="help-text"><?= __('kiosks_card_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider">
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="daily_limit"><?= __('kiosks_daily_limit') ?></label>
|
||||||
|
<input type="number" id="daily_limit" name="daily_limit" min="0" value="<?= Kiosk::DEFAULT_DAILY_LIMIT ?>">
|
||||||
|
<div class="help-text"><?= __('kiosks_daily_limit_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cooldown_seconds"><?= __('kiosks_cooldown') ?></label>
|
||||||
|
<input type="number" id="cooldown_seconds" name="cooldown_seconds" min="0" max="3600" value="<?= Kiosk::DEFAULT_COOLDOWN ?>">
|
||||||
|
<div class="help-text"><?= __('kiosks_cooldown_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="display_seconds"><?= __('kiosks_display') ?></label>
|
||||||
|
<input type="number" id="display_seconds" name="display_seconds" min="10" max="600" value="<?= Kiosk::DEFAULT_DISPLAY_SECONDS ?>">
|
||||||
|
<div class="help-text"><?= __('kiosks_display_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-group" id="activeRow" style="display:none;">
|
||||||
|
<input type="checkbox" id="is_active" name="is_active" checked>
|
||||||
|
<label for="is_active"><?= __('kiosks_active') ?></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal()"><?= __('btn_cancel') ?></button>
|
||||||
|
<button type="submit" name="add_kiosk" id="submitAdd" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="edit_kiosk" id="submitEdit" class="btn btn-primary" style="display:none;">
|
||||||
|
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR-Code des Links -->
|
||||||
|
<div class="modal" id="qrModal">
|
||||||
|
<div class="modal-content" style="max-width:420px;" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title" id="qrTitle"><?= __('kiosks_qr') ?></h2>
|
||||||
|
<button class="modal-close" type="button" onclick="closeQr()" aria-label="<?= __('btn_cancel') ?>">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="text-align:center;">
|
||||||
|
<div id="qrTarget" style="display:inline-block;padding:14px;background:#fff;border-radius:12px;line-height:0;"></div>
|
||||||
|
<p class="help-text" style="margin-top:14px;"><?= __('kiosks_qr_hint') ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||||
|
<script src="../assets/global.js"></script>
|
||||||
|
<script>
|
||||||
|
function openAddModal() {
|
||||||
|
document.getElementById('kioskModalTitle').textContent = <?= json_encode(__('kiosks_add')) ?>;
|
||||||
|
document.querySelector('#kioskModal form').reset();
|
||||||
|
document.getElementById('kiosk_id').value = '';
|
||||||
|
setImageField('logo_url', '');
|
||||||
|
setImageField('background_url', '');
|
||||||
|
document.getElementById('submitAdd').style.display = '';
|
||||||
|
document.getElementById('submitEdit').style.display = 'none';
|
||||||
|
document.getElementById('activeRow').style.display = 'none';
|
||||||
|
document.getElementById('kioskModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(data) {
|
||||||
|
document.getElementById('kioskModalTitle').textContent = <?= json_encode(__('kiosks_edit')) ?>;
|
||||||
|
document.getElementById('kiosk_id').value = data.id;
|
||||||
|
document.getElementById('name').value = data.name || '';
|
||||||
|
document.getElementById('site_id').value = data.site_id;
|
||||||
|
document.getElementById('template_id').value = data.template_id || 0;
|
||||||
|
document.getElementById('headline').value = data.headline || '';
|
||||||
|
document.getElementById('subline').value = data.subline || '';
|
||||||
|
document.getElementById('bg_overlay').value = data.bg_overlay;
|
||||||
|
document.getElementById('accent_color').value = data.accent_color || '';
|
||||||
|
document.getElementById('card_style').value = data.card_style || 'light';
|
||||||
|
setImageField('logo_url', data.logo_url || '');
|
||||||
|
setImageField('background_url', data.background_url || '');
|
||||||
|
document.getElementById('daily_limit').value = data.daily_limit;
|
||||||
|
document.getElementById('cooldown_seconds').value = data.cooldown_seconds;
|
||||||
|
document.getElementById('display_seconds').value = data.display_seconds;
|
||||||
|
document.getElementById('is_active').checked = data.is_active === 1;
|
||||||
|
document.getElementById('submitAdd').style.display = 'none';
|
||||||
|
document.getElementById('submitEdit').style.display = '';
|
||||||
|
document.getElementById('activeRow').style.display = '';
|
||||||
|
document.getElementById('kioskModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() { document.getElementById('kioskModal').classList.remove('active'); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildfeld im Modal auf den Wert des Kiosks setzen: Vorschau, URL-Feld und
|
||||||
|
* der Entfernen-Schalter hängen am selben Namen.
|
||||||
|
*/
|
||||||
|
function setImageField(name, value) {
|
||||||
|
const wrapper = document.querySelector('[name="' + name + '"]').closest('.form-group');
|
||||||
|
const text = wrapper.querySelector('input[type="text"]');
|
||||||
|
const preview = wrapper.querySelector('.image-preview');
|
||||||
|
const remove = wrapper.querySelector('input[type="checkbox"]');
|
||||||
|
if (text) text.value = value;
|
||||||
|
if (remove) remove.checked = false;
|
||||||
|
if (preview) {
|
||||||
|
const src = value && !/^https?:|^\//.test(value) ? '../' + value : value;
|
||||||
|
preview.innerHTML = value
|
||||||
|
? '<img src="' + src + '" alt="">'
|
||||||
|
: '<i class="fas fa-image" aria-hidden="true"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQr(url, name) {
|
||||||
|
var target = document.getElementById('qrTarget');
|
||||||
|
target.innerHTML = '';
|
||||||
|
document.getElementById('qrTitle').textContent = name;
|
||||||
|
new QRCode(target, { text: url, width: 260, height: 260, colorDark: '#101625', colorLight: '#ffffff' });
|
||||||
|
document.getElementById('qrModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQr() { document.getElementById('qrModal').classList.remove('active'); }
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { closeModal(); closeQr(); }
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal').forEach(function (m) {
|
||||||
|
m.addEventListener('click', function (e) { if (e.target === m) m.classList.remove('active'); });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -6,6 +6,7 @@ ini_set('log_errors', 1);
|
||||||
require_once __DIR__ . '/../config.php';
|
require_once __DIR__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/Ui.php';
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
|
|
@ -95,56 +96,46 @@ $adminBase = '';
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Reporting – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('rep_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
<div class="page-header">
|
||||||
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:22px; margin-bottom:20px; box-shadow:0 4px 14px var(--shadow); }
|
<div>
|
||||||
.card h2 { font-size:15px; margin-bottom:14px; color:var(--text-primary); }
|
<h1 class="page-title"><?= __('rep_title') ?></h1>
|
||||||
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px; }
|
<p class="page-subtitle"><?= __('rep_subtitle') ?></p>
|
||||||
.stat { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:20px; }
|
</div>
|
||||||
.stat .n { font-size:28px; font-weight:700; color:var(--text-primary); } .stat .l { color:var(--text-muted); font-size:12.5px; margin-top:3px; }
|
</div>
|
||||||
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:10px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); } th { color:var(--text-muted); }
|
|
||||||
.btn { padding:9px 15px; border:none; border-radius:8px; font-weight:600; font-size:13px; text-decoration:none; display:inline-block; cursor:pointer; }
|
|
||||||
.btn-s { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
|
||||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:18px; }
|
|
||||||
select.input { padding:9px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); }
|
|
||||||
@media print { .sidebar,.header,.toolbar,.no-print { display:none !important; } .main-content { margin:0 !important; } }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 style="font-size:24px;margin-bottom:18px;color:var(--text-primary);">📊 Reporting</h1>
|
|
||||||
|
|
||||||
<div class="toolbar no-print">
|
<div class="toolbar no-print">
|
||||||
<form method="get" style="display:flex;gap:8px;align-items:center;">
|
<form method="get" style="display:flex;gap:8px;align-items:center;">
|
||||||
<label style="color:var(--text-muted);font-size:13px;">Zeitraum:</label>
|
<label style="margin:0;"><?= __('rep_period') ?></label>
|
||||||
<select class="input" name="days" onchange="this.form.submit()">
|
<select class="input" name="days" onchange="this.form.submit()">
|
||||||
<?php foreach ([7,30,90,365] as $d): ?>
|
<?php foreach ([7,30,90,365] as $d): ?>
|
||||||
<option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> Tage</option>
|
<option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> <?= __('rep_days') ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn btn-s" href="?export=daily&days=<?= $days ?>">⬇️ CSV (täglich)</a>
|
<a class="btn btn-secondary" href="?export=daily&days=<?= $days ?>"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_daily') ?></a>
|
||||||
<a class="btn btn-s" href="?export=per_site">⬇️ CSV (pro Site)</a>
|
<a class="btn btn-secondary" href="?export=per_site"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_site') ?></a>
|
||||||
<a class="btn btn-s" href="?export=per_user">⬇️ CSV (pro Nutzer)</a>
|
<a class="btn btn-secondary" href="?export=per_user"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_user') ?></a>
|
||||||
<button class="btn btn-s" onclick="window.print()">🖨️ Drucken/PDF</button>
|
<button class="btn btn-secondary" onclick="window.print()"><i class="fas fa-print" aria-hidden="true"></i> <?= __('rep_print') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid4">
|
<div class="grid4">
|
||||||
<div class="stat"><div class="n"><?= (int)$totals['total'] ?></div><div class="l">Vouchers gesamt</div></div>
|
<div class="stat"><div class="n"><?= (int)$totals['total'] ?></div><div class="l"><?= __('rep_total') ?></div></div>
|
||||||
<div class="stat"><div class="n"><?= (int)$totals['valid'] ?></div><div class="l">Gültig</div></div>
|
<div class="stat"><div class="n"><?= (int)$totals['valid'] ?></div><div class="l"><?= __('status_valid') ?></div></div>
|
||||||
<div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l">Verwendet</div></div>
|
<div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l"><?= __('status_used') ?></div></div>
|
||||||
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l">In <?= $days ?> Tagen erstellt</div></div>
|
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l"><?= str_replace('{days}', (string)$days, __('rep_in_period')) ?></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Erstellte Voucher (<?= $days ?> Tage)</h2>
|
<h2><?= str_replace('{days}', (string)$days, __('rep_chart_title')) ?></h2>
|
||||||
<canvas id="chart" height="90"></canvas>
|
<canvas id="chart" height="90"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Pro Site</h2>
|
<h2><?= __('rep_per_site') ?></h2>
|
||||||
<table><tr><th>Site</th><th>Gesamt</th><th>Gültig</th><th>Verwendet</th><th>Abgelaufen</th></tr>
|
<table><tr><th><?= __('label_site') ?></th><th><?= __('label_total') ?></th><th><?= __('status_valid') ?></th><th><?= __('status_used') ?></th><th><?= __('status_expired') ?></th></tr>
|
||||||
<?php foreach ($perSite as $r): ?>
|
<?php foreach ($perSite as $r): ?>
|
||||||
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><?= (int)$r['total'] ?></td><td><?= (int)$r['valid'] ?></td><td><?= (int)$r['used'] ?></td><td><?= (int)$r['expired'] ?></td></tr>
|
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><?= (int)$r['total'] ?></td><td><?= (int)$r['valid'] ?></td><td><?= (int)$r['used'] ?></td><td><?= (int)$r['expired'] ?></td></tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
@ -152,22 +143,32 @@ select.input { padding:9px; border:2px solid var(--border-color); border-radius:
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Top-Nutzer</h2>
|
<h2><?= __('rep_top_users') ?></h2>
|
||||||
<table><tr><th>Benutzer</th><th>Voucher erstellt</th></tr>
|
<table><tr><th><?= __('label_user') ?></th><th><?= __('rep_col_created') ?></th></tr>
|
||||||
<?php foreach ($perUser as $r): ?>
|
<?php foreach ($perUser as $r): ?>
|
||||||
<tr><td><?= htmlspecialchars($r['name'] ?? '–') ?></td><td><?= (int)$r['c'] ?></td></tr>
|
<tr><td><?= htmlspecialchars($r['name'] ?? '–') ?></td><td><?= (int)$r['c'] ?></td></tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);">Keine Daten</td></tr><?php endif; ?>
|
<?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);"><?= __('rep_no_data') ?></td></tr><?php endif; ?>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /main-content -->
|
</main>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const styles = getComputedStyle(document.documentElement);
|
||||||
|
const accent = styles.getPropertyValue('--accent').trim();
|
||||||
|
const grid = styles.getPropertyValue('--border-color').trim();
|
||||||
|
const muted = styles.getPropertyValue('--text-muted').trim();
|
||||||
new Chart(document.getElementById('chart'), {
|
new Chart(document.getElementById('chart'), {
|
||||||
type:'line',
|
type:'line',
|
||||||
data:{ labels: <?= json_encode($chartLabels) ?>, datasets:[{ label:'Voucher', data: <?= json_encode($chartData) ?>, borderColor:'#667eea', backgroundColor:'rgba(102,126,234,.15)', fill:true, tension:.3 }] },
|
data:{ labels: <?= json_encode($chartLabels) ?>, datasets:[{ label:'Voucher', data: <?= json_encode($chartData) ?>, borderColor: accent, backgroundColor: accent + '22', fill:true, tension:.35, pointRadius:0, pointHoverRadius:4, borderWidth:2 }] },
|
||||||
options:{ plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true,ticks:{precision:0}}} }
|
options:{
|
||||||
|
plugins:{legend:{display:false}},
|
||||||
|
scales:{
|
||||||
|
y:{ beginAtZero:true, border:{display:false}, grid:{color:grid, drawTicks:false}, ticks:{precision:0, color:muted, padding:10, font:{size:11}} },
|
||||||
|
x:{ border:{display:false}, grid:{display:false}, ticks:{color:muted, padding:8, font:{size:11}} }
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ ini_set('log_errors', 1);
|
||||||
require_once __DIR__ . '/../config.php';
|
require_once __DIR__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
require_once __DIR__ . '/../includes/Ui.php';
|
||||||
|
|
||||||
|
I18n::init();
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
$auth->requireLogin();
|
$auth->requireLogin();
|
||||||
|
|
@ -24,20 +28,20 @@ $setupRequired = isset($_GET['setup_required']);
|
||||||
// 2FA aktivieren (Code bestaetigen)
|
// 2FA aktivieren (Code bestaetigen)
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$error = 'Ungültiges Sicherheits-Token';
|
$error = __('sec_token_invalid');
|
||||||
} else {
|
} else {
|
||||||
$secret = $_SESSION['totp_setup_secret'] ?? '';
|
$secret = $_SESSION['totp_setup_secret'] ?? '';
|
||||||
$code = trim($_POST['code'] ?? '');
|
$code = trim($_POST['code'] ?? '');
|
||||||
if ($secret === '') {
|
if ($secret === '') {
|
||||||
$error = 'Setup abgelaufen, bitte erneut starten.';
|
$error = __('sec_setup_expired');
|
||||||
} elseif (!Totp::verify($secret, $code)) {
|
} elseif (!Totp::verify($secret, $code)) {
|
||||||
$error = 'Code ungültig. Bitte erneut versuchen.';
|
$error = __('sec_code_invalid');
|
||||||
} else {
|
} else {
|
||||||
$backupCodes = $auth->enableTotp($user['id'], $secret);
|
$backupCodes = $auth->enableTotp($user['id'], $secret);
|
||||||
unset($_SESSION['totp_setup_secret']);
|
unset($_SESSION['totp_setup_secret']);
|
||||||
$totpEnabled = true;
|
$totpEnabled = true;
|
||||||
$user = $auth->getCurrentUser();
|
$user = $auth->getCurrentUser();
|
||||||
$success = 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!';
|
$success = __('sec_enabled');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,32 +49,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
|
||||||
// Überall abmelden (andere Sessions beenden)
|
// Überall abmelden (andere Sessions beenden)
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$error = 'Ungültiges Sicherheits-Token';
|
$error = __('sec_token_invalid');
|
||||||
} else {
|
} else {
|
||||||
$auth->logoutOtherSessions();
|
$auth->logoutOtherSessions();
|
||||||
$success = 'Alle anderen Sitzungen wurden beendet.';
|
$success = __('sec_sessions_closed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recovery-Codes neu erzeugen
|
// Recovery-Codes neu erzeugen
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$error = 'Ungültiges Sicherheits-Token';
|
$error = __('sec_token_invalid');
|
||||||
} elseif (!empty($user['totp_enabled'])) {
|
} elseif (!empty($user['totp_enabled'])) {
|
||||||
$backupCodes = $auth->regenerateBackupCodes($user['id']);
|
$backupCodes = $auth->regenerateBackupCodes($user['id']);
|
||||||
$user = $auth->getCurrentUser();
|
$user = $auth->getCurrentUser();
|
||||||
$success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.';
|
$success = __('sec_codes_new');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2FA deaktivieren
|
// 2FA deaktivieren
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$error = 'Ungültiges Sicherheits-Token';
|
$error = __('sec_token_invalid');
|
||||||
} else {
|
} else {
|
||||||
$auth->disableTotp($user['id']);
|
$auth->disableTotp($user['id']);
|
||||||
$totpEnabled = false;
|
$totpEnabled = false;
|
||||||
$success = 'Zwei-Faktor-Authentifizierung wurde deaktiviert.';
|
$success = __('sec_disabled');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,54 +91,36 @@ $dbSessions = $db->getSetting('session_driver', 'php') === 'db';
|
||||||
$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
|
$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="<?= I18n::getLanguage() ?>">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Zwei-Faktor-Authentifizierung – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('sec_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php if (!$totpEnabled && $hasPassword): ?>
|
<?php if (!$totpEnabled && $hasPassword): ?>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<style>
|
<?= Ui::head($db, '../') ?>
|
||||||
* { margin:0; padding:0; box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
|
|
||||||
body { background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px; }
|
|
||||||
.card { background:#fff; border-radius:18px; box-shadow:0 20px 60px rgba(0,0,0,.3); max-width:480px; width:100%; padding:36px; }
|
|
||||||
h1 { font-size:22px; color:#333; margin-bottom:6px; }
|
|
||||||
.sub { color:#777; font-size:14px; margin-bottom:24px; }
|
|
||||||
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
|
|
||||||
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
|
||||||
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
|
||||||
.status { display:inline-flex; align-items:center; gap:8px; padding:6px 12px; border-radius:8px; font-size:13px; font-weight:600; margin-bottom:20px; }
|
|
||||||
.on { background:#e3f6ea; color:#2a7; } .off { background:#fdeaea; color:#c33; }
|
|
||||||
.qr { display:flex; justify-content:center; margin:18px 0; }
|
|
||||||
.secret { font-family:monospace; background:#f5f6fa; padding:10px; border-radius:8px; text-align:center; letter-spacing:2px; word-break:break-all; font-size:14px; margin-bottom:18px; }
|
|
||||||
ol { margin:0 0 18px 18px; color:#555; font-size:14px; line-height:1.7; }
|
|
||||||
label { display:block; font-size:14px; color:#555; margin-bottom:8px; font-weight:500; }
|
|
||||||
input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-radius:10px; font-size:18px; letter-spacing:6px; text-align:center; }
|
|
||||||
.btn { width:100%; padding:14px; border:none; border-radius:10px; font-size:15px; font-weight:600; cursor:pointer; margin-top:14px; }
|
|
||||||
.btn-primary { background:#667eea; color:#fff; } .btn-danger { background:#e25555; color:#fff; }
|
|
||||||
.back { display:block; text-align:center; margin-top:20px; color:#667eea; text-decoration:none; font-size:14px; }
|
|
||||||
.codes-box { background:#fff8e6; border:1px solid #ffe3a3; border-radius:10px; padding:16px; margin-bottom:18px; }
|
|
||||||
.codes-box strong { font-size:15px; } .codes-box p { color:#8a6d2f; font-size:13px; margin:6px 0 12px; }
|
|
||||||
.codes { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
|
||||||
.codes span { font-family:monospace; background:#fff; border:1px solid #ffe3a3; border-radius:6px; padding:8px; text-align:center; letter-spacing:2px; font-size:14px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app-body focus-page">
|
||||||
<div class="card">
|
<div class="focus-card card">
|
||||||
<h1>🔐 Zwei-Faktor-Authentifizierung</h1>
|
<div class="focus-head">
|
||||||
<p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p>
|
<span class="focus-icon"><i class="fas fa-shield-halved" aria-hidden="true"></i></span>
|
||||||
|
<div>
|
||||||
|
<h1><?= __('sec_title') ?></h1>
|
||||||
|
<p class="sub"><?= __('sec_account') ?> <?= htmlspecialchars($user['email']) ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php if ($setupRequired && !$totpEnabled): ?>
|
<?php if ($setupRequired && !$totpEnabled): ?>
|
||||||
<div class="alert alert-error">Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.</div>
|
<div class="alert alert-error"><?= __('sec_required_hint') ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
<?php if (!empty($backupCodes)): ?>
|
<?php if (!empty($backupCodes)): ?>
|
||||||
<div class="codes-box">
|
<div class="codes-box">
|
||||||
<strong>🔑 Recovery-Codes</strong>
|
<strong><i class="fas fa-key" aria-hidden="true"></i> <?= __('sec_recovery_codes') ?></strong>
|
||||||
<p>Bewahren Sie diese sicher auf. Jeder Code funktioniert <em>einmal</em>, falls Sie keinen Zugriff auf Ihre App haben.</p>
|
<p><?= __('sec_recovery_hint') ?></p>
|
||||||
<div class="codes">
|
<div class="codes">
|
||||||
<?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?>
|
<?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -142,53 +128,54 @@ input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-ra
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (!$hasPassword): ?>
|
<?php if (!$hasPassword): ?>
|
||||||
<div class="status off">● Nicht verfügbar</div>
|
<div class="status off"><i class="fas fa-circle-minus" aria-hidden="true"></i> <?= __('sec_unavailable') ?></div>
|
||||||
<p class="sub">Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.</p>
|
<p class="sub"><?= __('sec_m365_hint') ?></p>
|
||||||
<?php elseif ($totpEnabled): ?>
|
<?php elseif ($totpEnabled): ?>
|
||||||
<div class="status on">● Aktiv</div>
|
<div class="status on"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('sec_active') ?></div>
|
||||||
<p class="sub">Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.<br>
|
<p class="sub"><?= __('sec_active_hint') ?><br>
|
||||||
Verbleibende Recovery-Codes: <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
|
<?= __('sec_codes_left') ?> <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
|
||||||
<form method="post" style="margin-bottom:10px;">
|
<form method="post" style="margin-bottom:10px;">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<button type="submit" name="regen_codes" class="btn btn-secondary" style="background:#eef0ff;color:#5a63d6;width:100%;">Recovery-Codes neu erzeugen</button>
|
<button type="submit" name="regen_codes" class="btn btn-secondary btn-lg btn-block"><?= __('sec_regen_codes') ?></button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" onsubmit="return confirm('2FA wirklich deaktivieren?');">
|
<form method="post" onsubmit="return confirm('<?= __('sec_disable_confirm') ?>');">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<button type="submit" name="disable_totp" class="btn btn-danger">2FA deaktivieren</button>
|
<button type="submit" name="disable_totp" class="btn btn-danger btn-lg btn-block"><?= __('sec_disable') ?></button>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="status off">● Inaktiv</div>
|
<div class="status off"><i class="fas fa-circle-minus" aria-hidden="true"></i> <?= __('sec_inactive') ?></div>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)</li>
|
<li><?= __('sec_step_1') ?></li>
|
||||||
<li>QR-Code scannen <em>oder</em> Secret manuell eingeben</li>
|
<li><?= __('sec_step_2') ?></li>
|
||||||
<li>Den angezeigten 6-stelligen Code unten eingeben</li>
|
<li><?= __('sec_step_3') ?></li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="qr"><div id="qrcode"></div></div>
|
<div class="qr"><div id="qrcode"></div></div>
|
||||||
<div class="secret"><?= htmlspecialchars($setupSecret) ?></div>
|
<div class="secret"><?= htmlspecialchars($setupSecret) ?></div>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<label for="code">6-stelliger Code</label>
|
<label for="code"><?= __('sec_code_label') ?></label>
|
||||||
<input type="text" id="code" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required>
|
<input type="text" id="code" name="code" class="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required placeholder="123456">
|
||||||
<button type="submit" name="enable_totp" class="btn btn-primary">2FA aktivieren</button>
|
<button type="submit" name="enable_totp" class="btn btn-primary btn-lg btn-block" style="margin-top:14px;"><?= __('sec_enable') ?></button>
|
||||||
</form>
|
</form>
|
||||||
<script>
|
<script>
|
||||||
new QRCode(document.getElementById('qrcode'), {
|
new QRCode(document.getElementById('qrcode'), {
|
||||||
text: <?= json_encode($otpUri) ?>, width: 180, height: 180,
|
text: <?= json_encode($otpUri) ?>, width: 168, height: 168,
|
||||||
|
colorDark: '#101625', colorLight: '#ffffff',
|
||||||
correctLevel: QRCode.CorrectLevel.M
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($dbSessions): ?>
|
<?php if ($dbSessions): ?>
|
||||||
<hr style="margin:20px 0;border:none;border-top:1px solid #eee;">
|
<hr>
|
||||||
<p class="sub">Aktive Sitzungen: <strong><?= (int)$activeSessions ?></strong></p>
|
<p class="sub"><?= __('sec_sessions') ?> <strong><?= (int)$activeSessions ?></strong></p>
|
||||||
<form method="post" onsubmit="return confirm('Alle anderen Sitzungen abmelden?');">
|
<form method="post" onsubmit="return confirm('<?= __('sec_logout_others_confirm') ?>');">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<button type="submit" name="logout_others" class="btn" style="background:#eef0ff;color:#5a63d6;width:100%;">Auf allen anderen Geräten abmelden</button>
|
<button type="submit" name="logout_others" class="btn btn-secondary btn-lg btn-block"><?= __('sec_logout_others') ?></button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<a class="back" href="../index.php">← Zurück</a>
|
<div class="auth-links"><a class="back-link" href="../index.php"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('nav_back') ?></a></div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
require_once __DIR__ . '/../includes/Mailer.php';
|
require_once __DIR__ . '/../includes/Mailer.php';
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
require_once __DIR__ . '/../includes/Ui.php';
|
||||||
|
require_once __DIR__ . '/../includes/Upload.php';
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
$auth->requireAdmin();
|
$auth->requireAdmin();
|
||||||
|
|
@ -51,13 +53,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
||||||
|
|
||||||
if ($formType === 'general') {
|
if ($formType === 'general') {
|
||||||
$settings['app_title'] = trim($_POST['app_title'] ?? '');
|
$settings['app_title'] = trim($_POST['app_title'] ?? '');
|
||||||
$settings['logo_url'] = trim($_POST['logo_url'] ?? '');
|
$settings['logo_url'] = Upload::resolveField('logo_url', (string)$db->getSetting('logo_url', ''), 'image');
|
||||||
$settings['favicon_url'] = trim($_POST['favicon_url'] ?? '');
|
$settings['favicon_url'] = Upload::resolveField('favicon_url', (string)$db->getSetting('favicon_url', ''), 'favicon');
|
||||||
$settings['instruction_header'] = trim($_POST['instruction_header'] ?? '');
|
$settings['instruction_header'] = trim($_POST['instruction_header'] ?? '');
|
||||||
$settings['instruction_text'] = $_POST['instruction_text'] ?? '';
|
$settings['instruction_text'] = $_POST['instruction_text'] ?? '';
|
||||||
$settings['public_access'] = isset($_POST['public_access']) ? '1' : '0';
|
$settings['public_access'] = isset($_POST['public_access']) ? '1' : '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($formType === 'branding') {
|
||||||
|
foreach (['brand_accent', 'brand_accent_dark', 'brand_gradient_from', 'brand_gradient_to'] as $key) {
|
||||||
|
$value = strtolower(trim($_POST[$key] ?? ''));
|
||||||
|
$settings[$key] = preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : '';
|
||||||
|
}
|
||||||
|
$radius = (int)($_POST['brand_radius'] ?? Ui::DEFAULT_RADIUS);
|
||||||
|
$settings['brand_radius'] = (string)max(0, min(28, $radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($formType === 'login') {
|
||||||
|
$settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0';
|
||||||
|
$settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? '');
|
||||||
|
$settings['login_logo_url'] = Upload::resolveField('login_logo_url', (string)$db->getSetting('login_logo_url', ''), 'image');
|
||||||
|
$settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? '');
|
||||||
|
$settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? '');
|
||||||
|
$settings['login_features'] = trim($_POST['login_features'] ?? '');
|
||||||
|
$settings['login_footer'] = trim($_POST['login_footer'] ?? '');
|
||||||
|
$settings['login_bg_image'] = Upload::resolveField('login_bg_image', (string)$db->getSetting('login_bg_image', ''), 'image');
|
||||||
|
$settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? '');
|
||||||
|
$settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? '');
|
||||||
|
$overlay = (int)($_POST['login_bg_overlay'] ?? 40);
|
||||||
|
$settings['login_bg_overlay'] = (string)max(0, min(90, $overlay));
|
||||||
|
}
|
||||||
|
|
||||||
if ($formType === 'defaults') {
|
if ($formType === 'defaults') {
|
||||||
$expMin = (int)($_POST['default_expire_minutes'] ?? 480);
|
$expMin = (int)($_POST['default_expire_minutes'] ?? 480);
|
||||||
$defDev = (int)($_POST['default_max_uses'] ?? 1);
|
$defDev = (int)($_POST['default_max_uses'] ?? 1);
|
||||||
|
|
@ -98,7 +124,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($formType === 'system') {
|
if ($formType === 'system') {
|
||||||
$settings['tinymce_api_key'] = trim($_POST['tinymce_api_key'] ?? '');
|
|
||||||
$settings['print_template'] = $_POST['print_template'] ?? '';
|
$settings['print_template'] = $_POST['print_template'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,6 +132,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$success = __('settings_saved');
|
$success = __('settings_saved');
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = 'Fehler: ' . $e->getMessage();
|
$error = 'Fehler: ' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
@ -185,11 +212,26 @@ $cs = [
|
||||||
'smtp_from_name' => $db->getSetting('smtp_from_name', ''),
|
'smtp_from_name' => $db->getSetting('smtp_from_name', ''),
|
||||||
'system_url' => $db->getSetting('system_url', $autoDetectedUrl),
|
'system_url' => $db->getSetting('system_url', $autoDetectedUrl),
|
||||||
'email_voucher_subject' => $db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'),
|
'email_voucher_subject' => $db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'),
|
||||||
'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nIhr Code: {VOUCHER_CODE}\n\nGültigkeit: 8h\nGeräte: {MAX_USES}\nSite: {SITE_NAME}"),
|
'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\n<strong>Maximale Geräte:</strong> {MAX_USES}<br>\n<strong>Standort:</strong> {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}"),
|
||||||
'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'),
|
'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'),
|
||||||
'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"),
|
'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"),
|
||||||
'tinymce_api_key' => $db->getSetting('tinymce_api_key', ''),
|
'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()),
|
||||||
'print_template' => $db->getSetting('print_template', '<div style="text-align:center;padding:40px"><h1>{APP_TITLE}</h1><h2>WLAN Code</h2><div style="font-size:48px;font-weight:bold;margin:30px 0;font-family:monospace">{VOUCHER_CODE}</div><p><strong>Gültig bis:</strong> {EXPIRY_DATE} {EXPIRY_TIME}</p><p><strong>Site:</strong> {SITE_NAME}</p><p><strong>Geräte:</strong> {MAX_USES}</p><hr style="margin:30px 0"><div>{INSTRUCTIONS}</div></div>'),
|
'brand_accent' => $db->getSetting('brand_accent', '') ?: Ui::DEFAULT_ACCENT,
|
||||||
|
'brand_accent_dark' => $db->getSetting('brand_accent_dark', '') ?: Ui::DEFAULT_ACCENT_DARK,
|
||||||
|
'brand_gradient_from' => $db->getSetting('brand_gradient_from', '') ?: Ui::DEFAULT_GRADIENT_FROM,
|
||||||
|
'brand_gradient_to' => $db->getSetting('brand_gradient_to', '') ?: Ui::DEFAULT_GRADIENT_TO,
|
||||||
|
'brand_radius' => $db->getSetting('brand_radius', (string)Ui::DEFAULT_RADIUS),
|
||||||
|
'login_panel_enabled' => $db->getSetting('login_panel_enabled', '1'),
|
||||||
|
'login_brand_name' => $db->getSetting('login_brand_name', ''),
|
||||||
|
'login_logo_url' => $db->getSetting('login_logo_url', ''),
|
||||||
|
'login_claim_title' => $db->getSetting('login_claim_title', ''),
|
||||||
|
'login_claim_text' => $db->getSetting('login_claim_text', ''),
|
||||||
|
'login_features' => $db->getSetting('login_features', ''),
|
||||||
|
'login_footer' => $db->getSetting('login_footer', ''),
|
||||||
|
'login_bg_image' => $db->getSetting('login_bg_image', ''),
|
||||||
|
'login_bg_from' => $db->getSetting('login_bg_from', '#3b2f8f'),
|
||||||
|
'login_bg_to' => $db->getSetting('login_bg_to', '#6d5ce7'),
|
||||||
|
'login_bg_overlay' => $db->getSetting('login_bg_overlay', '40'),
|
||||||
'cron_token' => $db->getSetting('cron_token', ''),
|
'cron_token' => $db->getSetting('cron_token', ''),
|
||||||
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
|
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
|
||||||
];
|
];
|
||||||
|
|
@ -204,101 +246,63 @@ $adminBase = '';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
||||||
|
|
||||||
<?php if (!empty($cs['tinymce_api_key'])): ?>
|
<!-- TinyMCE wird lokal ausgeliefert (GPL-Variante) – keine externen Aufrufe. -->
|
||||||
<script src="https://cdn.tiny.cloud/1/<?= htmlspecialchars($cs['tinymce_api_key']) ?>/tinymce/6/tinymce.min.js"></script>
|
<?= Ui::script('assets/vendor/tinymce/tinymce.min.js', '../') ?>
|
||||||
<?php else: ?>
|
<script>window.TINYMCE_BASE_URL = '../assets/vendor/tinymce';</script>
|
||||||
<script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js"></script>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
|
||||||
<style>
|
|
||||||
.page-header { margin-bottom: 30px; }
|
|
||||||
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
|
|
||||||
.alert { padding: 14px 20px; border-radius: 10px; margin-bottom: 25px; font-size: 14px; display: flex; align-items: center; gap: 10px; }
|
|
||||||
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
|
|
||||||
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
|
|
||||||
.tab-container { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; }
|
|
||||||
.tab-navigation { display: flex; background: var(--bg-table-head); border-bottom: 2px solid var(--border-color); overflow-x: auto; position: sticky; top: 70px; z-index: 50; }
|
|
||||||
.tab-button { padding: 15px 20px; background: transparent; border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-secondary); transition: all 0.3s; white-space: nowrap; display: flex; align-items: center; gap: 7px; }
|
|
||||||
.tab-button:hover { background: rgba(102,126,234,0.1); color: var(--accent); }
|
|
||||||
.tab-button.active { color: var(--accent); border-bottom-color: var(--accent); background: var(--bg-card); }
|
|
||||||
.tab-content { display: none; padding: 30px; animation: fadeIn 0.2s; }
|
|
||||||
.tab-content.active { display: block; }
|
|
||||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
.form-group { margin-bottom: 20px; }
|
|
||||||
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
|
||||||
input[type="text"], input[type="url"], input[type="password"], input[type="number"], input[type="email"], select, textarea { width: 100%; padding: 11px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; transition: border-color 0.2s; font-family: inherit; background: var(--bg-input); color: var(--text-primary); }
|
|
||||||
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
|
|
||||||
textarea { resize: vertical; min-height: 100px; }
|
|
||||||
.checkbox-group { display: flex; align-items: center; gap: 10px; }
|
|
||||||
.checkbox-group input { width: auto; accent-color: var(--accent); }
|
|
||||||
.help-text { font-size: 12px; color: var(--text-muted); margin-top: 5px; }
|
|
||||||
.info-box { background: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
|
||||||
.info-box h4 { color: #0066cc; margin-bottom: 8px; font-size: 14px; }
|
|
||||||
.info-box p { color: #004d99; font-size: 13px; line-height: 1.5; }
|
|
||||||
.section-divider { border: none; border-top: 2px solid var(--border-color); margin: 28px 0; }
|
|
||||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
|
|
||||||
.placeholder-info { background: #fff9e6; border: 1px solid #ffe066; border-radius: 8px; padding: 14px; margin: 14px 0; }
|
|
||||||
.placeholder-info h4 { color: #996600; margin-bottom: 8px; font-size: 14px; }
|
|
||||||
.placeholder-info code { background: #fff; padding: 2px 6px; border-radius: 3px; font-size: 12px; color: #d63384; }
|
|
||||||
.placeholder-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
||||||
.btn-primary { background: var(--accent); color: white; }
|
|
||||||
.btn-primary:hover { background: var(--accent-hover); }
|
|
||||||
.btn-danger { background: #dc3545; color: white; }
|
|
||||||
.btn-small { padding: 6px 12px; font-size: 13px; }
|
|
||||||
.token-box { background: var(--bg-hover); padding: 14px; border-radius: 6px; font-family: monospace; word-break: break-all; color: var(--text-primary); }
|
|
||||||
code { background: var(--code-bg); color: var(--accent); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title"><?= __('settings_title') ?></h1>
|
<div>
|
||||||
<p style="color: var(--text-muted); font-size: 14px;"><?= __('settings_subtitle') ?></p>
|
<h1 class="page-title"><?= __('settings_title') ?></h1>
|
||||||
|
<p class="page-subtitle"><?= __('settings_subtitle') ?></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i><span><?= htmlspecialchars($error) ?></span></div>
|
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i><span><?= htmlspecialchars($error) ?></span></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($success): ?>
|
<?php if ($success): ?>
|
||||||
<div class="alert alert-success"><i class="fas fa-check-circle"></i><span><?= htmlspecialchars($success) ?></span></div>
|
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i><span><?= htmlspecialchars($success) ?></span></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="tab-container">
|
<div class="tab-container">
|
||||||
<div class="tab-navigation" id="tabNav">
|
<div class="tab-navigation" id="tabNav">
|
||||||
<button class="tab-button active" data-tab="general"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></button>
|
<button class="tab-button<?= $activeTab === 'general' ? ' active' : '' ?>" data-tab="general"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></button>
|
||||||
<button class="tab-button" data-tab="defaults"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></button>
|
<button class="tab-button<?= $activeTab === 'defaults' ? ' active' : '' ?>" data-tab="defaults"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></button>
|
||||||
<button class="tab-button" data-tab="cron"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></button>
|
<button class="tab-button<?= $activeTab === 'branding' ? ' active' : '' ?>" data-tab="branding"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></button>
|
||||||
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft"></i> <?= __('settings_tab_m365') ?></button>
|
<button class="tab-button<?= $activeTab === 'login' ? ' active' : '' ?>" data-tab="login"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></button>
|
||||||
<button class="tab-button" data-tab="smtp"><i class="fas fa-envelope"></i> <?= __('settings_tab_smtp') ?></button>
|
<button class="tab-button<?= $activeTab === 'cron' ? ' active' : '' ?>" data-tab="cron"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></button>
|
||||||
<button class="tab-button" data-tab="templates_email"><i class="fas fa-file-alt"></i> <?= __('settings_tab_templates_email') ?></button>
|
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft" aria-hidden="true"></i> <?= __('settings_tab_m365') ?></button>
|
||||||
<button class="tab-button" data-tab="system"><i class="fas fa-cogs"></i> <?= __('settings_tab_system') ?></button>
|
<button class="tab-button<?= $activeTab === 'smtp' ? ' active' : '' ?>" data-tab="smtp"><i class="fas fa-envelope" aria-hidden="true"></i> <?= __('settings_tab_smtp') ?></button>
|
||||||
<button class="tab-button" data-tab="password"><i class="fas fa-key"></i> <?= __('settings_tab_password') ?></button>
|
<button class="tab-button<?= $activeTab === 'templates_email' ? ' active' : '' ?>" data-tab="templates_email"><i class="fas fa-file-alt" aria-hidden="true"></i> <?= __('settings_tab_templates_email') ?></button>
|
||||||
|
<button class="tab-button<?= $activeTab === 'system' ? ' active' : '' ?>" data-tab="system"><i class="fas fa-cogs" aria-hidden="true"></i> <?= __('settings_tab_system') ?></button>
|
||||||
|
<button class="tab-button<?= $activeTab === 'password' ? ' active' : '' ?>" data-tab="password"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Allgemein -->
|
<!-- Allgemein -->
|
||||||
<div id="tab-general" class="tab-content active">
|
<div id="tab-general" class="tab-content<?= $activeTab === 'general' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></h2>
|
||||||
<form method="post">
|
<form method="post" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="general">
|
<input type="hidden" name="form_type" value="general">
|
||||||
<div class="form-group"><label><?= __('settings_app_title') ?></label><input type="text" name="app_title" value="<?= htmlspecialchars($cs['app_title']) ?>" required></div>
|
<div class="form-group"><label><?= __('settings_app_title') ?></label><input type="text" name="app_title" value="<?= htmlspecialchars($cs['app_title']) ?>" required></div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group"><label><?= __('settings_logo_url') ?></label><input type="url" name="logo_url" value="<?= htmlspecialchars($cs['logo_url']) ?>" placeholder="https://example.com/logo.png"></div>
|
<?= Ui::imageField('logo_url', __('settings_logo_url'), $cs['logo_url'], __('settings_upload_hint')) ?>
|
||||||
<div class="form-group"><label><?= __('settings_favicon_url') ?></label><input type="url" name="favicon_url" value="<?= htmlspecialchars($cs['favicon_url']) ?>" placeholder="https://example.com/favicon.ico"><div class="help-text"><?= __('settings_favicon_hint') ?></div></div>
|
<?= Ui::imageField('favicon_url', __('settings_favicon_url'), $cs['favicon_url'], __('settings_favicon_hint'), 'image/x-icon,image/png,image/svg+xml') ?>
|
||||||
</div>
|
</div>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<div class="form-group"><label><?= __('settings_instr_header') ?></label><input type="text" name="instruction_header" value="<?= htmlspecialchars($cs['instruction_header']) ?>"></div>
|
<div class="form-group"><label><?= __('settings_instr_header') ?></label><input type="text" name="instruction_header" value="<?= htmlspecialchars($cs['instruction_header']) ?>"></div>
|
||||||
<div class="form-group"><label><?= __('settings_instr_text') ?></label><textarea name="instruction_text" class="tinymce-editor"><?= htmlspecialchars($cs['instruction_text']) ?></textarea></div>
|
<div class="form-group"><label><?= __('settings_instr_text') ?></label><textarea name="instruction_text" class="tinymce-editor"><?= htmlspecialchars($cs['instruction_text']) ?></textarea></div>
|
||||||
<div class="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="public_access" id="public_access" <?= $cs['public_access'] == '1' ? 'checked' : '' ?>><label for="public_access" style="margin:0;"><?= __('settings_public_access') ?></label></div>
|
<div class="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="public_access" id="public_access" <?= $cs['public_access'] == '1' ? 'checked' : '' ?>><label for="public_access" style="margin:0;"><?= __('settings_public_access') ?></label></div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Voucher-Standards -->
|
<!-- Voucher-Standards -->
|
||||||
<div id="tab-defaults" class="tab-content">
|
<div id="tab-defaults" class="tab-content<?= $activeTab === 'defaults' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></h2>
|
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></h2>
|
||||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;">Diese Werte werden als Vorgabe im Voucher-Formular verwendet.</p>
|
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_defaults_hint') ?></p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="defaults">
|
<input type="hidden" name="form_type" value="defaults">
|
||||||
|
|
@ -320,37 +324,184 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-box" style="margin-top: 10px;">
|
<div class="info-box" style="margin-top: 10px;">
|
||||||
<h4><i class="fas fa-info-circle"></i> Gültigkeits-Referenz</h4>
|
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> Gültigkeits-Referenz</h4>
|
||||||
<p>
|
<p>
|
||||||
60 Min = 1 Stunde | 480 Min = 8 Stunden |
|
60 Min = 1 Stunde | 480 Min = 8 Stunden |
|
||||||
1440 Min = 1 Tag | 10080 Min = 1 Woche |
|
1440 Min = 1 Tag | 10080 Min = 1 Woche |
|
||||||
43200 Min = 30 Tage
|
43200 Min = 30 Tage
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top: 16px;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top: 16px;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Design & Branding -->
|
||||||
|
<div id="tab-branding" class="tab-content<?= $activeTab === 'branding' ? ' active' : '' ?>">
|
||||||
|
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></h2>
|
||||||
|
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_branding_intro') ?></p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
<input type="hidden" name="form_type" value="branding">
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_brand_accent') ?></label>
|
||||||
|
<div class="color-field">
|
||||||
|
<input type="color" class="color-swatch" data-target="brand_accent" value="<?= htmlspecialchars($cs['brand_accent']) ?>">
|
||||||
|
<input type="text" id="brand_accent" name="brand_accent" value="<?= htmlspecialchars($cs['brand_accent']) ?>" placeholder="<?= Ui::DEFAULT_ACCENT ?>">
|
||||||
|
</div>
|
||||||
|
<div class="help-text"><?= __('settings_brand_accent_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_brand_accent_dark') ?></label>
|
||||||
|
<div class="color-field">
|
||||||
|
<input type="color" class="color-swatch" data-target="brand_accent_dark" value="<?= htmlspecialchars($cs['brand_accent_dark']) ?>">
|
||||||
|
<input type="text" id="brand_accent_dark" name="brand_accent_dark" value="<?= htmlspecialchars($cs['brand_accent_dark']) ?>" placeholder="<?= Ui::DEFAULT_ACCENT_DARK ?>">
|
||||||
|
</div>
|
||||||
|
<div class="help-text"><?= __('settings_brand_accent_dark_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_brand_gradient_from') ?></label>
|
||||||
|
<div class="color-field">
|
||||||
|
<input type="color" class="color-swatch" data-target="brand_gradient_from" value="<?= htmlspecialchars($cs['brand_gradient_from']) ?>">
|
||||||
|
<input type="text" id="brand_gradient_from" name="brand_gradient_from" value="<?= htmlspecialchars($cs['brand_gradient_from']) ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_brand_gradient_to') ?></label>
|
||||||
|
<div class="color-field">
|
||||||
|
<input type="color" class="color-swatch" data-target="brand_gradient_to" value="<?= htmlspecialchars($cs['brand_gradient_to']) ?>">
|
||||||
|
<input type="text" id="brand_gradient_to" name="brand_gradient_to" value="<?= htmlspecialchars($cs['brand_gradient_to']) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="help-text"><?= __('settings_brand_gradient_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_brand_radius') ?></label>
|
||||||
|
<select id="brand_radius" name="brand_radius">
|
||||||
|
<?php foreach ([6 => __('settings_brand_radius_sharp'), 14 => __('settings_brand_radius_default'), 20 => __('settings_brand_radius_round')] as $value => $label): ?>
|
||||||
|
<option value="<?= $value ?>" <?= (int)$cs['brand_radius'] === $value ? 'selected' : '' ?>><?= htmlspecialchars($label) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="brandPreview" style="margin-top: 8px;">
|
||||||
|
<h3><?= __('settings_brand_preview') ?></h3>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:14px;">
|
||||||
|
<span class="brand-mark" style="width:38px;height:38px;"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||||||
|
<button type="button" class="btn btn-primary"><i class="fas fa-ticket" aria-hidden="true"></i> <?= __('voucher_create_btn') ?></button>
|
||||||
|
<button type="button" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
|
<span class="badge badge-success"><?= __('status_valid') ?></span>
|
||||||
|
<span class="chip"><?= __('nav_vouchers') ?></span>
|
||||||
|
<a href="#" onclick="return false;"><?= __('settings_brand_link') ?></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top:16px;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login-Seite -->
|
||||||
|
<div id="tab-login" class="tab-content<?= $activeTab === 'login' ? ' active' : '' ?>">
|
||||||
|
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></h2>
|
||||||
|
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_login_intro') ?></p>
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
<input type="hidden" name="form_type" value="login">
|
||||||
|
|
||||||
|
<div class="checkbox-group" style="margin-bottom: 20px;">
|
||||||
|
<input type="checkbox" name="login_panel_enabled" id="login_panel_enabled" <?= $cs['login_panel_enabled'] === '1' ? 'checked' : '' ?>>
|
||||||
|
<label for="login_panel_enabled" style="margin:0;"><?= __('settings_login_panel') ?></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_login_brand') ?></label>
|
||||||
|
<input type="text" name="login_brand_name" value="<?= htmlspecialchars($cs['login_brand_name']) ?>" placeholder="<?= htmlspecialchars($cs['app_title']) ?>">
|
||||||
|
<div class="help-text"><?= __('settings_login_brand_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
<?= Ui::imageField('login_logo_url', __('settings_login_logo'), $cs['login_logo_url'], __('settings_login_logo_hint')) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_login_claim_title') ?></label>
|
||||||
|
<input type="text" name="login_claim_title" value="<?= htmlspecialchars($cs['login_claim_title']) ?>" placeholder="<?= htmlspecialchars(__('auth_claim_title')) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_login_claim_text') ?></label>
|
||||||
|
<textarea name="login_claim_text" rows="3" placeholder="<?= htmlspecialchars(__('auth_claim_text')) ?>"><?= htmlspecialchars($cs['login_claim_text']) ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_login_features') ?></label>
|
||||||
|
<textarea name="login_features" rows="4" placeholder="<?= htmlspecialchars(__('auth_feature_1') . "\n" . __('auth_feature_2') . "\n" . __('auth_feature_3')) ?>"><?= htmlspecialchars($cs['login_features']) ?></textarea>
|
||||||
|
<div class="help-text"><?= __('settings_login_features_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_login_footer') ?></label>
|
||||||
|
<input type="text" name="login_footer" value="<?= htmlspecialchars($cs['login_footer']) ?>" placeholder="© <?= date('Y') ?> <?= htmlspecialchars($cs['app_title']) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider">
|
||||||
|
|
||||||
|
<?= Ui::imageField('login_bg_image', __('settings_login_bg_image'), $cs['login_bg_image'], __('settings_login_bg_image_hint')) ?>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_login_bg_from') ?></label>
|
||||||
|
<div class="color-field">
|
||||||
|
<input type="color" class="color-swatch" data-target="login_bg_from"
|
||||||
|
value="<?= preg_match('/^#[0-9a-fA-F]{6}$/', $cs['login_bg_from']) ? htmlspecialchars($cs['login_bg_from']) : '#3b2f8f' ?>">
|
||||||
|
<input type="text" id="login_bg_from" name="login_bg_from" value="<?= htmlspecialchars($cs['login_bg_from']) ?>" placeholder="#3b2f8f">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_login_bg_to') ?></label>
|
||||||
|
<div class="color-field">
|
||||||
|
<input type="color" class="color-swatch" data-target="login_bg_to"
|
||||||
|
value="<?= preg_match('/^#[0-9a-fA-F]{6}$/', $cs['login_bg_to']) ? htmlspecialchars($cs['login_bg_to']) : '#6d5ce7' ?>">
|
||||||
|
<input type="text" id="login_bg_to" name="login_bg_to" value="<?= htmlspecialchars($cs['login_bg_to']) ?>" placeholder="#6d5ce7">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><?= __('settings_login_overlay') ?></label>
|
||||||
|
<input type="number" name="login_bg_overlay" min="0" max="90" value="<?= (int)$cs['login_bg_overlay'] ?>">
|
||||||
|
<div class="help-text"><?= __('settings_login_overlay_hint') ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:16px;">
|
||||||
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||||
|
<a href="../login.php?preview=1" target="_blank" rel="noopener" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> <?= __('settings_login_preview') ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cron-Sync -->
|
<!-- Cron-Sync -->
|
||||||
<div id="tab-cron" class="tab-content">
|
<div id="tab-cron" class="tab-content<?= $activeTab === 'cron' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></h2>
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4><i class="fas fa-info-circle"></i> Was macht der Cron-Job?</h4>
|
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> <?= __('settings_cron_what') ?></h4>
|
||||||
<p>Der Cron-Job synchronisiert automatisch alle Voucher von Ihren UniFi Controllern in die lokale Datenbank.</p>
|
<p>Der Cron-Job synchronisiert automatisch alle Voucher von Ihren UniFi Controllern in die lokale Datenbank.</p>
|
||||||
</div>
|
</div>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<?php if (empty($cs['cron_token'])): ?>
|
<?php if (empty($cs['cron_token'])): ?>
|
||||||
<p style="color: var(--text-muted); margin-bottom: 15px;"><i class="fas fa-exclamation-triangle" style="color: var(--warning);"></i> Kein Token konfiguriert.</p>
|
<p style="color: var(--text-muted); margin-bottom: 15px;"><i class="fas fa-exclamation-triangle" style="color: var(--warning);" aria-hidden="true"></i> Kein Token konfiguriert.</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<button type="submit" name="generate_cron_token" class="btn btn-primary"><i class="fas fa-key"></i> Token generieren</button>
|
<button type="submit" name="generate_cron_token" class="btn btn-primary"><i class="fas fa-key" aria-hidden="true"></i> Token generieren</button>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="token-box" style="margin-bottom: 15px;"><?= htmlspecialchars($cs['cron_token']) ?></div>
|
<div class="token-box" style="margin-bottom: 15px;"><?= htmlspecialchars($cs['cron_token']) ?></div>
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
|
||||||
<button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy"></i> Kopieren</button>
|
<button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy" aria-hidden="true"></i> Kopieren</button>
|
||||||
<form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync"></i> Neu generieren</button></form>
|
<form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync" aria-hidden="true"></i> Neu generieren</button></form>
|
||||||
<form method="post" style="display:inline;" onsubmit="return confirm('Token wirklich löschen?');"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="delete_cron_token" class="btn btn-secondary" style="color: var(--danger);"><i class="fas fa-trash"></i> Löschen</button></form>
|
<form method="post" style="display:inline;" onsubmit="return confirm('<?= __('js_confirm_delete_token') ?>');"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="delete_cron_token" class="btn btn-secondary" style="color: var(--danger);"><i class="fas fa-trash" aria-hidden="true"></i> Löschen</button></form>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -360,7 +511,7 @@ $adminBase = '';
|
||||||
<label>Cron-URL</label>
|
<label>Cron-URL</label>
|
||||||
<div style="display:flex;gap:10px;">
|
<div style="display:flex;gap:10px;">
|
||||||
<input type="text" id="cronUrl" value="<?= htmlspecialchars($cronUrl) ?>" readonly>
|
<input type="text" id="cronUrl" value="<?= htmlspecialchars($cronUrl) ?>" readonly>
|
||||||
<button onclick="copyToClipboard(document.getElementById('cronUrl').value)" class="btn btn-secondary"><i class="fas fa-copy"></i></button>
|
<button onclick="copyToClipboard(document.getElementById('cronUrl').value)" class="btn btn-secondary"><i class="fas fa-copy" aria-hidden="true"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="placeholder-info" style="background: var(--bg-hover); border-color: var(--border-color);">
|
<div class="placeholder-info" style="background: var(--bg-hover); border-color: var(--border-color);">
|
||||||
|
|
@ -371,7 +522,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
<?php if ($cs['cron_token']): ?>
|
<?php if ($cs['cron_token']): ?>
|
||||||
<div style="margin-top: 20px;">
|
<div style="margin-top: 20px;">
|
||||||
<button onclick="testCronJob()" class="btn btn-primary" id="testCronBtn"><i class="fas fa-play"></i> Jetzt ausführen</button>
|
<button onclick="testCronJob()" class="btn btn-primary" id="testCronBtn"><i class="fas fa-play" aria-hidden="true"></i> Jetzt ausführen</button>
|
||||||
<span id="testCronResult" style="margin-left: 15px;"></span>
|
<span id="testCronResult" style="margin-left: 15px;"></span>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
@ -380,9 +531,9 @@ $adminBase = '';
|
||||||
|
|
||||||
<!-- M365 -->
|
<!-- M365 -->
|
||||||
<div id="tab-m365" class="tab-content">
|
<div id="tab-m365" class="tab-content">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fab fa-microsoft"></i> Microsoft 365</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fab fa-microsoft" aria-hidden="true"></i> Microsoft 365</h2>
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4><i class="fas fa-info-circle"></i> Azure AD App</h4>
|
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> Azure AD App</h4>
|
||||||
<p>Redirect URI: <strong><?= $protocol . '://' . $host . $scriptPath ?>/m365_callback.php</strong></p>
|
<p>Redirect URI: <strong><?= $protocol . '://' . $host . $scriptPath ?>/m365_callback.php</strong></p>
|
||||||
</div>
|
</div>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
|
@ -391,13 +542,13 @@ $adminBase = '';
|
||||||
<div class="form-group"><label>Client ID</label><input type="text" name="m365_client_id" value="<?= htmlspecialchars($cs['m365_client_id']) ?>"></div>
|
<div class="form-group"><label>Client ID</label><input type="text" name="m365_client_id" value="<?= htmlspecialchars($cs['m365_client_id']) ?>"></div>
|
||||||
<div class="form-group"><label>Client Secret</label><input type="password" name="m365_client_secret" value="<?= htmlspecialchars($cs['m365_client_secret']) ?>"></div>
|
<div class="form-group"><label>Client Secret</label><input type="password" name="m365_client_secret" value="<?= htmlspecialchars($cs['m365_client_secret']) ?>"></div>
|
||||||
<div class="form-group"><label>Tenant ID</label><input type="text" name="m365_tenant_id" value="<?= htmlspecialchars($cs['m365_tenant_id']) ?>"></div>
|
<div class="form-group"><label>Tenant ID</label><input type="text" name="m365_tenant_id" value="<?= htmlspecialchars($cs['m365_tenant_id']) ?>"></div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SMTP -->
|
<!-- SMTP -->
|
||||||
<div id="tab-smtp" class="tab-content">
|
<div id="tab-smtp" class="tab-content<?= $activeTab === 'smtp' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope"></i> SMTP</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope" aria-hidden="true"></i> SMTP</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="smtp">
|
<input type="hidden" name="form_type" value="smtp">
|
||||||
|
|
@ -409,60 +560,60 @@ $adminBase = '';
|
||||||
<div class="form-group"><label>Verschlüsselung</label><select name="smtp_encryption"><option value="tls" <?= $cs['smtp_encryption']==='tls'?'selected':'' ?>>TLS</option><option value="ssl" <?= $cs['smtp_encryption']==='ssl'?'selected':'' ?>>SSL</option><option value="none" <?= $cs['smtp_encryption']==='none'?'selected':'' ?>>Keine</option></select></div>
|
<div class="form-group"><label>Verschlüsselung</label><select name="smtp_encryption"><option value="tls" <?= $cs['smtp_encryption']==='tls'?'selected':'' ?>>TLS</option><option value="ssl" <?= $cs['smtp_encryption']==='ssl'?'selected':'' ?>>SSL</option><option value="none" <?= $cs['smtp_encryption']==='none'?'selected':'' ?>>Keine</option></select></div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group"><label>Benutzername</label><input type="text" name="smtp_username" value="<?= htmlspecialchars($cs['smtp_username']) ?>"></div>
|
<div class="form-group"><label>Benutzername</label><input type="text" name="smtp_username" value="<?= htmlspecialchars($cs['smtp_username']) ?>"></div>
|
||||||
<div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="Leer = nicht ändern"></div>
|
<div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="<?= __('settings_leave_empty') ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group"><label>Absender E-Mail</label><input type="email" name="smtp_from_email" value="<?= htmlspecialchars($cs['smtp_from_email']) ?>"></div>
|
<div class="form-group"><label>Absender E-Mail</label><input type="email" name="smtp_from_email" value="<?= htmlspecialchars($cs['smtp_from_email']) ?>"></div>
|
||||||
<div class="form-group"><label>Absender Name</label><input type="text" name="smtp_from_name" value="<?= htmlspecialchars($cs['smtp_from_name']) ?>"></div>
|
<div class="form-group"><label><?= __('settings_smtp_from_name') ?></label><input type="text" name="smtp_from_name" value="<?= htmlspecialchars($cs['smtp_from_name']) ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">SMTP testen</h3>
|
<h3 style="margin-bottom:15px; color: var(--text-primary);">SMTP testen</h3>
|
||||||
<div style="display:flex;gap:10px;align-items:flex-end;">
|
<div style="display:flex;gap:10px;align-items:flex-end;">
|
||||||
<div style="flex:1;"><label>Test-E-Mail senden an</label><input type="email" id="smtpTestEmail" placeholder="empfaenger@example.com" style="margin-top:6px;"></div>
|
<div style="flex:1;"><label>Test-E-Mail senden an</label><input type="email" id="smtpTestEmail" placeholder="empfaenger@example.com" style="margin-top:6px;"></div>
|
||||||
<button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn"><i class="fas fa-paper-plane"></i> <?= __('btn_test') ?></button>
|
<button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn"><i class="fas fa-paper-plane" aria-hidden="true"></i> <?= __('btn_test') ?></button>
|
||||||
</div>
|
</div>
|
||||||
<span id="smtpTestResult" style="display:block;margin-top:10px;font-size:13px;"></span>
|
<span id="smtpTestResult" style="display:block;margin-top:10px;font-size:13px;"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- E-Mail Templates -->
|
<!-- E-Mail Templates -->
|
||||||
<div id="tab-templates_email" class="tab-content">
|
<div id="tab-templates_email" class="tab-content<?= $activeTab === 'templates_email' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt"></i> E-Mail Templates</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt" aria-hidden="true"></i> E-Mail Templates</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="templates">
|
<input type="hidden" name="form_type" value="templates">
|
||||||
<div class="form-group"><label>System-URL</label><input type="url" name="system_url" value="<?= htmlspecialchars($cs['system_url']) ?>"><div class="help-text">Auto: <code><?= $autoDetectedUrl ?></code></div></div>
|
<div class="form-group"><label>System-URL</label><input type="url" name="system_url" value="<?= htmlspecialchars($cs['system_url']) ?>"><div class="help-text">Auto: <code><?= $autoDetectedUrl ?></code></div></div>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">Voucher E-Mail</h3>
|
<h3 style="margin-bottom:15px; color: var(--text-primary);"><?= __('settings_tpl_voucher_mail') ?></h3>
|
||||||
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{VOUCHER_CODE}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div></div>
|
<div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{VOUCHER_CARD}</code><code>{VOUCHER_CODE}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div><p class="help-text"><?= __('settings_card_hint') ?></p></div>
|
||||||
<div class="form-group"><label>Betreff</label><input type="text" name="email_voucher_subject" value="<?= htmlspecialchars($cs['email_voucher_subject']) ?>"></div>
|
<div class="form-group"><label>Betreff</label><input type="text" name="email_voucher_subject" value="<?= htmlspecialchars($cs['email_voucher_subject']) ?>"></div>
|
||||||
<div class="form-group"><label>E-Mail Text</label><textarea name="email_voucher_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_voucher_body']) ?></textarea></div>
|
<div class="form-group"><label>E-Mail Text</label><textarea name="email_voucher_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_voucher_body']) ?></textarea></div>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">Benutzer-Benachrichtigung</h3>
|
<h3 style="margin-bottom:15px; color: var(--text-primary);"><?= __('settings_tpl_user_notify') ?></h3>
|
||||||
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{USER_NAME}</code><code>{CHANGES}</code><code>{APP_TITLE}</code><code>{SYSTEM_URL}</code></div></div>
|
<div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{USER_NAME}</code><code>{CHANGES}</code><code>{APP_TITLE}</code><code>{SYSTEM_URL}</code></div></div>
|
||||||
<div class="form-group"><label>Betreff</label><input type="text" name="email_user_notification_subject" value="<?= htmlspecialchars($cs['email_user_notification_subject']) ?>"></div>
|
<div class="form-group"><label>Betreff</label><input type="text" name="email_user_notification_subject" value="<?= htmlspecialchars($cs['email_user_notification_subject']) ?>"></div>
|
||||||
<div class="form-group"><label>E-Mail Text</label><textarea name="email_user_notification_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_user_notification_body']) ?></textarea></div>
|
<div class="form-group"><label>E-Mail Text</label><textarea name="email_user_notification_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_user_notification_body']) ?></textarea></div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System -->
|
<!-- System -->
|
||||||
<div id="tab-system" class="tab-content">
|
<div id="tab-system" class="tab-content<?= $activeTab === 'system' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs"></i> System & Erweitert</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs" aria-hidden="true"></i> System & Erweitert</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="system">
|
<input type="hidden" name="form_type" value="system">
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4><i class="fas fa-info-circle"></i> TinyMCE API Key</h4>
|
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> WYSIWYG-Editor</h4>
|
||||||
<p>Kostenlosen API Key: <a href="https://www.tiny.cloud/auth/signup/" target="_blank" rel="noopener" style="color:#0066cc;">tiny.cloud/signup</a></p>
|
<p><?= __('settings_editor_hint') ?> <code>assets/vendor/</code> <?= __('settings_editor_hint2') ?></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label>TinyMCE API Key</label><input type="text" name="tinymce_api_key" value="<?= htmlspecialchars($cs['tinymce_api_key']) ?>" placeholder="your-api-key-here"><div class="help-text">Für WYSIWYG-Editor in Anleitungen</div></div>
|
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">Druck-Template</h3>
|
<h3 style="margin-bottom:15px; color: var(--text-primary);">Druck-Template</h3>
|
||||||
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{VOUCHER_CODE}</code><code>{EXPIRY_DATE}</code><code>{EXPIRY_TIME}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div></div>
|
<div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{QR_CODE}</code><code>{VOUCHER_CODE}</code><code>{EXPIRY_DATE}</code><code>{EXPIRY_TIME}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div><p class="help-text"><?= __('settings_qr_hint') ?></p></div>
|
||||||
<div class="form-group"><label>HTML Template für Voucher-Druck</label><textarea name="print_template" class="tinymce-editor" style="min-height:250px;"><?= htmlspecialchars($cs['print_template']) ?></textarea></div>
|
<div class="form-group"><label><?= __('settings_print_template') ?></label><textarea name="print_template" class="tinymce-editor" style="min-height:250px;"><?= htmlspecialchars($cs['print_template']) ?></textarea></div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">System-Information</h3>
|
<h3 style="margin-bottom:15px; color: var(--text-primary);">System-Information</h3>
|
||||||
|
|
@ -474,19 +625,19 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Passwort -->
|
<!-- Passwort -->
|
||||||
<div id="tab-password" class="tab-content">
|
<div id="tab-password" class="tab-content<?= $activeTab === 'password' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key"></i> <?= __('settings_tab_password') ?></h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></h2>
|
||||||
<form method="post" style="max-width:500px;">
|
<form method="post" style="max-width:500px;">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<div class="form-group"><label><?= __('settings_pw_current') ?></label><input type="password" name="current_password" required></div>
|
<div class="form-group"><label><?= __('settings_pw_current') ?></label><input type="password" name="current_password" required></div>
|
||||||
<div class="form-group"><label><?= __('settings_pw_new') ?></label><input type="password" name="new_password" required minlength="8"><div class="help-text"><?= __('settings_pw_minlength') ?></div></div>
|
<div class="form-group"><label><?= __('settings_pw_new') ?></label><input type="password" name="new_password" required minlength="8"><div class="help-text"><?= __('settings_pw_minlength') ?></div></div>
|
||||||
<div class="form-group"><label><?= __('settings_pw_confirm') ?></label><input type="password" name="confirm_password" required></div>
|
<div class="form-group"><label><?= __('settings_pw_confirm') ?></label><input type="password" name="confirm_password" required></div>
|
||||||
<button type="submit" name="change_password" class="btn btn-primary"><i class="fas fa-lock"></i> <?= __('settings_tab_password') ?></button>
|
<button type="submit" name="change_password" class="btn btn-primary"><i class="fas fa-lock" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- main-content -->
|
</main>
|
||||||
|
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -502,7 +653,43 @@ document.querySelectorAll('.tab-button').forEach(btn => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore tab from hash
|
// Branding-Vorschau live faerben
|
||||||
|
function updateBrandPreview() {
|
||||||
|
const preview = document.getElementById('brandPreview');
|
||||||
|
if (!preview) return;
|
||||||
|
const accent = (document.getElementById('brand_accent') || {}).value || '';
|
||||||
|
const from = (document.getElementById('brand_gradient_from') || {}).value || '';
|
||||||
|
const to = (document.getElementById('brand_gradient_to') || {}).value || '';
|
||||||
|
const radius = (document.getElementById('brand_radius') || {}).value || '14';
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(accent)) {
|
||||||
|
preview.style.setProperty('--accent', accent);
|
||||||
|
preview.style.setProperty('--accent-hover', `color-mix(in srgb, ${accent} 84%, #000)`);
|
||||||
|
preview.style.setProperty('--accent-soft', `color-mix(in srgb, ${accent} 12%, #fff)`);
|
||||||
|
preview.style.setProperty('--accent-border', `color-mix(in srgb, ${accent} 32%, #fff)`);
|
||||||
|
}
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(from) && /^#[0-9a-fA-F]{6}$/.test(to)) {
|
||||||
|
preview.style.setProperty('--brand-gradient', `linear-gradient(135deg, ${from} 0%, ${to} 100%)`);
|
||||||
|
}
|
||||||
|
preview.style.setProperty('--r-lg', radius + 'px');
|
||||||
|
}
|
||||||
|
['brand_accent', 'brand_gradient_from', 'brand_gradient_to', 'brand_radius'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener('input', updateBrandPreview);
|
||||||
|
if (el) el.addEventListener('change', updateBrandPreview);
|
||||||
|
});
|
||||||
|
updateBrandPreview();
|
||||||
|
|
||||||
|
// Farbwähler und Hex-Feld synchron halten
|
||||||
|
document.querySelectorAll('.color-swatch').forEach(swatch => {
|
||||||
|
const field = document.getElementById(swatch.dataset.target);
|
||||||
|
if (!field) return;
|
||||||
|
swatch.addEventListener('input', () => { field.value = swatch.value; updateBrandPreview(); });
|
||||||
|
field.addEventListener('input', () => {
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(field.value.trim())) swatch.value = field.value.trim();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab aus Anker uebernehmen (der Query-Parameter wird serverseitig gesetzt)
|
||||||
window.addEventListener('DOMContentLoaded', function() {
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
const hash = location.hash.substring(1);
|
const hash = location.hash.substring(1);
|
||||||
if (hash) {
|
if (hash) {
|
||||||
|
|
@ -516,9 +703,9 @@ async function testSmtp() {
|
||||||
const email = document.getElementById('smtpTestEmail').value.trim();
|
const email = document.getElementById('smtpTestEmail').value.trim();
|
||||||
const btn = document.getElementById('smtpTestBtn');
|
const btn = document.getElementById('smtpTestBtn');
|
||||||
const result = document.getElementById('smtpTestResult');
|
const result = document.getElementById('smtpTestResult');
|
||||||
if (!email) { result.textContent = 'Bitte E-Mail eingeben.'; return; }
|
if (!email) { result.textContent = '<?= __('js_enter_email') ?>'; return; }
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>';
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('ajax_smtp_test', '1');
|
fd.append('ajax_smtp_test', '1');
|
||||||
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
|
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
|
||||||
|
|
@ -528,38 +715,52 @@ async function testSmtp() {
|
||||||
result.textContent = data.message;
|
result.textContent = data.message;
|
||||||
result.style.color = data.success ? 'var(--success)' : 'var(--danger)';
|
result.style.color = data.success ? 'var(--success)' : 'var(--danger)';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Testen';
|
btn.innerHTML = '<i class="fas fa-paper-plane" aria-hidden="true"></i> Testen';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testCronJob() {
|
async function testCronJob() {
|
||||||
const btn = document.getElementById('testCronBtn');
|
const btn = document.getElementById('testCronBtn');
|
||||||
const result = document.getElementById('testCronResult');
|
const result = document.getElementById('testCronResult');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Läuft...';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= __('js_running') ?>';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('../cron_sync.php?token=<?= htmlspecialchars($cs['cron_token']) ?>');
|
const res = await fetch('../cron_sync.php?token=<?= htmlspecialchars($cs['cron_token']) ?>');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
result.innerHTML = data.success
|
result.innerHTML = data.success
|
||||||
? `<span style="color:var(--success)"><i class="fas fa-check-circle"></i> ${data.message}</span>`
|
? `<span style="color:var(--success)"><i class="fas fa-check-circle" aria-hidden="true"></i> ${data.message}</span>`
|
||||||
: `<span style="color:var(--danger)"><i class="fas fa-times-circle"></i> ${data.message}</span>`;
|
: `<span style="color:var(--danger)"><i class="fas fa-times-circle" aria-hidden="true"></i> ${data.message}</span>`;
|
||||||
if (data.success) showToast('success', 'Cron ausgeführt', data.message);
|
if (data.success) showToast('success', 'Cron ausgeführt', data.message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result.innerHTML = `<span style="color:var(--danger)">Fehler: ${e.message}</span>`;
|
result.innerHTML = `<span style="color:var(--danger)"><?= __('js_error') ?>: ${e.message}</span>`;
|
||||||
}
|
}
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fas fa-play"></i> Jetzt ausführen';
|
btn.innerHTML = '<i class="fas fa-play" aria-hidden="true"></i> <?= __('js_run_now') ?>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTinyMCE() {
|
function initTinyMCE() {
|
||||||
tinymce.init({
|
if (typeof tinymce === 'undefined') return;
|
||||||
|
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
const config = {
|
||||||
selector: '.tinymce-editor',
|
selector: '.tinymce-editor',
|
||||||
height: 350,
|
height: 350,
|
||||||
menubar: false,
|
menubar: false,
|
||||||
plugins: ['advlist','autolink','lists','link','searchreplace','visualblocks','code','fullscreen','table','help','wordcount'],
|
plugins: ['advlist','autolink','lists','link','searchreplace','visualblocks','code','fullscreen','table','help','wordcount'],
|
||||||
toolbar: 'undo redo | blocks | bold italic forecolor | alignleft aligncenter alignright | bullist numlist | removeformat | help',
|
toolbar: 'undo redo | blocks | bold italic forecolor | alignleft aligncenter alignright | bullist numlist | removeformat | help',
|
||||||
content_style: 'body { font-family: -apple-system, sans-serif; font-size: 14px; line-height: 1.6; }',
|
content_style: "body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.6; }",
|
||||||
|
skin: dark ? 'oxide-dark' : 'oxide',
|
||||||
|
content_css: dark ? 'dark' : 'default',
|
||||||
branding: false, promotion: false
|
branding: false, promotion: false
|
||||||
});
|
};
|
||||||
|
if (window.TINYMCE_BASE_URL) {
|
||||||
|
tinymce.baseURL = window.TINYMCE_BASE_URL;
|
||||||
|
config.base_url = window.TINYMCE_BASE_URL;
|
||||||
|
config.suffix = '.min';
|
||||||
|
if (document.documentElement.lang === 'de') {
|
||||||
|
config.language = 'de';
|
||||||
|
config.language_url = window.TINYMCE_BASE_URL + '/langs/de.js';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tinymce.init(config);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -101,68 +101,24 @@ $currentPage = 'sites';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('sites_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('sites_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
|
||||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; }
|
|
||||||
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
|
|
||||||
.alert { padding: 13px 18px; border-radius: 10px; font-size: 14px; margin-bottom: 20px; }
|
|
||||||
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
|
|
||||||
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
|
|
||||||
.sites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 18px; }
|
|
||||||
.site-card { background: var(--bg-card); border: 2px solid var(--border-color); border-radius: 14px; padding: 20px; transition: border-color .2s, box-shadow .2s; }
|
|
||||||
.site-card:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(102,126,234,.15); }
|
|
||||||
.site-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 14px; }
|
|
||||||
.site-name { font-size: 17px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
|
|
||||||
.site-id-label { font-size: 12px; color: var(--text-muted); font-family: monospace; }
|
|
||||||
.site-info { margin: 14px 0; font-size: 13px; color: var(--text-secondary); }
|
|
||||||
.site-info-item { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
|
|
||||||
.site-actions { display: flex; gap: 7px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color); flex-wrap: wrap; }
|
|
||||||
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; margin: 2px; }
|
|
||||||
.badge-success { background: #d4edda; color: #155724; }
|
|
||||||
.badge-warning { background: #fff3cd; color: #856404; }
|
|
||||||
.badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
|
|
||||||
.btn { padding: 8px 15px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
|
|
||||||
.btn-primary { background: var(--accent); color: white; }
|
|
||||||
.btn-primary:hover { background: var(--accent-hover); }
|
|
||||||
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
|
||||||
.btn-secondary:hover { background: var(--border-color); }
|
|
||||||
.btn-danger { background: var(--danger); color: white; }
|
|
||||||
.btn-success { background: var(--success); color: white; }
|
|
||||||
.btn-sm { padding: 6px 11px; font-size: 12px; }
|
|
||||||
.modal { display: none; position: fixed; inset: 0; background: var(--modal-overlay); z-index: 1000; align-items: center; justify-content: center; }
|
|
||||||
.modal.active { display: flex; }
|
|
||||||
.modal-content { background: var(--bg-card); border-radius: 14px; max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border-color); }
|
|
||||||
.modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.modal-title { font-size: 19px; font-weight: 600; color: var(--text-primary); }
|
|
||||||
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
|
|
||||||
.modal-body { padding: 22px 25px; }
|
|
||||||
.form-group { margin-bottom: 17px; }
|
|
||||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
||||||
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
|
||||||
input[type="text"], input[type="password"], input[type="url"] { width: 100%; padding: 11px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color .2s; }
|
|
||||||
input:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.checkbox-group { display: flex; align-items: center; gap: 10px; }
|
|
||||||
.checkbox-group input { width: auto; }
|
|
||||||
.empty-card { background: var(--bg-card); border-radius: 14px; border: 1px solid var(--border-color); padding: 60px 20px; text-align: center; color: var(--text-muted); }
|
|
||||||
@media(max-width:768px){ .main-content{ margin-left:0!important; } .form-grid{ grid-template-columns:1fr; } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title"><?= __('sites_title') ?></h1>
|
<h1 class="page-title"><?= __('sites_title') ?></h1>
|
||||||
<button onclick="openModal()" class="btn btn-primary">
|
<button onclick="openModal()" class="btn btn-primary">
|
||||||
<i class="fas fa-plus"></i> <?= __('sites_add') ?>
|
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
|
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($success): ?>
|
<?php if ($success): ?>
|
||||||
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
|
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (empty($sites)): ?>
|
<?php if (empty($sites)): ?>
|
||||||
<div class="empty-card">
|
<div class="empty-card">
|
||||||
<i class="fas fa-map-marker-alt" style="font-size:48px;margin-bottom:20px;opacity:.3;display:block;"></i>
|
<i class="fas fa-map-marker-alt" style="font-size:48px;margin-bottom:20px;opacity:.3;display:block;" aria-hidden="true"></i>
|
||||||
<p><?= __('sites_none') ?></p>
|
<p><?= __('sites_none') ?></p>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
@ -176,43 +132,43 @@ $currentPage = 'sites';
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<?php if ($site['is_active']): ?>
|
<?php if ($site['is_active']): ?>
|
||||||
<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span>
|
<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_active') ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span>
|
<span class="badge badge-warning"><i class="fas fa-pause" aria-hidden="true"></i> <?= __('status_inactive') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($site['public_access']): ?>
|
<?php if ($site['public_access']): ?>
|
||||||
<span class="badge badge-info"><i class="fas fa-globe"></i> <?= __('status_public') ?></span>
|
<span class="badge badge-info"><i class="fas fa-globe" aria-hidden="true"></i> <?= __('status_public') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-info">
|
<div class="site-info">
|
||||||
<div class="site-info-item">
|
<div class="site-info-item">
|
||||||
<i class="fas fa-server" style="color:var(--accent);width:16px;"></i>
|
<i class="fas fa-server" style="color:var(--accent);width:16px;" aria-hidden="true"></i>
|
||||||
<span style="word-break:break-all;"><?= htmlspecialchars($site['unifi_controller_url']) ?></span>
|
<span style="word-break:break-all;"><?= htmlspecialchars($site['unifi_controller_url']) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-info-item">
|
<div class="site-info-item">
|
||||||
<i class="fas fa-user" style="color:var(--accent);width:16px;"></i>
|
<i class="fas fa-user" style="color:var(--accent);width:16px;" aria-hidden="true"></i>
|
||||||
<span><?= htmlspecialchars($site['unifi_username']) ?></span>
|
<span><?= htmlspecialchars($site['unifi_username']) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-info-item">
|
<div class="site-info-item">
|
||||||
<i class="fas fa-clock" style="color:var(--text-muted);width:16px;"></i>
|
<i class="fas fa-clock" style="color:var(--text-muted);width:16px;" aria-hidden="true"></i>
|
||||||
<span style="color:var(--text-muted);"><?= date('d.m.Y', strtotime($site['created_at'])) ?></span>
|
<span style="color:var(--text-muted);"><?= date('d.m.Y', strtotime($site['created_at'])) ?></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-actions">
|
<div class="site-actions">
|
||||||
<button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>)"
|
<button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>)"
|
||||||
class="btn btn-secondary btn-sm">
|
class="btn btn-secondary btn-sm">
|
||||||
<i class="fas fa-edit"></i> <?= __('btn_edit') ?>
|
<i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?>
|
||||||
</button>
|
</button>
|
||||||
<a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||||
class="btn btn-secondary btn-sm">
|
class="btn btn-secondary btn-sm">
|
||||||
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>"></i>
|
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i>
|
||||||
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
|
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
|
||||||
</a>
|
</a>
|
||||||
<a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||||
class="btn btn-danger btn-sm"
|
class="btn btn-danger-soft btn-sm"
|
||||||
onclick="return confirm('Möchten Sie diese Site wirklich löschen?')">
|
onclick="return confirm('<?= __('js_confirm_delete_site') ?>')">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -220,7 +176,7 @@ $currentPage = 'sites';
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</div><!-- /main-content -->
|
</main>
|
||||||
|
|
||||||
<!-- Add Site Modal -->
|
<!-- Add Site Modal -->
|
||||||
<div id="addSiteModal" class="modal">
|
<div id="addSiteModal" class="modal">
|
||||||
|
|
@ -240,7 +196,7 @@ $currentPage = 'sites';
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><?= __('sites_site_id') ?></label>
|
<label><?= __('sites_site_id') ?></label>
|
||||||
<input type="text" name="site_id" required placeholder="z.B. default">
|
<input type="text" name="site_id" required placeholder="z.B. default">
|
||||||
<small style="color:var(--text-muted);font-size:12px;">Zu finden in der UniFi Controller URL</small>
|
<small style="color:var(--text-muted);font-size:12px;"><?= __('sites_id_hint') ?></small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><?= __('sites_controller') ?></label>
|
<label><?= __('sites_controller') ?></label>
|
||||||
|
|
@ -263,7 +219,7 @@ $currentPage = 'sites';
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn">
|
<button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn">
|
||||||
<i class="fas fa-save"></i> <?= __('sites_add') ?>
|
<i class="fas fa-save" aria-hidden="true"></i> <?= __('sites_add') ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="closeModal('addSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('addSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -303,7 +259,7 @@ $currentPage = 'sites';
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><?= __('sites_password_edit') ?></label>
|
<label><?= __('sites_password_edit') ?></label>
|
||||||
<input type="password" id="edit_password" name="password" placeholder="Leer lassen = nicht ändern">
|
<input type="password" id="edit_password" name="password" placeholder="<?= __('sites_pw_unchanged') ?>">
|
||||||
<small style="color:var(--text-muted);font-size:12px;"><?= __('sites_password_hint') ?></small>
|
<small style="color:var(--text-muted);font-size:12px;"><?= __('sites_password_hint') ?></small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -313,7 +269,7 @@ $currentPage = 'sites';
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn">
|
<button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn">
|
||||||
<i class="fas fa-save"></i> <?= __('btn_save') ?>
|
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="closeModal('editSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('editSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -322,7 +278,7 @@ $currentPage = 'sites';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function openModal() { document.getElementById('addSiteModal').classList.add('active'); }
|
function openModal() { document.getElementById('addSiteModal').classList.add('active'); }
|
||||||
|
|
@ -342,12 +298,12 @@ function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAcces
|
||||||
document.getElementById('addSiteForm').addEventListener('submit', function() {
|
document.getElementById('addSiteForm').addEventListener('submit', function() {
|
||||||
const btn = document.getElementById('addSiteSubmitBtn');
|
const btn = document.getElementById('addSiteSubmitBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= addslashes(__('sites_testing')) ?>';
|
||||||
});
|
});
|
||||||
document.getElementById('editSiteForm').addEventListener('submit', function() {
|
document.getElementById('editSiteForm').addEventListener('submit', function() {
|
||||||
const btn = document.getElementById('editSiteSubmitBtn');
|
const btn = document.getElementById('editSiteSubmitBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= addslashes(__('sites_testing')) ?>';
|
||||||
});
|
});
|
||||||
|
|
||||||
['addSiteModal','editSiteModal'].forEach(id => {
|
['addSiteModal','editSiteModal'].forEach(id => {
|
||||||
|
|
|
||||||
|
|
@ -100,62 +100,22 @@ $adminBase = '';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('templates_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('templates_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
|
||||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; flex-wrap: wrap; gap: 15px; }
|
|
||||||
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); }
|
|
||||||
.alert { padding: 14px 20px; border-radius: 10px; margin-bottom: 25px; font-size: 14px; }
|
|
||||||
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
|
|
||||||
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
|
|
||||||
.card { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 25px; }
|
|
||||||
.card-header { padding: 20px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.card-title { font-size: 18px; font-weight: 600; color: var(--text-primary); }
|
|
||||||
.table { width: 100%; border-collapse: collapse; }
|
|
||||||
.table th { text-align: left; padding: 12px 15px; background: var(--bg-table-head); color: var(--text-secondary); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
||||||
.table td { padding: 14px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
|
|
||||||
.table tr:last-child td { border-bottom: none; }
|
|
||||||
.table tr:hover td { background: var(--bg-hover); }
|
|
||||||
.badge { display: inline-block; padding: 3px 10px; border-radius: 6px; font-size: 11px; font-weight: 500; }
|
|
||||||
.badge-success { background: #d4edda; color: #155724; }
|
|
||||||
.badge-secondary { background: var(--bg-hover); color: var(--text-muted); }
|
|
||||||
.btn-primary { background: var(--accent); color: white; }
|
|
||||||
.btn-primary:hover { background: var(--accent-hover); }
|
|
||||||
.btn-danger { background: var(--danger); color: white; }
|
|
||||||
.btn-small { padding: 6px 12px; font-size: 12px; }
|
|
||||||
.modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--modal-overlay); z-index: 500; align-items: center; justify-content: center; }
|
|
||||||
.modal.active { display: flex; }
|
|
||||||
.modal-content { background: var(--bg-card); border-radius: 15px; max-width: 520px; width: 90%; max-height: 90vh; overflow-y: auto; }
|
|
||||||
.modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.modal-title { font-size: 18px; font-weight: 600; color: var(--text-primary); }
|
|
||||||
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
|
|
||||||
.modal-body { padding: 25px; }
|
|
||||||
.form-group { margin-bottom: 18px; }
|
|
||||||
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
|
||||||
input[type="text"], input[type="number"], textarea, select { width: 100%; padding: 11px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color 0.2s; font-family: inherit; }
|
|
||||||
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.checkbox-group { display: flex; align-items: center; gap: 10px; }
|
|
||||||
.checkbox-group input { width: auto; accent-color: var(--accent); }
|
|
||||||
.help-text { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
|
||||||
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
|
||||||
.empty-state i { font-size: 48px; margin-bottom: 20px; opacity: 0.3; display: block; }
|
|
||||||
.duration-badge { display: inline-flex; align-items: center; gap: 5px; background: var(--bg-hover); padding: 3px 10px; border-radius: 20px; font-size: 12px; color: var(--text-secondary); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title"><?= __('templates_title') ?></h1>
|
<h1 class="page-title"><?= __('templates_title') ?></h1>
|
||||||
<p style="color: var(--text-muted); font-size: 14px; margin-top: 5px;"><?= __('templates_subtitle') ?></p>
|
<p class="page-subtitle"><?= __('templates_subtitle') ?></p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="openAddModal()" class="btn btn-primary">
|
<button onclick="openAddModal()" class="btn btn-primary">
|
||||||
<i class="fas fa-plus"></i> <?= __('templates_add') ?>
|
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('templates_add') ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
|
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($success): ?>
|
<?php if ($success): ?>
|
||||||
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
|
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -165,14 +125,15 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
<?php if (empty($templates)): ?>
|
<?php if (empty($templates)): ?>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-layer-group"></i>
|
<i class="fas fa-layer-group" aria-hidden="true"></i>
|
||||||
<p style="font-size: 15px; margin-bottom: 8px;"><?= __('templates_none') ?></p>
|
<p style="font-size: 15px; margin-bottom: 8px;"><?= __('templates_none') ?></p>
|
||||||
<p style="font-size: 13px;"><?= __('templates_add_hint') ?></p>
|
<p style="font-size: 13px;"><?= __('templates_add_hint') ?></p>
|
||||||
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top: 20px;"><i class="fas fa-plus"></i> <?= __('templates_add') ?></button>
|
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top: 20px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('templates_add') ?></button>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div style="overflow-x: auto;">
|
<div style="overflow-x: auto;">
|
||||||
<table class="table">
|
<div class="table-container">
|
||||||
|
<table class="table table-stack">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?= __('templates_name') ?></th>
|
<th><?= __('templates_name') ?></th>
|
||||||
|
|
@ -186,11 +147,11 @@ $adminBase = '';
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($templates as $t): ?>
|
<?php foreach ($templates as $t): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong><?= htmlspecialchars($t['name']) ?></strong></td>
|
<td data-label="<?= __('templates_name') ?>"><strong><?= htmlspecialchars($t['name']) ?></strong></td>
|
||||||
<td>
|
<td data-label="<?= __('templates_devices') ?>">
|
||||||
<span class="duration-badge"><i class="fas fa-mobile-alt"></i> <?= (int)$t['max_uses'] ?></span>
|
<span class="duration-badge"><i class="fas fa-mobile-alt" aria-hidden="true"></i> <?= (int)$t['max_uses'] ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="<?= __('templates_duration') ?>">
|
||||||
<?php
|
<?php
|
||||||
$m = (int)$t['expire_minutes'];
|
$m = (int)$t['expire_minutes'];
|
||||||
if ($m >= 1440 && $m % 1440 === 0) {
|
if ($m >= 1440 && $m % 1440 === 0) {
|
||||||
|
|
@ -201,38 +162,39 @@ $adminBase = '';
|
||||||
$durLabel = $m . ' Min.';
|
$durLabel = $m . ' Min.';
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<span class="duration-badge"><i class="fas fa-clock"></i> <?= $durLabel ?></span>
|
<span class="duration-badge"><i class="fas fa-clock" aria-hidden="true"></i> <?= $durLabel ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
|
<td data-label="<?= __('templates_desc') ?>" style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
|
||||||
<td>
|
<td data-label="<?= __('label_status') ?>">
|
||||||
<?php if ($t['is_active']): ?>
|
<?php if ($t['is_active']): ?>
|
||||||
<span class="badge badge-success"><?= __('status_active') ?></span>
|
<span class="badge badge-success"><?= __('status_active') ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge badge-secondary"><?= __('status_inactive') ?></span>
|
<span class="badge badge-secondary"><?= __('status_inactive') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="<?= __('label_actions') ?>">
|
||||||
<button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)"
|
<button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)"
|
||||||
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
|
class="btn btn-secondary btn-small"><i class="fas fa-edit" aria-hidden="true"></i></button>
|
||||||
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||||
onclick="return confirm('Profil wirklich löschen?')"
|
onclick="return confirm('<?= __('js_confirm_delete_template') ?>')"
|
||||||
class="btn btn-danger btn-small"><i class="fas fa-trash"></i></a>
|
class="btn btn-danger-soft btn-small"><i class="fas fa-trash" aria-hidden="true"></i></a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- main-content -->
|
</main>
|
||||||
|
|
||||||
<!-- Modal: Hinzufügen -->
|
<!-- Modal: Hinzufügen -->
|
||||||
<div id="addModal" class="modal">
|
<div id="addModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title"><i class="fas fa-plus-circle" style="color: var(--accent);"></i> <?= __('templates_add') ?></h2>
|
<h2 class="modal-title"><i class="fas fa-plus-circle" style="color: var(--accent);" aria-hidden="true"></i> <?= __('templates_add') ?></h2>
|
||||||
<button class="modal-close" onclick="closeModal('addModal')">×</button>
|
<button class="modal-close" onclick="closeModal('addModal')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
@ -248,17 +210,17 @@ $adminBase = '';
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><?= __('templates_duration') ?> *</label>
|
<label><?= __('templates_duration') ?> *</label>
|
||||||
<input type="number" name="expire_minutes" value="480" min="1" max="525600">
|
<input type="number" name="expire_minutes" value="480" min="1" max="525600">
|
||||||
<div class="help-text">480 = 8 Stunden</div>
|
<div class="help-text"><?= __('templates_minutes_hint') ?></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="Kurze Beschreibung für Ihr Team"></textarea></div>
|
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="<?= __('templates_desc_placeholder') ?>"></textarea></div>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||||
<div class="form-group"><label>Download (kbit/s)</label><input type="number" name="qos_rate_max_down" min="0" placeholder="0 = unbegrenzt"></div>
|
<div class="form-group"><label>Download (kbit/s)</label><input type="number" name="qos_rate_max_down" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
<div class="form-group"><label>Upload (kbit/s)</label><input type="number" name="qos_rate_max_up" min="0" placeholder="0 = unbegrenzt"></div>
|
<div class="form-group"><label>Upload (kbit/s)</label><input type="number" name="qos_rate_max_up" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
<div class="form-group"><label>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" min="0" placeholder="0 = unbegrenzt"></div>
|
<div class="form-group"><label>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||||
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -270,7 +232,7 @@ $adminBase = '';
|
||||||
<div id="editModal" class="modal">
|
<div id="editModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title"><i class="fas fa-edit" style="color: var(--accent);"></i> <?= __('templates_edit') ?></h2>
|
<h2 class="modal-title"><i class="fas fa-edit" style="color: var(--accent);" aria-hidden="true"></i> <?= __('templates_edit') ?></h2>
|
||||||
<button class="modal-close" onclick="closeModal('editModal')">×</button>
|
<button class="modal-close" onclick="closeModal('editModal')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
@ -299,7 +261,7 @@ $adminBase = '';
|
||||||
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
|
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:10px;">
|
<div style="display:flex;gap:10px;">
|
||||||
<button type="submit" name="edit_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="edit_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||||
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -171,75 +171,30 @@ $currentPage = 'users';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('users_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('users_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
|
||||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; }
|
|
||||||
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
|
|
||||||
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 24px; }
|
|
||||||
.card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); }
|
|
||||||
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
|
||||||
.alert { padding: 13px 18px; border-radius: 10px; font-size: 14px; margin-bottom: 20px; }
|
|
||||||
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
|
|
||||||
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
|
|
||||||
.table { width: 100%; border-collapse: collapse; }
|
|
||||||
.table th { text-align: left; padding: 12px 15px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
|
|
||||||
.table td { padding: 13px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
|
|
||||||
.table tr:last-child td { border-bottom: none; }
|
|
||||||
.table tr:hover { background: var(--bg-hover); }
|
|
||||||
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; margin: 2px; }
|
|
||||||
.badge-success { background: #d4edda; color: #155724; }
|
|
||||||
.badge-warning { background: #fff3cd; color: #856404; }
|
|
||||||
.badge-danger { background: #f8d7da; color: #721c24; }
|
|
||||||
.badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
|
|
||||||
.btn { padding: 8px 16px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
|
|
||||||
.btn-primary { background: var(--accent); color: white; }
|
|
||||||
.btn-primary:hover { background: var(--accent-hover); }
|
|
||||||
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
|
||||||
.btn-secondary:hover { background: var(--border-color); }
|
|
||||||
.btn-danger { background: var(--danger); color: white; }
|
|
||||||
.btn-warning { background: var(--warning); color: #333; }
|
|
||||||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
|
||||||
.modal { display: none; position: fixed; inset: 0; background: var(--modal-overlay); z-index: 1000; align-items: center; justify-content: center; }
|
|
||||||
.modal.active { display: flex; }
|
|
||||||
.modal-content { background: var(--bg-card); border-radius: 14px; max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border-color); }
|
|
||||||
.modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.modal-title { font-size: 19px; font-weight: 600; color: var(--text-primary); }
|
|
||||||
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
|
|
||||||
.modal-body { padding: 22px 25px; }
|
|
||||||
.form-group { margin-bottom: 18px; }
|
|
||||||
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
|
||||||
input[type="text"], input[type="email"], input[type="password"] { width: 100%; padding: 11px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color .2s; }
|
|
||||||
input:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
|
||||||
.checkbox-group input { width: auto; }
|
|
||||||
.site-selection { border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; max-height: 180px; overflow-y: auto; background: var(--bg-hover); }
|
|
||||||
.empty-state { text-align: center; padding: 50px 20px; color: var(--text-muted); }
|
|
||||||
.empty-state i { font-size: 40px; margin-bottom: 15px; opacity: .3; display: block; }
|
|
||||||
.action-btns { display: flex; gap: 5px; flex-wrap: wrap; }
|
|
||||||
@media(max-width:768px){ .main-content{ margin-left:0!important; } .table th:nth-child(5),.table td:nth-child(5){ display:none; } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title"><?= __('users_title') ?></h1>
|
<h1 class="page-title"><?= __('users_title') ?></h1>
|
||||||
<button onclick="openModal()" class="btn btn-primary">
|
<button onclick="openModal()" class="btn btn-primary">
|
||||||
<i class="fas fa-plus"></i> <?= __('users_add') ?>
|
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('users_add') ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
|
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($success): ?>
|
<?php if ($success): ?>
|
||||||
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
|
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h2 class="card-title"><?= __('users_all') ?></h2></div>
|
<div class="card-header"><h2 class="card-title"><?= __('users_all') ?></h2></div>
|
||||||
<div class="card-body" style="padding:0;">
|
<div class="card-body" style="padding:0;">
|
||||||
<?php if (empty($users)): ?>
|
<?php if (empty($users)): ?>
|
||||||
<div class="empty-state"><i class="fas fa-users"></i><p><?= __('users_none_found') ?></p></div>
|
<div class="empty-state"><i class="fas fa-users" aria-hidden="true"></i><p><?= __('users_none_found') ?></p></div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="table">
|
<div class="table-container">
|
||||||
|
<table class="table table-stack">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?= __('label_name') ?></th>
|
<th><?= __('label_name') ?></th>
|
||||||
|
|
@ -255,28 +210,28 @@ $currentPage = 'users';
|
||||||
<?php foreach ($users as $user): ?>
|
<?php foreach ($users as $user): ?>
|
||||||
<?php $userSiteIds = array_column($userSiteAccess[$user['id']]??[], 'id'); ?>
|
<?php $userSiteIds = array_column($userSiteAccess[$user['id']]??[], 'id'); ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td data-label="<?= __('label_name') ?>">
|
||||||
<strong><?= htmlspecialchars($user['name']) ?></strong>
|
<strong><?= htmlspecialchars($user['name']) ?></strong>
|
||||||
<?php if ($user['id'] == $_SESSION['user_id']): ?>
|
<?php if ($user['id'] == $_SESSION['user_id']): ?>
|
||||||
<span class="badge badge-info"><?= __('users_you') ?></span>
|
<span class="badge badge-info"><?= __('users_you') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td><?= htmlspecialchars($user['email']) ?></td>
|
<td data-label="<?= __('label_email') ?>"><?= htmlspecialchars($user['email']) ?></td>
|
||||||
<td>
|
<td data-label="<?= __('label_role') ?>">
|
||||||
<?php if ($user['is_admin']): ?>
|
<?php if ($user['is_admin']): ?>
|
||||||
<span class="badge badge-danger"><i class="fas fa-crown"></i> <?= __('status_admin') ?></span>
|
<span class="badge badge-danger"><i class="fas fa-crown" aria-hidden="true"></i> <?= __('status_admin') ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge badge-info"><?= __('status_user') ?></span>
|
<span class="badge badge-info"><?= __('status_user') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="<?= __('label_status') ?>">
|
||||||
<?php if ($user['is_active']): ?>
|
<?php if ($user['is_active']): ?>
|
||||||
<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span>
|
<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_active') ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span>
|
<span class="badge badge-warning"><i class="fas fa-pause" aria-hidden="true"></i> <?= __('status_inactive') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="<?= __('users_site_access') ?>">
|
||||||
<?php if ($user['is_admin']): ?>
|
<?php if ($user['is_admin']): ?>
|
||||||
<em style="color:var(--text-muted);"><?= __('users_all_sites') ?></em>
|
<em style="color:var(--text-muted);"><?= __('users_all_sites') ?></em>
|
||||||
<?php elseif (!empty($userSiteAccess[$user['id']])): ?>
|
<?php elseif (!empty($userSiteAccess[$user['id']])): ?>
|
||||||
|
|
@ -287,7 +242,7 @@ $currentPage = 'users';
|
||||||
<em style="color:var(--text-muted);"><?= __('users_none') ?></em>
|
<em style="color:var(--text-muted);"><?= __('users_none') ?></em>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:13px;">
|
<td data-label="<?= __('users_last_login') ?>" style="font-size:13px;">
|
||||||
<?php if ($user['last_login']): ?>
|
<?php if ($user['last_login']): ?>
|
||||||
<?= date('d.m.Y H:i', strtotime($user['last_login'])) ?>
|
<?= date('d.m.Y H:i', strtotime($user['last_login'])) ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
@ -298,31 +253,31 @@ $currentPage = 'users';
|
||||||
<div class="action-btns">
|
<div class="action-btns">
|
||||||
<button onclick="openEditModal(<?= $user['id'] ?>, '<?= htmlspecialchars($user['name'], ENT_QUOTES) ?>', <?= $user['is_admin'] ?>, [<?= implode(',', array_map('intval', $userSiteIds)) ?>])"
|
<button onclick="openEditModal(<?= $user['id'] ?>, '<?= htmlspecialchars($user['name'], ENT_QUOTES) ?>', <?= $user['is_admin'] ?>, [<?= implode(',', array_map('intval', $userSiteIds)) ?>])"
|
||||||
class="btn btn-secondary btn-sm" title="<?= __('btn_edit') ?>">
|
class="btn btn-secondary btn-sm" title="<?= __('btn_edit') ?>">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<?php if ($user['id'] != $_SESSION['user_id']): ?>
|
<?php if ($user['id'] != $_SESSION['user_id']): ?>
|
||||||
<a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||||
class="btn btn-secondary btn-sm" title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>">
|
class="btn btn-secondary btn-sm" title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>">
|
||||||
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>"></i>
|
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
<?php if ($smtpEnabled && !empty($user['password_hash'])): ?>
|
<?php if ($smtpEnabled && !empty($user['password_hash'])): ?>
|
||||||
<a href="?send_reset=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<a href="?send_reset=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||||
class="btn btn-warning btn-sm" title="<?= __('users_reset_pw') ?>"
|
class="btn btn-warning btn-sm" title="<?= __('users_reset_pw') ?>"
|
||||||
onclick="return confirm('Passwort-Reset-Link senden an <?= htmlspecialchars($user['email'], ENT_QUOTES) ?>?')">
|
onclick="return confirm('Passwort-Reset-Link senden an <?= htmlspecialchars($user['email'], ENT_QUOTES) ?>?')">
|
||||||
<i class="fas fa-key"></i>
|
<i class="fas fa-key" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($user['totp_enabled'])): ?>
|
<?php if (!empty($user['totp_enabled'])): ?>
|
||||||
<a href="?reset_2fa=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<a href="?reset_2fa=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||||
class="btn btn-secondary btn-sm" title="2FA zurücksetzen"
|
class="btn btn-secondary btn-sm" title="2FA zurücksetzen"
|
||||||
onclick="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
|
onclick="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
|
||||||
<i class="fas fa-user-shield"></i>
|
<i class="fas fa-user-shield" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||||
class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"
|
class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>"
|
||||||
onclick="return confirm('Benutzer wirklich löschen?')">
|
onclick="return confirm('<?= __('js_confirm_delete_user') ?>')">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -332,11 +287,12 @@ $currentPage = 'users';
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /main-content -->
|
</main>
|
||||||
|
|
||||||
<!-- Add User Modal -->
|
<!-- Add User Modal -->
|
||||||
<div id="addUserModal" class="modal">
|
<div id="addUserModal" class="modal">
|
||||||
|
|
@ -386,7 +342,7 @@ $currentPage = 'users';
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" name="add_user" class="btn btn-primary" style="flex:1;">
|
<button type="submit" name="add_user" class="btn btn-primary" style="flex:1;">
|
||||||
<i class="fas fa-save"></i> <?= __('users_save') ?>
|
<i class="fas fa-save" aria-hidden="true"></i> <?= __('users_save') ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="closeModal('addUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('addUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -430,7 +386,7 @@ $currentPage = 'users';
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" name="edit_user" class="btn btn-primary" style="flex:1;">
|
<button type="submit" name="edit_user" class="btn btn-primary" style="flex:1;">
|
||||||
<i class="fas fa-save"></i> <?= __('users_save_edit') ?>
|
<i class="fas fa-save" aria-hidden="true"></i> <?= __('users_save_edit') ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="closeModal('editUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('editUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -439,7 +395,7 @@ $currentPage = 'users';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function openModal() { document.getElementById('addUserModal').classList.add('active'); }
|
function openModal() { document.getElementById('addUserModal').classList.add('active'); }
|
||||||
|
|
|
||||||
|
|
@ -130,82 +130,22 @@ $currentPage = 'vouchers';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('vouchers_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('vouchers_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<style>
|
|
||||||
.page-header { margin-bottom: 28px; }
|
|
||||||
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 12px; }
|
|
||||||
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
|
|
||||||
.live-indicator { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--success); }
|
|
||||||
.live-indicator .dot { width: 8px; height: 8px; background: var(--success); border-radius: 50%; animation: pulse 2s infinite; }
|
|
||||||
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.5} }
|
|
||||||
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); margin-bottom: 22px; overflow: hidden; }
|
|
||||||
.card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
|
||||||
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
|
||||||
.card-body { padding: 22px; }
|
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px,1fr)); gap: 16px; margin-bottom: 22px; }
|
|
||||||
.stat-card { background: var(--bg-card); padding: 18px; border-radius: 12px; box-shadow: 0 2px 8px var(--shadow); border: 1px solid var(--border-color); }
|
|
||||||
.stat-label { color: var(--text-muted); font-size: 13px; margin-bottom: 6px; }
|
|
||||||
.stat-value { font-size: 28px; font-weight: 700; color: var(--text-primary); }
|
|
||||||
.stat-value.valid { color: var(--success); }
|
|
||||||
.stat-value.used { color: var(--warning); }
|
|
||||||
.stat-value.expired { color: var(--danger); }
|
|
||||||
.site-selector { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.site-selector select { padding: 10px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; min-width: 220px; cursor: pointer; background: var(--bg-input); color: var(--text-primary); }
|
|
||||||
.site-selector select:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.search-bar { padding: 10px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); min-width: 200px; }
|
|
||||||
.search-bar:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.btn { padding: 9px 18px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
|
|
||||||
.btn-primary { background: var(--accent); color: white; }
|
|
||||||
.btn-primary:hover { background: var(--accent-hover); }
|
|
||||||
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
|
||||||
.btn-secondary:hover { background: var(--border-color); }
|
|
||||||
.btn-danger { background: var(--danger); color: white; }
|
|
||||||
.btn-danger:hover { opacity: .88; }
|
|
||||||
.btn-success { background: var(--success); color: white; }
|
|
||||||
.btn-sm { padding: 5px 11px; font-size: 12px; }
|
|
||||||
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
|
||||||
.table-container { overflow-x: auto; }
|
|
||||||
.table { width: 100%; border-collapse: collapse; }
|
|
||||||
.table th { text-align: left; padding: 11px 14px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; white-space: nowrap; }
|
|
||||||
.table td { padding: 13px 14px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 13px; }
|
|
||||||
.table tr:last-child td { border-bottom: none; }
|
|
||||||
.table tr:hover { background: var(--bg-hover); }
|
|
||||||
.table tr.deleting { opacity: .45; pointer-events: none; }
|
|
||||||
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; }
|
|
||||||
.badge-success { background: #d4edda; color: #155724; }
|
|
||||||
.badge-warning { background: #fff3cd; color: #856404; }
|
|
||||||
.badge-danger { background: #f8d7da; color: #721c24; }
|
|
||||||
.badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
|
|
||||||
code { background: var(--bg-hover); padding: 4px 8px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; letter-spacing: 1px; }
|
|
||||||
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
|
||||||
.empty-state i { font-size: 42px; margin-bottom: 18px; opacity: .3; display: block; }
|
|
||||||
.loading { display: flex; align-items: center; justify-content: center; padding: 60px; color: var(--text-muted); gap: 12px; }
|
|
||||||
.loading i { font-size: 22px; animation: spin 1s linear infinite; }
|
|
||||||
@keyframes spin { from{transform:rotate(0)}to{transform:rotate(360deg)} }
|
|
||||||
.filter-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
||||||
.filter-btn { padding: 7px 14px; border: 2px solid var(--border-color); background: var(--bg-card); color: var(--text-secondary); border-radius: 8px; cursor: pointer; font-size: 13px; transition: all .2s; }
|
|
||||||
.filter-btn:hover { border-color: var(--accent); }
|
|
||||||
.filter-btn.active { border-color: var(--accent); background: var(--accent); color: white; }
|
|
||||||
.usage-info { display: flex; align-items: center; gap: 6px; }
|
|
||||||
.usage-bar { width: 50px; height: 5px; background: var(--border-color); border-radius: 3px; overflow: hidden; }
|
|
||||||
.usage-bar-fill { height: 100%; background: var(--accent); border-radius: 3px; }
|
|
||||||
.voucher-note { max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.no-sites-warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 12px; padding: 30px; text-align: center; color: #856404; }
|
|
||||||
@media(max-width:768px){ .main-content{ margin-left:0!important; } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">
|
<div>
|
||||||
<?= __('vouchers_title') ?>
|
<h1 class="page-title">
|
||||||
<span class="live-indicator"><span class="dot"></span>LIVE</span>
|
<?= __('vouchers_title') ?>
|
||||||
</h1>
|
<span class="live-indicator"><span class="dot"></span>LIVE</span>
|
||||||
<p class="page-subtitle"><?= __('vouchers_subtitle') ?></p>
|
</h1>
|
||||||
|
<p class="page-subtitle"><?= __('vouchers_subtitle') ?></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (empty($sites)): ?>
|
<?php if (empty($sites)): ?>
|
||||||
<div class="no-sites-warning">
|
<div class="no-sites-warning">
|
||||||
<i class="fas fa-exclamation-triangle" style="font-size:48px;margin-bottom:15px;opacity:.7;display:block;"></i>
|
<i class="fas fa-triangle-exclamation" style="font-size:26px;margin-bottom:12px;display:block;" aria-hidden="true"></i>
|
||||||
<h3><?= __('vouchers_no_sites') ?></h3>
|
<h3><?= __('vouchers_no_sites') ?></h3>
|
||||||
<a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus"></i> <?= __('nav_sites') ?></a>
|
<a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('nav_sites') ?></a>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
||||||
|
|
@ -227,8 +167,8 @@ $currentPage = 'vouchers';
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<input type="search" id="searchInput" class="search-bar" placeholder="<?= __('vouchers_search') ?>" oninput="filterAndRender()" style="display:none;">
|
<input type="search" id="searchInput" class="search-bar" placeholder="<?= __('vouchers_search') ?>" oninput="filterAndRender()" style="display:none;">
|
||||||
<button id="refreshBtn" class="btn btn-success" onclick="loadVouchers(true)" disabled>
|
<button id="refreshBtn" class="btn btn-secondary" onclick="loadVouchers(true)" disabled>
|
||||||
<i class="fas fa-sync-alt"></i> <?= __('btn_refresh') ?>
|
<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('btn_refresh') ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -247,23 +187,25 @@ $currentPage = 'vouchers';
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title" id="voucherListTitle">Vouchers</h2>
|
<h2 class="card-title" id="voucherListTitle">Vouchers</h2>
|
||||||
<a id="csvExportBtn" style="display:none;" class="btn btn-secondary btn-sm" href="#">
|
<a id="csvExportBtn" style="display:none;" class="btn btn-secondary btn-sm" href="#">
|
||||||
<i class="fas fa-download"></i> <?= __('btn_export_csv') ?>
|
<i class="fas fa-download" aria-hidden="true"></i> <?= __('btn_export_csv') ?>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" style="padding:0;">
|
<div class="card-body" style="padding:0;">
|
||||||
<div id="voucherContent">
|
<div id="voucherContent">
|
||||||
<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('vouchers_select_hint') ?></p></div>
|
<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= __('vouchers_select_hint') ?></p></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</div><!-- /main-content -->
|
</main>
|
||||||
|
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const csrfToken = '<?= $auth->getCsrfToken() ?>';
|
const csrfToken = '<?= $auth->getCsrfToken() ?>';
|
||||||
|
// Datums- und Zeitformat folgen der gewaehlten Sprache
|
||||||
|
const LOCALE = '<?= I18n::getLanguage() === 'en' ? 'en-GB' : 'de-DE' ?>';
|
||||||
let currentSiteId = null;
|
let currentSiteId = null;
|
||||||
let allVouchers = [];
|
let allVouchers = [];
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
|
|
@ -278,7 +220,7 @@ async function loadVouchers(syncFirst=false) {
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
|
||||||
if (!siteId) {
|
if (!siteId) {
|
||||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_select_hint')) ?></p></div>`;
|
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= addslashes(__('vouchers_select_hint')) ?></p></div>`;
|
||||||
document.getElementById('statsContainer').style.display = 'none';
|
document.getElementById('statsContainer').style.display = 'none';
|
||||||
searchInput.style.display = 'none';
|
searchInput.style.display = 'none';
|
||||||
refreshBtn.disabled = true;
|
refreshBtn.disabled = true;
|
||||||
|
|
@ -287,9 +229,9 @@ async function loadVouchers(syncFirst=false) {
|
||||||
|
|
||||||
currentSiteId = siteId;
|
currentSiteId = siteId;
|
||||||
refreshBtn.disabled = false;
|
refreshBtn.disabled = false;
|
||||||
refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
|
refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
|
||||||
|
|
||||||
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner"></i><span>${syncFirst ? 'Synchronisiere...' : 'Lade...'}</span></div>`;
|
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner" aria-hidden="true"></i><span>${syncFirst ? 'Synchronisiere...' : 'Lade...'}</span></div>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json());
|
const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json());
|
||||||
|
|
@ -307,15 +249,15 @@ async function loadVouchers(syncFirst=false) {
|
||||||
csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`;
|
csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`;
|
||||||
if (syncFirst) showToast('success', '<?= addslashes(__('btn_refresh')) ?>', `${result.count} Vouchers geladen`);
|
if (syncFirst) showToast('success', '<?= addslashes(__('btn_refresh')) ?>', `${result.count} Vouchers geladen`);
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>${result.message}</p></div>`;
|
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)" aria-hidden="true"></i><p>${result.message}</p></div>`;
|
||||||
document.getElementById('statsContainer').style.display = 'none';
|
document.getElementById('statsContainer').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>Verbindungsfehler: ${e.message}</p></div>`;
|
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)" aria-hidden="true"></i><p>Verbindungsfehler: ${e.message}</p></div>`;
|
||||||
document.getElementById('statsContainer').style.display = 'none';
|
document.getElementById('statsContainer').style.display = 'none';
|
||||||
}
|
}
|
||||||
refreshBtn.disabled = false;
|
refreshBtn.disabled = false;
|
||||||
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= addslashes(__('btn_refresh')) ?>';
|
refreshBtn.innerHTML = '<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= addslashes(__('btn_refresh')) ?>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStats() {
|
function updateStats() {
|
||||||
|
|
@ -353,7 +295,7 @@ function renderVouchers() {
|
||||||
const pageVouchers = vouchers.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE);
|
const pageVouchers = vouchers.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE);
|
||||||
|
|
||||||
if (vouchers.length === 0) {
|
if (vouchers.length === 0) {
|
||||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`;
|
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -369,7 +311,7 @@ function renderVouchers() {
|
||||||
<button class="filter-btn ${currentFilter==='expired'?'active':''}" data-filter="expired" onclick="setFilter('expired')"><?= __('vouchers_filter_expired') ?> (${expiredCnt})</button>
|
<button class="filter-btn ${currentFilter==='expired'?'active':''}" data-filter="expired" onclick="setFilter('expired')"><?= __('vouchers_filter_expired') ?> (${expiredCnt})</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-container"><table class="table">
|
<div class="table-container"><table class="table table-stack">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th><?= __('label_created') ?></th>
|
<th><?= __('label_created') ?></th>
|
||||||
<th><?= __('label_code') ?></th>
|
<th><?= __('label_code') ?></th>
|
||||||
|
|
@ -390,21 +332,21 @@ function renderVouchers() {
|
||||||
})() : '';
|
})() : '';
|
||||||
const usagePct = v.quota > 0 ? Math.min(100, (v.used/v.quota)*100) : 0;
|
const usagePct = v.quota > 0 ? Math.min(100, (v.used/v.quota)*100) : 0;
|
||||||
const statusBadge = v.status==='valid'
|
const statusBadge = v.status==='valid'
|
||||||
? `<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_valid') ?></span>`
|
? `<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_valid') ?></span>`
|
||||||
: v.status==='used'
|
: v.status==='used'
|
||||||
? `<span class="badge badge-warning"><i class="fas fa-user-check"></i> <?= __('status_used') ?></span>`
|
? `<span class="badge badge-warning"><i class="fas fa-user-check" aria-hidden="true"></i> <?= __('status_used') ?></span>`
|
||||||
: `<span class="badge badge-danger"><i class="fas fa-times"></i> <?= __('status_expired') ?></span>`;
|
: `<span class="badge badge-danger"><i class="fas fa-times" aria-hidden="true"></i> <?= __('status_expired') ?></span>`;
|
||||||
|
|
||||||
html += `<tr id="voucher-${v._id}">
|
html += `<tr id="voucher-${v._id}">
|
||||||
<td><strong>${createDate.toLocaleDateString('de-DE')}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}</small></td>
|
<td data-label="<?= __('label_created') ?>"><strong>${createDate.toLocaleDateString(LOCALE)}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString(LOCALE,{hour:'2-digit',minute:'2-digit'})}</small></td>
|
||||||
<td><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','Kopiert!')" title="Kopieren" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
|
<td data-label="<?= __('label_code') ?>"><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','<?= __('js_copied') ?>')" title="<?= __('js_copy') ?>" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
|
||||||
<td class="voucher-note" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
|
<td class="voucher-note" data-label="<?= __('label_note') ?>" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
|
||||||
<td>${statusBadge}</td>
|
<td data-label="<?= __('label_status') ?>">${statusBadge}</td>
|
||||||
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
|
<td data-label="<?= __('label_usage') ?>"><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
|
||||||
<td>${remaining?`<span style="color:var(--success)"><i class="fas fa-clock"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} Min.</small></td>
|
<td data-label="<?= __('label_expires') ?>">${remaining?`<span style="color:var(--success)"><i class="fas fa-clock" aria-hidden="true"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} <?= __('label_minutes_short') ?></small></td>
|
||||||
<td style="white-space:nowrap;">
|
<td data-label="<?= __('label_actions') ?>" style="white-space:nowrap;">
|
||||||
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="Per E-Mail senden"><i class="fas fa-envelope"></i></button>
|
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="<?= __('vouchers_resend') ?>" aria-label="<?= __('vouchers_resend') ?>"><i class="fas fa-envelope" aria-hidden="true"></i></button>
|
||||||
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button>
|
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>"><i class="fas fa-trash" aria-hidden="true"></i></button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
|
|
@ -416,11 +358,11 @@ function renderVouchers() {
|
||||||
<span style="font-size:13px;color:var(--text-muted);">${<?= json_encode(__('vouchers_page_of')) ?>
|
<span style="font-size:13px;color:var(--text-muted);">${<?= json_encode(__('vouchers_page_of')) ?>
|
||||||
.replace('{current}',currentPage).replace('{total}',totalPages)} (${vouchers.length})</span>
|
.replace('{current}',currentPage).replace('{total}',totalPages)} (${vouchers.length})</span>
|
||||||
<div style="display:flex;gap:5px;">
|
<div style="display:flex;gap:5px;">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage-1})" ${currentPage<=1?'disabled':''}><i class="fas fa-chevron-left"></i></button>`;
|
<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage-1})" ${currentPage<=1?'disabled':''}><i class="fas fa-chevron-left" aria-hidden="true"></i></button>`;
|
||||||
for (let p=Math.max(1,currentPage-2); p<=Math.min(totalPages,currentPage+2); p++) {
|
for (let p=Math.max(1,currentPage-2); p<=Math.min(totalPages,currentPage+2); p++) {
|
||||||
html += `<button class="btn btn-sm ${p===currentPage?'btn-primary':'btn-secondary'}" onclick="setPage(${p})">${p}</button>`;
|
html += `<button class="btn btn-sm ${p===currentPage?'btn-primary':'btn-secondary'}" onclick="setPage(${p})">${p}</button>`;
|
||||||
}
|
}
|
||||||
html += `<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}><i class="fas fa-chevron-right"></i></button>
|
html += `<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,7 +386,7 @@ async function resendVoucher(voucherId, code) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteVoucher(voucherId) {
|
async function deleteVoucher(voucherId) {
|
||||||
if (!confirm('Voucher wirklich löschen?')) return;
|
if (!confirm('<?= __('js_confirm_delete_voucher') ?>')) return;
|
||||||
const row = document.getElementById(`voucher-${voucherId}`);
|
const row = document.getElementById(`voucher-${voucherId}`);
|
||||||
if (row) row.classList.add('deleting');
|
if (row) row.classList.add('deleting');
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
2217
assets/global.css
|
|
@ -1,7 +1,21 @@
|
||||||
/* === DARK MODE === */
|
/* === DARK MODE ===
|
||||||
|
Reihenfolge: ausdrueckliche Auswahl des Nutzers > Systemeinstellung. */
|
||||||
(function() {
|
(function() {
|
||||||
const saved = localStorage.getItem('theme') || 'light';
|
const saved = localStorage.getItem('theme');
|
||||||
document.documentElement.setAttribute('data-theme', saved);
|
// Der Inline-Schnipsel im <head> hat das Theme bereits gesetzt – diese
|
||||||
|
// Entscheidung wird hier nicht überschrieben.
|
||||||
|
const current = document.documentElement.getAttribute('data-theme');
|
||||||
|
const system = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', saved || current || system);
|
||||||
|
|
||||||
|
// Solange nichts ausgewaehlt wurde, folgt die Oberflaeche dem System.
|
||||||
|
if (!saved && window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||||
|
if (localStorage.getItem('theme')) return;
|
||||||
|
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||||
|
if (typeof updateDarkModeBtn === 'function') updateDarkModeBtn();
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
|
|
@ -17,7 +31,12 @@ function updateDarkModeBtn() {
|
||||||
const btn = document.getElementById('darkModeBtn');
|
const btn = document.getElementById('darkModeBtn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
btn.textContent = isDark ? '☀️' : '🌙';
|
const icon = btn.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
|
||||||
|
} else {
|
||||||
|
btn.textContent = isDark ? '☀' : '☾';
|
||||||
|
}
|
||||||
btn.title = isDark ? 'Light Mode' : 'Dark Mode';
|
btn.title = isDark ? 'Light Mode' : 'Dark Mode';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,8 +61,8 @@ document.addEventListener('DOMContentLoaded', updateDarkModeBtn);
|
||||||
const icons = {
|
const icons = {
|
||||||
success: '✓',
|
success: '✓',
|
||||||
error: '✕',
|
error: '✕',
|
||||||
info: 'ℹ',
|
info: 'i',
|
||||||
warning: '⚠'
|
warning: '!'
|
||||||
};
|
};
|
||||||
|
|
||||||
window.showToast = function(type, title, message, duration) {
|
window.showToast = function(type, title, message, duration) {
|
||||||
|
|
|
||||||
30
assets/vendor/README.md
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Lokale Drittanbieter-Assets
|
||||||
|
|
||||||
|
Alle Frontend-Bibliotheken werden aus diesem Ordner ausgeliefert. Damit gibt es
|
||||||
|
im Betrieb **keine Verbindungen zu externen CDNs** – wichtig für den Datenschutz
|
||||||
|
(keine IP-Übertragung an Dritte) und für abgeschottete Netze ohne Internetzugang.
|
||||||
|
|
||||||
|
| Ordner | Inhalt | Version | Lizenz |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `inter/` | Schriftschnitte 400/500/600/700 als woff2 | Inter 4.0 | SIL OFL 1.1 |
|
||||||
|
| `fontawesome/` | Solid- und Brands-Icons, gekürzte CSS (nur woff2) | Font Awesome Free 6.4.0 | Icons CC BY 4.0, Fonts SIL OFL 1.1, Code MIT |
|
||||||
|
| `chartjs/` | Diagramme für Dashboard und Reporting | Chart.js 4.4.0 | MIT |
|
||||||
|
| `qrcodejs/` | QR-Code-Erzeugung im Browser | qrcodejs 1.0.0 | MIT |
|
||||||
|
| `tinymce/` | WYSIWYG-Editor (GPL-Variante), auf die genutzten Plugins gekürzt | TinyMCE 6.8.3 | GPL-2.0-or-later |
|
||||||
|
|
||||||
|
Eingebunden werden sie über `Ui::head()` bzw. `Ui::script()` aus
|
||||||
|
`includes/Ui.php` – inklusive Versionsstempel (`?v=<filemtime>`), damit Browser
|
||||||
|
nach einem Update nicht die alten Dateien aus dem Cache verwenden.
|
||||||
|
|
||||||
|
## Aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Beispiel Chart.js
|
||||||
|
curl -L -o assets/vendor/chartjs/chart.umd.min.js \
|
||||||
|
https://cdn.jsdelivr.net/npm/chart.js@<version>/dist/chart.umd.min.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei TinyMCE werden nur `tinymce.min.js`, `themes/silver`, `models/dom`,
|
||||||
|
`icons/default`, die Skins `oxide`/`oxide-dark`, die deutsche Sprachdatei und
|
||||||
|
die tatsächlich genutzten Plugins übernommen (siehe `initTinyMCE()` in
|
||||||
|
`admin/settings.php`).
|
||||||
20
assets/vendor/chartjs/chart.umd.min.js
vendored
Normal file
11
assets/vendor/fontawesome/fontawesome.css
vendored
Normal file
BIN
assets/vendor/fontawesome/webfonts/fa-brands-400.woff2
vendored
Normal file
BIN
assets/vendor/fontawesome/webfonts/fa-solid-900.woff2
vendored
Normal file
BIN
assets/vendor/inter/Inter-Bold.woff2
vendored
Normal file
BIN
assets/vendor/inter/Inter-Medium.woff2
vendored
Normal file
BIN
assets/vendor/inter/Inter-Regular.woff2
vendored
Normal file
BIN
assets/vendor/inter/Inter-SemiBold.woff2
vendored
Normal file
30
assets/vendor/inter/inter.css
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/* Inter v4 – lokal ausgeliefert (SIL Open Font License 1.1).
|
||||||
|
Quelle: https://github.com/rsms/inter/releases – nur die vier genutzten Schnitte. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Inter-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Inter-Medium.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Inter-SemiBold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Inter-Bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
1
assets/vendor/qrcodejs/qrcode.min.js
vendored
Normal file
1
assets/vendor/tinymce/icons/default/icons.min.js
vendored
Normal file
406
assets/vendor/tinymce/langs/de.js
vendored
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
tinymce.addI18n("de", {
|
||||||
|
"Redo": "Wiederholen",
|
||||||
|
"Undo": "R\xfcckg\xe4ngig machen",
|
||||||
|
"Cut": "Ausschneiden",
|
||||||
|
"Copy": "Kopieren",
|
||||||
|
"Paste": "Einf\xfcgen",
|
||||||
|
"Select all": "Alles ausw\xe4hlen",
|
||||||
|
"New document": "Neues Dokument",
|
||||||
|
"Ok": "Ok",
|
||||||
|
"Cancel": "Abbrechen",
|
||||||
|
"Visual aids": "Visuelle Hilfen",
|
||||||
|
"Bold": "Fett",
|
||||||
|
"Italic": "Kursiv",
|
||||||
|
"Underline": "Unterstrichen",
|
||||||
|
"Strikethrough": "Durchgestrichen",
|
||||||
|
"Superscript": "Hochgestellt",
|
||||||
|
"Subscript": "Tiefgestellt",
|
||||||
|
"Clear formatting": "Formatierung entfernen",
|
||||||
|
"Remove": "Entfernen",
|
||||||
|
"Align left": "Linksb\xfcndig ausrichten",
|
||||||
|
"Align center": "Zentrieren",
|
||||||
|
"Align right": "Rechtsb\xfcndig ausrichten",
|
||||||
|
"No alignment": "Keine Ausrichtung",
|
||||||
|
"Justify": "Blocksatz",
|
||||||
|
"Bullet list": "Aufz\xe4hlung",
|
||||||
|
"Numbered list": "Nummerierte Liste",
|
||||||
|
"Decrease indent": "Einzug verkleinern",
|
||||||
|
"Increase indent": "Einzug vergr\xf6\xdfern",
|
||||||
|
"Close": "Schlie\xdfen",
|
||||||
|
"Formats": "Formate",
|
||||||
|
"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X/C/V keyboard shortcuts instead.": "Ihr Browser unterst\xfctzt leider keinen direkten Zugriff auf die Zwischenablage. Bitte benutzen Sie die Tastenkombinationen Strg+X/C/V.",
|
||||||
|
"Headings": "\xdcberschriften",
|
||||||
|
"Heading 1": "\xdcberschrift 1",
|
||||||
|
"Heading 2": "\xdcberschrift 2",
|
||||||
|
"Heading 3": "\xdcberschrift 3",
|
||||||
|
"Heading 4": "\xdcberschrift 4",
|
||||||
|
"Heading 5": "\xdcberschrift 5",
|
||||||
|
"Heading 6": "\xdcberschrift 6",
|
||||||
|
"Preformatted": "Vorformatiert",
|
||||||
|
"Div": "Div",
|
||||||
|
"Pre": "Pre",
|
||||||
|
"Code": "Code",
|
||||||
|
"Paragraph": "Absatz",
|
||||||
|
"Blockquote": "Blockzitat",
|
||||||
|
"Inline": "Zeichenformate",
|
||||||
|
"Blocks": "Bl\xf6cke",
|
||||||
|
"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Einf\xfcgen ist nun im unformatierten Textmodus. Inhalte werden ab jetzt als unformatierter Text eingef\xfcgt, bis Sie diese Einstellung wieder deaktivieren.",
|
||||||
|
"Fonts": "Schriftarten",
|
||||||
|
"Font sizes": "Schriftgr\xf6\xdfen",
|
||||||
|
"Class": "Klasse",
|
||||||
|
"Browse for an image": "Bild...",
|
||||||
|
"OR": "ODER",
|
||||||
|
"Drop an image here": "Bild hier ablegen",
|
||||||
|
"Upload": "Hochladen",
|
||||||
|
"Uploading image": "Bild wird hochgeladen",
|
||||||
|
"Block": "Blocksatz",
|
||||||
|
"Align": "Ausrichtung",
|
||||||
|
"Default": "Standard",
|
||||||
|
"Circle": "Kreis",
|
||||||
|
"Disc": "Scheibe",
|
||||||
|
"Square": "Rechteck",
|
||||||
|
"Lower Alpha": "Lateinisches Alphabet in Kleinbuchstaben",
|
||||||
|
"Lower Greek": "Griechische Kleinbuchstaben",
|
||||||
|
"Lower Roman": "Kleiner r\xf6mischer Buchstabe",
|
||||||
|
"Upper Alpha": "Lateinisches Alphabet in Gro\xdfbuchstaben",
|
||||||
|
"Upper Roman": "Gro\xdfer r\xf6mischer Buchstabe",
|
||||||
|
"Anchor...": "Textmarke",
|
||||||
|
"Anchor": "Anker",
|
||||||
|
"Name": "Name",
|
||||||
|
"ID": "ID",
|
||||||
|
"ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Die ID muss mit einem Buchstaben beginnen gefolgt von Buchstaben, Zahlen, Bindestrichen, Punkten, Doppelpunkten oder Unterstrichen.",
|
||||||
|
"You have unsaved changes are you sure you want to navigate away?": "Die \xc4nderungen wurden noch nicht gespeichert. Sind Sie sicher, dass Sie diese Seite verlassen wollen?",
|
||||||
|
"Restore last draft": "Letzten Entwurf wiederherstellen",
|
||||||
|
"Special character...": "Sonderzeichen...",
|
||||||
|
"Special Character": "Sonderzeichen",
|
||||||
|
"Source code": "Quellcode",
|
||||||
|
"Insert/Edit code sample": "Codebeispiel einf\xfcgen/bearbeiten",
|
||||||
|
"Language": "Sprache",
|
||||||
|
"Code sample...": "Codebeispiel...",
|
||||||
|
"Left to right": "Von links nach rechts",
|
||||||
|
"Right to left": "Von rechts nach links",
|
||||||
|
"Title": "Titel",
|
||||||
|
"Fullscreen": "Vollbild",
|
||||||
|
"Action": "Aktion",
|
||||||
|
"Shortcut": "Tastenkombination",
|
||||||
|
"Help": "Hilfe",
|
||||||
|
"Address": "Adresse",
|
||||||
|
"Focus to menubar": "Fokus auf Men\xfcleiste",
|
||||||
|
"Focus to toolbar": "Fokus auf Symbolleiste",
|
||||||
|
"Focus to element path": "Fokus auf Elementpfad",
|
||||||
|
"Focus to contextual toolbar": "Fokus auf kontextbezogene Symbolleiste",
|
||||||
|
"Insert link (if link plugin activated)": "Link einf\xfcgen (wenn Link-Plugin aktiviert ist)",
|
||||||
|
"Save (if save plugin activated)": "Speichern (wenn Save-Plugin aktiviert ist)",
|
||||||
|
"Find (if searchreplace plugin activated)": "Suchen (wenn Suchen/Ersetzen-Plugin aktiviert ist)",
|
||||||
|
"Plugins installed ({0}):": "Installierte Plugins ({0}):",
|
||||||
|
"Premium plugins:": "Premium-Plugins:",
|
||||||
|
"Learn more...": "Erfahren Sie mehr dazu...",
|
||||||
|
"You are using {0}": "Sie verwenden {0}",
|
||||||
|
"Plugins": "Plugins",
|
||||||
|
"Handy Shortcuts": "Praktische Tastenkombinationen",
|
||||||
|
"Horizontal line": "Horizontale Linie",
|
||||||
|
"Insert/edit image": "Bild einf\xfcgen/bearbeiten",
|
||||||
|
"Alternative description": "Alternative Beschreibung",
|
||||||
|
"Accessibility": "Barrierefreiheit",
|
||||||
|
"Image is decorative": "Bild ist dekorativ",
|
||||||
|
"Source": "Quelle",
|
||||||
|
"Dimensions": "Abmessungen",
|
||||||
|
"Constrain proportions": "Seitenverh\xe4ltnis beibehalten",
|
||||||
|
"General": "Allgemein",
|
||||||
|
"Advanced": "Erweitert",
|
||||||
|
"Style": "Formatvorlage",
|
||||||
|
"Vertical space": "Vertikaler Raum",
|
||||||
|
"Horizontal space": "Horizontaler Raum",
|
||||||
|
"Border": "Rahmen",
|
||||||
|
"Insert image": "Bild einf\xfcgen",
|
||||||
|
"Image...": "Bild...",
|
||||||
|
"Image list": "Bildliste",
|
||||||
|
"Resize": "Skalieren",
|
||||||
|
"Insert date/time": "Datum/Uhrzeit einf\xfcgen",
|
||||||
|
"Date/time": "Datum/Uhrzeit",
|
||||||
|
"Insert/edit link": "Link einf\xfcgen/bearbeiten",
|
||||||
|
"Text to display": "Anzuzeigender Text",
|
||||||
|
"Url": "URL",
|
||||||
|
"Open link in...": "Link \xf6ffnen in...",
|
||||||
|
"Current window": "Aktuelles Fenster",
|
||||||
|
"None": "Keine",
|
||||||
|
"New window": "Neues Fenster",
|
||||||
|
"Open link": "Link \xf6ffnen",
|
||||||
|
"Remove link": "Link entfernen",
|
||||||
|
"Anchors": "Anker",
|
||||||
|
"Link...": "Link...",
|
||||||
|
"Paste or type a link": "Link einf\xfcgen oder eingeben",
|
||||||
|
"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "Diese URL scheint eine E-Mail-Adresse zu sein. M\xf6chten Sie das dazu ben\xf6tigte mailto: voranstellen?",
|
||||||
|
"The URL you entered seems to be an external link. Do you want to add the required http:// prefix?": "Diese URL scheint ein externer Link zu sein. M\xf6chten Sie das dazu ben\xf6tigte http:// voranstellen?",
|
||||||
|
"The URL you entered seems to be an external link. Do you want to add the required https:// prefix?": "Die eingegebene URL scheint ein externer Link zu sein. Soll das fehlende https:// davor erg\xe4nzt werden?",
|
||||||
|
"Link list": "Linkliste",
|
||||||
|
"Insert video": "Video einf\xfcgen",
|
||||||
|
"Insert/edit video": "Video einf\xfcgen/bearbeiten",
|
||||||
|
"Insert/edit media": "Medien einf\xfcgen/bearbeiten",
|
||||||
|
"Alternative source": "Alternative Quelle",
|
||||||
|
"Alternative source URL": "URL der alternativen Quelle",
|
||||||
|
"Media poster (Image URL)": "Medienposter (Bild-URL)",
|
||||||
|
"Paste your embed code below:": "F\xfcgen Sie Ihren Einbettungscode unten ein:",
|
||||||
|
"Embed": "Einbettung",
|
||||||
|
"Media...": "Medien...",
|
||||||
|
"Nonbreaking space": "Gesch\xfctztes Leerzeichen",
|
||||||
|
"Page break": "Seitenumbruch",
|
||||||
|
"Paste as text": "Als Text einf\xfcgen",
|
||||||
|
"Preview": "Vorschau",
|
||||||
|
"Print": "Drucken",
|
||||||
|
"Print...": "Drucken...",
|
||||||
|
"Save": "Speichern",
|
||||||
|
"Find": "Suchen",
|
||||||
|
"Replace with": "Ersetzen durch",
|
||||||
|
"Replace": "Ersetzen",
|
||||||
|
"Replace all": "Alle ersetzen",
|
||||||
|
"Previous": "Vorherige",
|
||||||
|
"Next": "N\xe4chste",
|
||||||
|
"Find and Replace": "Suchen und Ersetzen",
|
||||||
|
"Find and replace...": "Suchen und ersetzen...",
|
||||||
|
"Could not find the specified string.": "Die angegebene Zeichenfolge wurde nicht gefunden.",
|
||||||
|
"Match case": "Gro\xdf-/Kleinschreibung beachten",
|
||||||
|
"Find whole words only": "Nur ganze W\xf6rter suchen",
|
||||||
|
"Find in selection": "In Auswahl suchen",
|
||||||
|
"Insert table": "Tabelle einf\xfcgen",
|
||||||
|
"Table properties": "Tabelleneigenschaften",
|
||||||
|
"Delete table": "Tabelle l\xf6schen",
|
||||||
|
"Cell": "Zelle",
|
||||||
|
"Row": "Zeile",
|
||||||
|
"Column": "Spalte",
|
||||||
|
"Cell properties": "Zelleigenschaften",
|
||||||
|
"Merge cells": "Zellen verbinden",
|
||||||
|
"Split cell": "Zelle aufteilen",
|
||||||
|
"Insert row before": "Neue Zeile davor einf\xfcgen",
|
||||||
|
"Insert row after": "Neue Zeile danach einf\xfcgen",
|
||||||
|
"Delete row": "Zeile l\xf6schen",
|
||||||
|
"Row properties": "Zeileneigenschaften",
|
||||||
|
"Cut row": "Zeile ausschneiden",
|
||||||
|
"Cut column": "Spalte ausschneiden",
|
||||||
|
"Copy row": "Zeile kopieren",
|
||||||
|
"Copy column": "Spalte kopieren",
|
||||||
|
"Paste row before": "Zeile davor einf\xfcgen",
|
||||||
|
"Paste column before": "Spalte davor einf\xfcgen",
|
||||||
|
"Paste row after": "Zeile danach einf\xfcgen",
|
||||||
|
"Paste column after": "Spalte danach einf\xfcgen",
|
||||||
|
"Insert column before": "Neue Spalte davor einf\xfcgen",
|
||||||
|
"Insert column after": "Neue Spalte danach einf\xfcgen",
|
||||||
|
"Delete column": "Spalte l\xf6schen",
|
||||||
|
"Cols": "Spalten",
|
||||||
|
"Rows": "Zeilen",
|
||||||
|
"Width": "Breite",
|
||||||
|
"Height": "H\xf6he",
|
||||||
|
"Cell spacing": "Zellenabstand",
|
||||||
|
"Cell padding": "Zelleninnenabstand",
|
||||||
|
"Row clipboard actions": "Zeilen-Zwischenablage-Aktionen",
|
||||||
|
"Column clipboard actions": "Spalten-Zwischenablage-Aktionen",
|
||||||
|
"Table styles": "Tabellenstil",
|
||||||
|
"Cell styles": "Zellstil",
|
||||||
|
"Column header": "Spaltenkopf",
|
||||||
|
"Row header": "Zeilenkopf",
|
||||||
|
"Table caption": "Tabellenbeschriftung",
|
||||||
|
"Caption": "Beschriftung",
|
||||||
|
"Show caption": "Beschriftung anzeigen",
|
||||||
|
"Left": "Links",
|
||||||
|
"Center": "Zentriert",
|
||||||
|
"Right": "Rechts",
|
||||||
|
"Cell type": "Zelltyp",
|
||||||
|
"Scope": "Bereich",
|
||||||
|
"Alignment": "Ausrichtung",
|
||||||
|
"Horizontal align": "Horizontal ausrichten",
|
||||||
|
"Vertical align": "Vertikal ausrichten",
|
||||||
|
"Top": "Oben",
|
||||||
|
"Middle": "Mitte",
|
||||||
|
"Bottom": "Unten",
|
||||||
|
"Header cell": "Kopfzelle",
|
||||||
|
"Row group": "Zeilengruppe",
|
||||||
|
"Column group": "Spaltengruppe",
|
||||||
|
"Row type": "Zeilentyp",
|
||||||
|
"Header": "Kopfzeile",
|
||||||
|
"Body": "Inhalt",
|
||||||
|
"Footer": "Fu\xdfzeile",
|
||||||
|
"Border color": "Rahmenfarbe",
|
||||||
|
"Solid": "Durchgezogen",
|
||||||
|
"Dotted": "Gepunktet",
|
||||||
|
"Dashed": "Gestrichelt",
|
||||||
|
"Double": "Doppelt",
|
||||||
|
"Groove": "Gekantet",
|
||||||
|
"Ridge": "Eingeritzt",
|
||||||
|
"Inset": "Eingelassen",
|
||||||
|
"Outset": "Hervorstehend",
|
||||||
|
"Hidden": "Unsichtbar",
|
||||||
|
"Insert template...": "Vorlage einf\xfcgen...",
|
||||||
|
"Templates": "Vorlagen",
|
||||||
|
"Template": "Vorlage",
|
||||||
|
"Insert Template": "Vorlage einf\xfcgen",
|
||||||
|
"Text color": "Textfarbe",
|
||||||
|
"Background color": "Hintergrundfarbe",
|
||||||
|
"Custom...": "Benutzerdefiniert...",
|
||||||
|
"Custom color": "Benutzerdefinierte Farbe",
|
||||||
|
"No color": "Keine Farbe",
|
||||||
|
"Remove color": "Farbauswahl aufheben",
|
||||||
|
"Show blocks": "Bl\xf6cke anzeigen",
|
||||||
|
"Show invisible characters": "Unsichtbare Zeichen anzeigen",
|
||||||
|
"Word count": "Anzahl der W\xf6rter",
|
||||||
|
"Count": "Anzahl",
|
||||||
|
"Document": "Dokument",
|
||||||
|
"Selection": "Auswahl",
|
||||||
|
"Words": "W\xf6rter",
|
||||||
|
"Words: {0}": "Wortzahl: {0}",
|
||||||
|
"{0} words": "{0} W\xf6rter",
|
||||||
|
"File": "Datei",
|
||||||
|
"Edit": "Bearbeiten",
|
||||||
|
"Insert": "Einf\xfcgen",
|
||||||
|
"View": "Ansicht",
|
||||||
|
"Format": "Format",
|
||||||
|
"Table": "Tabelle",
|
||||||
|
"Tools": "Werkzeuge",
|
||||||
|
"Powered by {0}": "Betrieben von {0}",
|
||||||
|
"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Rich-Text-Bereich. Dr\xfccken Sie Alt+F9 f\xfcr das Men\xfc. Dr\xfccken Sie Alt+F10 f\xfcr die Symbolleiste. Dr\xfccken Sie Alt+0 f\xfcr Hilfe.",
|
||||||
|
"Image title": "Bildtitel",
|
||||||
|
"Border width": "Rahmenbreite",
|
||||||
|
"Border style": "Rahmenstil",
|
||||||
|
"Error": "Fehler",
|
||||||
|
"Warn": "Warnung",
|
||||||
|
"Valid": "G\xfcltig",
|
||||||
|
"To open the popup, press Shift+Enter": "Dr\xfccken Sie Umschalt+Eingabe, um das Popup-Fenster zu \xf6ffnen.",
|
||||||
|
"Rich Text Area": "Rich-Text-Area",
|
||||||
|
"Rich Text Area. Press ALT-0 for help.": "Rich-Text-Bereich. Dr\xfccken Sie Alt+0 f\xfcr Hilfe.",
|
||||||
|
"System Font": "Betriebssystemschriftart",
|
||||||
|
"Failed to upload image: {0}": "Bild konnte nicht hochgeladen werden: {0}",
|
||||||
|
"Failed to load plugin: {0} from url {1}": "Plugin konnte nicht geladen werden: {0} von URL {1}",
|
||||||
|
"Failed to load plugin url: {0}": "Plugin-URL konnte nicht geladen werden: {0}",
|
||||||
|
"Failed to initialize plugin: {0}": "Plugin konnte nicht initialisiert werden: {0}",
|
||||||
|
"example": "Beispiel",
|
||||||
|
"Search": "Suchen",
|
||||||
|
"All": "Alle",
|
||||||
|
"Currency": "W\xe4hrung",
|
||||||
|
"Text": "Text",
|
||||||
|
"Quotations": "Anf\xfchrungszeichen",
|
||||||
|
"Mathematical": "Mathematisch",
|
||||||
|
"Extended Latin": "Erweitertes Latein",
|
||||||
|
"Symbols": "Symbole",
|
||||||
|
"Arrows": "Pfeile",
|
||||||
|
"User Defined": "Benutzerdefiniert",
|
||||||
|
"dollar sign": "Dollarzeichen",
|
||||||
|
"currency sign": "W\xe4hrungssymbol",
|
||||||
|
"euro-currency sign": "Eurozeichen",
|
||||||
|
"colon sign": "Doppelpunkt",
|
||||||
|
"cruzeiro sign": "Cruzeirozeichen",
|
||||||
|
"french franc sign": "Franczeichen",
|
||||||
|
"lira sign": "Lirezeichen",
|
||||||
|
"mill sign": "Millzeichen",
|
||||||
|
"naira sign": "Nairazeichen",
|
||||||
|
"peseta sign": "Pesetazeichen",
|
||||||
|
"rupee sign": "Rupiezeichen",
|
||||||
|
"won sign": "Wonzeichen",
|
||||||
|
"new sheqel sign": "Schekelzeichen",
|
||||||
|
"dong sign": "Dongzeichen",
|
||||||
|
"kip sign": "Kipzeichen",
|
||||||
|
"tugrik sign": "Tugrikzeichen",
|
||||||
|
"drachma sign": "Drachmezeichen",
|
||||||
|
"german penny symbol": "Pfennigzeichen",
|
||||||
|
"peso sign": "Pesozeichen",
|
||||||
|
"guarani sign": "Guaranizeichen",
|
||||||
|
"austral sign": "Australzeichen",
|
||||||
|
"hryvnia sign": "Hrywnjazeichen",
|
||||||
|
"cedi sign": "Cedizeichen",
|
||||||
|
"livre tournois sign": "Livrezeichen",
|
||||||
|
"spesmilo sign": "Spesmilozeichen",
|
||||||
|
"tenge sign": "Tengezeichen",
|
||||||
|
"indian rupee sign": "Indisches Rupiezeichen",
|
||||||
|
"turkish lira sign": "T\xfcrkisches Lirazeichen",
|
||||||
|
"nordic mark sign": "Zeichen nordische Mark",
|
||||||
|
"manat sign": "Manatzeichen",
|
||||||
|
"ruble sign": "Rubelzeichen",
|
||||||
|
"yen character": "Yenzeichen",
|
||||||
|
"yuan character": "Yuanzeichen",
|
||||||
|
"yuan character, in hong kong and taiwan": "Yuanzeichen in Hongkong und Taiwan",
|
||||||
|
"yen/yuan character variant one": "Yen-/Yuanzeichen Variante 1",
|
||||||
|
"Emojis": "Emojis",
|
||||||
|
"Emojis...": "Emojis...",
|
||||||
|
"Loading emojis...": "Lade Emojis...",
|
||||||
|
"Could not load emojis": "Emojis konnten nicht geladen werden",
|
||||||
|
"People": "Menschen",
|
||||||
|
"Animals and Nature": "Tiere und Natur",
|
||||||
|
"Food and Drink": "Essen und Trinken",
|
||||||
|
"Activity": "Aktivit\xe4t",
|
||||||
|
"Travel and Places": "Reisen und Orte",
|
||||||
|
"Objects": "Objekte",
|
||||||
|
"Flags": "Flaggen",
|
||||||
|
"Characters": "Zeichen",
|
||||||
|
"Characters (no spaces)": "Zeichen (ohne Leerzeichen)",
|
||||||
|
"{0} characters": "{0}\xa0Zeichen",
|
||||||
|
"Error: Form submit field collision.": "Fehler: Kollision der Formularbest\xe4tigungsfelder.",
|
||||||
|
"Error: No form element found.": "Fehler: Kein Formularelement gefunden.",
|
||||||
|
"Color swatch": "Farbpalette",
|
||||||
|
"Color Picker": "Farbwahl",
|
||||||
|
"Invalid hex color code: {0}": "Ung\xfcltiger Hexadezimal-Farbwert: {0}",
|
||||||
|
"Invalid input": "Ung\xfcltige Eingabe",
|
||||||
|
"R": "R",
|
||||||
|
"Red component": "Rotanteil",
|
||||||
|
"G": "G",
|
||||||
|
"Green component": "Gr\xfcnanteil",
|
||||||
|
"B": "B",
|
||||||
|
"Blue component": "Blauanteil",
|
||||||
|
"#": "#",
|
||||||
|
"Hex color code": "Hexadezimal-Farbwert",
|
||||||
|
"Range 0 to 255": "Spanne 0 bis 255",
|
||||||
|
"Turquoise": "T\xfcrkis",
|
||||||
|
"Green": "Gr\xfcn",
|
||||||
|
"Blue": "Blau",
|
||||||
|
"Purple": "Violett",
|
||||||
|
"Navy Blue": "Marineblau",
|
||||||
|
"Dark Turquoise": "Dunkelt\xfcrkis",
|
||||||
|
"Dark Green": "Dunkelgr\xfcn",
|
||||||
|
"Medium Blue": "Mittleres Blau",
|
||||||
|
"Medium Purple": "Mittelviolett",
|
||||||
|
"Midnight Blue": "Mitternachtsblau",
|
||||||
|
"Yellow": "Gelb",
|
||||||
|
"Orange": "Orange",
|
||||||
|
"Red": "Rot",
|
||||||
|
"Light Gray": "Hellgrau",
|
||||||
|
"Gray": "Grau",
|
||||||
|
"Dark Yellow": "Dunkelgelb",
|
||||||
|
"Dark Orange": "Dunkelorange",
|
||||||
|
"Dark Red": "Dunkelrot",
|
||||||
|
"Medium Gray": "Mittelgrau",
|
||||||
|
"Dark Gray": "Dunkelgrau",
|
||||||
|
"Light Green": "Hellgr\xfcn",
|
||||||
|
"Light Yellow": "Hellgelb",
|
||||||
|
"Light Red": "Hellrot",
|
||||||
|
"Light Purple": "Helllila",
|
||||||
|
"Light Blue": "Hellblau",
|
||||||
|
"Dark Purple": "Dunkellila",
|
||||||
|
"Dark Blue": "Dunkelblau",
|
||||||
|
"Black": "Schwarz",
|
||||||
|
"White": "Wei\xdf",
|
||||||
|
"Switch to or from fullscreen mode": "Vollbildmodus umschalten",
|
||||||
|
"Open help dialog": "Hilfe-Dialog \xf6ffnen",
|
||||||
|
"history": "Historie",
|
||||||
|
"styles": "Stile",
|
||||||
|
"formatting": "Formatierung",
|
||||||
|
"alignment": "Ausrichtung",
|
||||||
|
"indentation": "Einr\xfcckungen",
|
||||||
|
"Font": "Schriftart",
|
||||||
|
"Size": "Schriftgr\xf6\xdfe",
|
||||||
|
"More...": "Mehr...",
|
||||||
|
"Select...": "Auswahl...",
|
||||||
|
"Preferences": "Einstellungen",
|
||||||
|
"Yes": "Ja",
|
||||||
|
"No": "Nein",
|
||||||
|
"Keyboard Navigation": "Tastaturnavigation",
|
||||||
|
"Version": "Version",
|
||||||
|
"Code view": "Code Ansicht",
|
||||||
|
"Open popup menu for split buttons": "\xd6ffne Popup Menge um Buttons zu trennen",
|
||||||
|
"List Properties": "Liste Eigenschaften",
|
||||||
|
"List properties...": "Liste Eigenschaften",
|
||||||
|
"Start list at number": "Beginne Liste mit Nummer",
|
||||||
|
"Line height": "Liniendicke",
|
||||||
|
"Dropped file type is not supported": "Hereingezogener Dateityp wird nicht unterst\xfctzt",
|
||||||
|
"Loading...": "Wird geladen...",
|
||||||
|
"ImageProxy HTTP error: Rejected request": "Image Proxy HTTP Fehler: Abgewiesene Anfrage",
|
||||||
|
"ImageProxy HTTP error: Could not find Image Proxy": "Image Proxy HTTP Fehler: Kann Image Proxy nicht finden",
|
||||||
|
"ImageProxy HTTP error: Incorrect Image Proxy URL": "Image Proxy HTTP Fehler: Falsche Image Proxy URL",
|
||||||
|
"ImageProxy HTTP error: Unknown ImageProxy error": "Image Proxy HTTP Fehler: Unbekannter Image Proxy Fehler"
|
||||||
|
});
|
||||||
21
assets/vendor/tinymce/license.txt
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
4
assets/vendor/tinymce/models/dom/model.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/advlist/plugin.min.js
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* TinyMCE version 6.8.3 (2024-02-08)
|
||||||
|
*/
|
||||||
|
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),i=t=>null==t,l=t=>!i(t);var o=tinymce.util.Tools.resolve("tinymce.util.Tools");class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return l(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=t=>e=>l(e)&&t.test(e.nodeName),d=u(/^(OL|UL|DL)$/),g=u(/^(TH|TD)$/),c=t=>i(t)||"default"===t?"":t,h=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,(r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let e=0,n=t.length;e<n;e++){const n=t[e];if(d(r=n)&&!/\btox\-/.test(r.className))return a.some(n);if(s(n,e))break}var r;return a.none()})(e,0,g).exists((e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e))))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)&&t.selection.isEditable()})(t,n)&&t.selection.isEditable())})(t,r.parents))),m=(t,s,r,n,i,l)=>{l.length>1?((t,s,r,n,i,l)=>{t.ui.registry.addSplitButton(s,{tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:t=>{t(o.map(l,(t=>{const e="OL"===i?"num":"bull",s="disc"===t||"decimal"===t?"default":t,r=c(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,(t=>t.toUpperCase())))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}})))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,i,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return a.from(s)})(t);return s.map((t=>e===t)).getOr(!1)},onSetup:h(t,i)})})(t,s,r,n,i,l):((t,s,r,n,i,l)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",onSetup:h(t,i),onAction:()=>t.queryCommandState(n)||""===l?t.execCommand(n):e(t,i,l)})})(t,s,r,n,i,c(l[0]))};t.add("advlist",(t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,circle,square".split(",")})})(t),(t=>{m(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),m(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",((s,r)=>{e(t,"UL",r["list-style-type"])})),t.addCommand("ApplyOrderedListStyle",((s,r)=>{e(t,"OL",r["list-style-type"])}))})(t)):console.error("Please use the Lists plugin together with the Advanced List plugin.")}))}();
|
||||||
4
assets/vendor/tinymce/plugins/autolink/plugin.min.js
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* TinyMCE version 6.8.3 (2024-02-08)
|
||||||
|
*/
|
||||||
|
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),n=t("autolink_pattern"),o=t("link_default_target"),r=t("link_default_protocol"),a=t("allow_unsafe_link_target"),s=("string",e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e));const l=(void 0,e=>undefined===e);const i=e=>!(e=>null==e)(e),c=Object.hasOwnProperty,d=e=>"\ufeff"===e;var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!d(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=n(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]"))return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),c.call(t,n))||"false"===i.getContentEditable(e);var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return $="www.",(b=t).length>=4&&b.substr(0,4)===$?t=r(e)+"://"+t:((e,t,n=0,o)=>{const r=e.indexOf(t,n);return-1!==r&&(!!l(o)||r+t.length<=o)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,$;return null},k=(e,t)=>{const{dom:n,selection:r}=e,{rng:l,url:i}=t,c=r.getBookmark();r.setRng(l);const d="createlink",u={command:d,ui:!1,value:i};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(d,!1,i),e.dispatch("ExecCommand",u);const t=o(e);if(s(t)){const o=r.getNode();n.setAttrib(o,"target",t),"_blank"!==t||a(e)||n.setAttrib(o,"rel","noopener")}}r.moveToBookmark(c),e.nodeChanged()},p=e=>{const t=m(e,-1);i(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);i(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}();
|
||||||
4
assets/vendor/tinymce/plugins/code/plugin.min.js
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* TinyMCE version 6.8.3 (2024-02-08)
|
||||||
|
*/
|
||||||
|
!function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",(e=>((e=>{e.addCommand("mceCodeEditor",(()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact((()=>{e.setContent(o)})),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)}))})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{})))}();
|
||||||
4
assets/vendor/tinymce/plugins/fullscreen/plugin.min.js
vendored
Normal file
90
assets/vendor/tinymce/plugins/help/js/i18n/keynav/de.js
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
tinymce.Resource.add('tinymce.html-i18n.help-keynav.de',
|
||||||
|
'<h1>Grundlagen der Tastaturnavigation</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<dl>\n' +
|
||||||
|
' <dt>Fokus auf Menüleiste</dt>\n' +
|
||||||
|
' <dd>Windows oder Linux: ALT+F9</dd>\n' +
|
||||||
|
' <dd>macOS: ⌥F9</dd>\n' +
|
||||||
|
' <dt>Fokus auf Symbolleiste</dt>\n' +
|
||||||
|
' <dd>Windows oder Linux: ALT+F10</dd>\n' +
|
||||||
|
' <dd>macOS: ⌥F10</dd>\n' +
|
||||||
|
' <dt>Fokus auf Fußzeile</dt>\n' +
|
||||||
|
' <dd>Windows oder Linux: ALT+F11</dd>\n' +
|
||||||
|
' <dd>macOS: ⌥F11</dd>\n' +
|
||||||
|
' <dt>Fokus auf kontextbezogene Symbolleiste</dt>\n' +
|
||||||
|
' <dd>Windows, Linux oder macOS: STRG+F9\n' +
|
||||||
|
'</dl>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Die Navigation beginnt beim ersten Benutzeroberflächenelement, welches hervorgehoben ist. Falls sich das erste Element im Pfad der Fußzeile befindet,\n' +
|
||||||
|
' ist es unterstrichen.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<h1>Zwischen Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Um von einem Abschnitt der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie <strong>TAB</strong>.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Um von einem Abschnitt der Benutzeroberfläche zum vorherigen zu wechseln, drücken Sie <strong>UMSCHALT+TAB</strong>.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Die Abschnitte der Benutzeroberfläche haben folgende <strong>TAB</strong>-Reihenfolge:</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<ol>\n' +
|
||||||
|
' <li>Menüleiste</li>\n' +
|
||||||
|
' <li>Einzelne Gruppen der Symbolleiste</li>\n' +
|
||||||
|
' <li>Randleiste</li>\n' +
|
||||||
|
' <li>Elementpfad in der Fußzeile</li>\n' +
|
||||||
|
' <li>Umschaltfläche „Wörter zählen“ in der Fußzeile</li>\n' +
|
||||||
|
' <li>Branding-Link in der Fußzeile</li>\n' +
|
||||||
|
' <li>Editor-Ziehpunkt zur Größenänderung in der Fußzeile</li>\n' +
|
||||||
|
'</ol>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Falls ein Abschnitt der Benutzeroberflächen nicht vorhanden ist, wird er übersprungen.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Wenn in der Fußzeile die Tastaturnavigation fokussiert ist und keine Randleiste angezeigt wird, wechselt der Fokus durch Drücken von <strong>UMSCHALT+TAB</strong>\n' +
|
||||||
|
' zur ersten Gruppe der Symbolleiste, nicht zur letzten.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<h1>Innerhalb von Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Um von einem Element der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie die entsprechende <strong>Pfeiltaste</strong>.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Die Pfeiltasten <strong>Links</strong> und <strong>Rechts</strong></p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<ul>\n' +
|
||||||
|
' <li>wechseln zwischen Menüs in der Menüleiste.</li>\n' +
|
||||||
|
' <li>öffnen das Untermenü eines Menüs.</li>\n' +
|
||||||
|
' <li>wechseln zwischen Schaltflächen in einer Gruppe der Symbolleiste.</li>\n' +
|
||||||
|
' <li>wechseln zwischen Elementen im Elementpfad der Fußzeile.</li>\n' +
|
||||||
|
'</ul>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Die Pfeiltasten <strong>Abwärts</strong> und <strong>Aufwärts</strong></p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<ul>\n' +
|
||||||
|
' <li>wechseln zwischen Menüelementen in einem Menü.</li>\n' +
|
||||||
|
' <li>wechseln zwischen Elementen in einem Popupmenü der Symbolleiste.</li>\n' +
|
||||||
|
'</ul>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Die <strong>Pfeiltasten</strong> rotieren innerhalb des fokussierten Abschnitts der Benutzeroberfläche.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Um ein geöffnetes Menü, ein geöffnetes Untermenü oder ein geöffnetes Popupmenü zu schließen, drücken Sie die <strong>ESC</strong>-Taste.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Wenn sich der aktuelle Fokus ganz oben in einem bestimmten Abschnitt der Benutzeroberfläche befindet, wird durch Drücken der <strong>ESC</strong>-Taste auch\n' +
|
||||||
|
' die Tastaturnavigation beendet.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<h1>Ein Menüelement oder eine Symbolleistenschaltfläche ausführen</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Wenn das gewünschte Menüelement oder die gewünschte Symbolleistenschaltfläche hervorgehoben ist, drücken Sie <strong>Zurück</strong>, <strong>Eingabe</strong>\n' +
|
||||||
|
' oder die <strong>Leertaste</strong>, um das Element auszuführen.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<h1>In Dialogfeldern ohne Registerkarten navigieren</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>In Dialogfeldern ohne Registerkarten ist beim Öffnen eines Dialogfelds die erste interaktive Komponente fokussiert.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Navigieren Sie zwischen den interaktiven Komponenten eines Dialogfelds, indem Sie <strong>TAB</strong> oder <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<h1>In Dialogfeldern mit Registerkarten navigieren</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>In Dialogfeldern mit Registerkarten ist beim Öffnen eines Dialogfelds die erste Schaltfläche eines Registerkartenmenüs fokussiert.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Navigieren Sie zwischen den interaktiven Komponenten auf dieser Registerkarte des Dialogfelds, indem Sie <strong>TAB</strong> oder\n' +
|
||||||
|
' <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Wechseln Sie zu einer anderen Registerkarte des Dialogfelds, indem Sie den Fokus auf das Registerkartenmenü legen und dann die entsprechende <strong>Pfeiltaste</strong>\n' +
|
||||||
|
' drücken, um durch die verfügbaren Registerkarten zu rotieren.</p>\n');
|
||||||
90
assets/vendor/tinymce/plugins/help/js/i18n/keynav/en.js
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
tinymce.Resource.add('tinymce.html-i18n.help-keynav.en',
|
||||||
|
'<h1>Begin keyboard navigation</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<dl>\n' +
|
||||||
|
' <dt>Focus the Menu bar</dt>\n' +
|
||||||
|
' <dd>Windows or Linux: Alt+F9</dd>\n' +
|
||||||
|
' <dd>macOS: ⌥F9</dd>\n' +
|
||||||
|
' <dt>Focus the Toolbar</dt>\n' +
|
||||||
|
' <dd>Windows or Linux: Alt+F10</dd>\n' +
|
||||||
|
' <dd>macOS: ⌥F10</dd>\n' +
|
||||||
|
' <dt>Focus the footer</dt>\n' +
|
||||||
|
' <dd>Windows or Linux: Alt+F11</dd>\n' +
|
||||||
|
' <dd>macOS: ⌥F11</dd>\n' +
|
||||||
|
' <dt>Focus a contextual toolbar</dt>\n' +
|
||||||
|
' <dd>Windows, Linux or macOS: Ctrl+F9\n' +
|
||||||
|
'</dl>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Navigation will start at the first UI item, which will be highlighted, or underlined in the case of the first item in\n' +
|
||||||
|
' the Footer element path.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<h1>Navigate between UI sections</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>To move from one UI section to the next, press <strong>Tab</strong>.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>To move from one UI section to the previous, press <strong>Shift+Tab</strong>.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>The <strong>Tab</strong> order of these UI sections is:</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<ol>\n' +
|
||||||
|
' <li>Menu bar</li>\n' +
|
||||||
|
' <li>Each toolbar group</li>\n' +
|
||||||
|
' <li>Sidebar</li>\n' +
|
||||||
|
' <li>Element path in the footer</li>\n' +
|
||||||
|
' <li>Word count toggle button in the footer</li>\n' +
|
||||||
|
' <li>Branding link in the footer</li>\n' +
|
||||||
|
' <li>Editor resize handle in the footer</li>\n' +
|
||||||
|
'</ol>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>If a UI section is not present, it is skipped.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>If the footer has keyboard navigation focus, and there is no visible sidebar, pressing <strong>Shift+Tab</strong>\n' +
|
||||||
|
' moves focus to the first toolbar group, not the last.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<h1>Navigate within UI sections</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>To move from one UI element to the next, press the appropriate <strong>Arrow</strong> key.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>The <strong>Left</strong> and <strong>Right</strong> arrow keys</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<ul>\n' +
|
||||||
|
' <li>move between menus in the menu bar.</li>\n' +
|
||||||
|
' <li>open a sub-menu in a menu.</li>\n' +
|
||||||
|
' <li>move between buttons in a toolbar group.</li>\n' +
|
||||||
|
' <li>move between items in the footer’s element path.</li>\n' +
|
||||||
|
'</ul>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>The <strong>Down</strong> and <strong>Up</strong> arrow keys</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<ul>\n' +
|
||||||
|
' <li>move between menu items in a menu.</li>\n' +
|
||||||
|
' <li>move between items in a toolbar pop-up menu.</li>\n' +
|
||||||
|
'</ul>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p><strong>Arrow</strong> keys cycle within the focused UI section.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>To close an open menu, an open sub-menu, or an open pop-up menu, press the <strong>Esc</strong> key.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>If the current focus is at the ‘top’ of a particular UI section, pressing the <strong>Esc</strong> key also exits\n' +
|
||||||
|
' keyboard navigation entirely.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<h1>Execute a menu item or toolbar button</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>When the desired menu item or toolbar button is highlighted, press <strong>Return</strong>, <strong>Enter</strong>,\n' +
|
||||||
|
' or the <strong>Space bar</strong> to execute the item.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<h1>Navigate non-tabbed dialogs</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>In non-tabbed dialogs, the first interactive component takes focus when the dialog opens.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Navigate between interactive dialog components by pressing <strong>Tab</strong> or <strong>Shift+Tab</strong>.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<h1>Navigate tabbed dialogs</h1>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>In tabbed dialogs, the first button in the tab menu takes focus when the dialog opens.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Navigate between interactive components of this dialog tab by pressing <strong>Tab</strong> or\n' +
|
||||||
|
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<p>Switch to another dialog tab by giving the tab menu focus and then pressing the appropriate <strong>Arrow</strong>\n' +
|
||||||
|
' key to cycle through the available tabs.</p>\n');
|
||||||
4
assets/vendor/tinymce/plugins/help/plugin.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/link/plugin.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/lists/plugin.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/searchreplace/plugin.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/table/plugin.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/visualblocks/plugin.min.js
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* TinyMCE version 6.8.3 (2024-02-08)
|
||||||
|
*/
|
||||||
|
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const s=(t,s,o)=>{t.dom.toggleClass(t.getBody(),"mce-visualblocks"),o.set(!o.get()),((t,s)=>{t.dispatch("VisualBlocks",{state:s})})(t,o.get())},o=("visualblocks_default_state",t=>t.options.get("visualblocks_default_state"));const e=(t,s)=>o=>{o.setActive(s.get());const e=t=>o.setActive(t.state);return t.on("VisualBlocks",e),()=>t.off("VisualBlocks",e)};t.add("visualblocks",((t,l)=>{(t=>{(0,t.options.register)("visualblocks_default_state",{processor:"boolean",default:!1})})(t);const a=(t=>{let s=!1;return{get:()=>s,set:t=>{s=t}}})();((t,o,e)=>{t.addCommand("mceVisualBlocks",(()=>{s(t,0,e)}))})(t,0,a),((t,s)=>{const o=()=>t.execCommand("mceVisualBlocks");t.ui.registry.addToggleButton("visualblocks",{icon:"visualblocks",tooltip:"Show blocks",onAction:o,onSetup:e(t,s)}),t.ui.registry.addToggleMenuItem("visualblocks",{text:"Show blocks",icon:"visualblocks",onAction:o,onSetup:e(t,s)})})(t,a),((t,e,l)=>{t.on("PreviewFormats AfterPreviewFormats",(s=>{l.get()&&t.dom.toggleClass(t.getBody(),"mce-visualblocks","afterpreviewformats"===s.type)})),t.on("init",(()=>{o(t)&&s(t,0,l)}))})(t,0,a)}))}();
|
||||||
4
assets/vendor/tinymce/plugins/wordcount/plugin.min.js
vendored
Normal file
1
assets/vendor/tinymce/skins/content/dark/content.min.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
body{background-color:#222f3e;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem}
|
||||||
1
assets/vendor/tinymce/skins/content/default/content.min.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}
|
||||||
1
assets/vendor/tinymce/skins/ui/oxide-dark/content.min.css
vendored
Normal file
1
assets/vendor/tinymce/skins/ui/oxide-dark/skin.min.css
vendored
Normal file
1
assets/vendor/tinymce/skins/ui/oxide/content.min.css
vendored
Normal file
1
assets/vendor/tinymce/skins/ui/oxide/skin.min.css
vendored
Normal file
4
assets/vendor/tinymce/themes/silver/theme.min.js
vendored
Normal file
4
assets/vendor/tinymce/tinymce.min.js
vendored
Normal file
|
|
@ -2,6 +2,17 @@
|
||||||
"name": "friloo/unifi-voucher-tool",
|
"name": "friloo/unifi-voucher-tool",
|
||||||
"description": "Webbasiertes WLAN-Voucher-Management für UniFi OS",
|
"description": "Webbasiertes WLAN-Voucher-Management für UniFi OS",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"homepage": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Friederich Loheide",
|
||||||
|
"homepage": "https://loheide.eu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool/issues",
|
||||||
|
"source": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool"
|
||||||
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=7.4"
|
"php": ">=7.4"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
32
database.sql
|
|
@ -66,6 +66,34 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` (
|
||||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `kiosks` (
|
||||||
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
`site_id` INT NOT NULL,
|
||||||
|
`template_id` INT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`token` VARCHAR(64) NOT NULL,
|
||||||
|
`headline` VARCHAR(255) NULL,
|
||||||
|
`subline` VARCHAR(500) NULL,
|
||||||
|
`logo_url` VARCHAR(500) NULL,
|
||||||
|
`background_url` VARCHAR(500) NULL,
|
||||||
|
`bg_overlay` TINYINT NOT NULL DEFAULT 45,
|
||||||
|
`accent_color` VARCHAR(7) NULL,
|
||||||
|
`card_style` ENUM('light','dark') NOT NULL DEFAULT 'light',
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`daily_limit` INT NOT NULL DEFAULT 100,
|
||||||
|
`cooldown_seconds` INT NOT NULL DEFAULT 20,
|
||||||
|
`display_seconds` INT NOT NULL DEFAULT 90,
|
||||||
|
`last_used_at` TIMESTAMP NULL,
|
||||||
|
`created_by` INT NULL,
|
||||||
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY `uniq_token` (`token`),
|
||||||
|
INDEX `idx_site` (`site_id`),
|
||||||
|
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`template_id`) REFERENCES `voucher_templates`(`id`) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `api_keys` (
|
CREATE TABLE IF NOT EXISTS `api_keys` (
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
`name` VARCHAR(255) NOT NULL,
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
|
@ -93,6 +121,7 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
`site_id` INT NOT NULL,
|
`site_id` INT NOT NULL,
|
||||||
`user_id` INT,
|
`user_id` INT,
|
||||||
|
`kiosk_id` INT NULL,
|
||||||
`voucher_code` VARCHAR(50) NOT NULL,
|
`voucher_code` VARCHAR(50) NOT NULL,
|
||||||
`voucher_name` VARCHAR(255) NOT NULL,
|
`voucher_name` VARCHAR(255) NOT NULL,
|
||||||
`max_uses` INT NOT NULL,
|
`max_uses` INT NOT NULL,
|
||||||
|
|
@ -109,7 +138,8 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
|
||||||
INDEX `idx_site` (`site_id`),
|
INDEX `idx_site` (`site_id`),
|
||||||
INDEX `idx_created` (`created_at`),
|
INDEX `idx_created` (`created_at`),
|
||||||
INDEX `idx_unifi_id` (`unifi_voucher_id`),
|
INDEX `idx_unifi_id` (`unifi_voucher_id`),
|
||||||
INDEX `idx_status` (`status`)
|
INDEX `idx_status` (`status`),
|
||||||
|
INDEX `idx_kiosk` (`kiosk_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `sessions` (
|
CREATE TABLE IF NOT EXISTS `sessions` (
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ services:
|
||||||
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
|
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
|
||||||
APP_KEY: ""
|
APP_KEY: ""
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
|
volumes:
|
||||||
|
# Hochgeladene Logos/Hintergruende ueberleben so ein Image-Update
|
||||||
|
- uploads:/var/www/html/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -36,3 +39,4 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
uploads:
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 319 KiB |
BIN
docs/screenshots/kiosk-branded.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/screenshots/kiosk-code.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
docs/screenshots/kiosk-display.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/screenshots/kiosks-admin.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
docs/screenshots/kiosks-form.png
Normal file
|
After Width: | Height: | Size: 446 KiB |
BIN
docs/screenshots/login-branding.png
Normal file
|
After Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 250 KiB |
BIN
docs/screenshots/mobile-vouchers.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/screenshots/settings-branding.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
docs/screenshots/settings-login.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
docs/screenshots/settings.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 1,024 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 972 KiB After Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 751 KiB |
BIN
docs/screenshots/vouchers.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
|
|
@ -7,6 +7,7 @@ require_once __DIR__ . '/config.php';
|
||||||
require_once __DIR__ . '/includes/Database.php';
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
require_once __DIR__ . '/includes/Auth.php';
|
require_once __DIR__ . '/includes/Auth.php';
|
||||||
require_once __DIR__ . '/includes/Mailer.php';
|
require_once __DIR__ . '/includes/Mailer.php';
|
||||||
|
require_once __DIR__ . '/includes/Ui.php';
|
||||||
require_once __DIR__ . '/includes/I18n.php';
|
require_once __DIR__ . '/includes/I18n.php';
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
|
|
@ -83,32 +84,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('reset_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('reset_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<link rel="stylesheet" href="assets/global.css">
|
<?= Ui::head($db) ?>
|
||||||
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
|
||||||
.box { background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); max-width: 420px; width: 100%; padding: 45px 40px; text-align: center; }
|
|
||||||
.logo { max-width: 180px; height: auto; margin-bottom: 25px; }
|
|
||||||
h1 { color: var(--text-primary); font-size: 26px; margin-bottom: 8px; }
|
|
||||||
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 28px; line-height: 1.5; }
|
|
||||||
.form-group { margin-bottom: 18px; text-align: left; }
|
|
||||||
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
|
||||||
input[type="email"] { width: 100%; padding: 13px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; background: var(--bg-input); color: var(--text-primary); transition: border-color 0.2s; }
|
|
||||||
input:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 8px; }
|
|
||||||
.btn:hover { background: var(--accent-hover); transform: translateY(-2px); }
|
|
||||||
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: left; }
|
|
||||||
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
|
|
||||||
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
|
|
||||||
.back-link { display: block; margin-top: 22px; color: var(--accent); text-decoration: none; font-size: 14px; }
|
|
||||||
.back-link:hover { text-decoration: underline; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app-body focus-page">
|
||||||
<div class="box">
|
<div class="focus-card card">
|
||||||
<?php if ($logoUrl): ?>
|
<?php if ($logoUrl): ?>
|
||||||
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
|
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
@ -129,11 +110,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
<label for="email"><?= __('reset_email_label') ?></label>
|
<label for="email"><?= __('reset_email_label') ?></label>
|
||||||
<input type="email" id="email" name="email" required autofocus placeholder="name@example.com">
|
<input type="email" id="email" name="email" required autofocus placeholder="name@example.com">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn"><?= __('reset_send_btn') ?></button>
|
<button type="submit" class="btn btn-primary btn-lg btn-block"><?= __('reset_send_btn') ?></button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<a href="login.php" class="back-link"><?= __('reset_back_login') ?></a>
|
<div class="auth-links"><a href="login.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('reset_back_login') ?></a></div>
|
||||||
</div>
|
</div>
|
||||||
<script src="assets/global.js"></script>
|
<script src="assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
2
includes/.htaccess
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
|
||||||
|
Require all denied
|
||||||
169
includes/Kiosk.php
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Öffentliche Display-Seiten ("Kiosk").
|
||||||
|
*
|
||||||
|
* Ein Kiosk gehört zu genau einer Site und ist über einen geheimen Link
|
||||||
|
* erreichbar. Gäste holen sich darüber mit einem Klick einen Zugangscode –
|
||||||
|
* ohne Anmeldung, aber begrenzt durch Tageslimit und Wartezeit.
|
||||||
|
*/
|
||||||
|
class Kiosk
|
||||||
|
{
|
||||||
|
/** Vorgaben für neue Kiosk-Seiten. */
|
||||||
|
public const DEFAULT_DAILY_LIMIT = 100;
|
||||||
|
public const DEFAULT_COOLDOWN = 20; // Sekunden zwischen zwei Codes
|
||||||
|
public const DEFAULT_DISPLAY_SECONDS = 90; // Anzeigedauer des Codes
|
||||||
|
|
||||||
|
/** Unrat-freier Zufallstoken für den öffentlichen Link. */
|
||||||
|
public static function newToken(): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Token aus einer Anfrage säubern (Länge und Zeichen fest vorgegeben). */
|
||||||
|
public static function sanitizeToken(?string $token): string
|
||||||
|
{
|
||||||
|
$token = strtolower(trim((string)$token));
|
||||||
|
|
||||||
|
return preg_match('/^[0-9a-f]{32}$/', $token) ? $token : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiosk samt Site und Profil laden. Liefert null, wenn der Token nicht
|
||||||
|
* passt, der Kiosk deaktiviert ist oder die Site nicht mehr aktiv ist.
|
||||||
|
*/
|
||||||
|
public static function findByToken($db, string $token): ?array
|
||||||
|
{
|
||||||
|
$token = self::sanitizeToken($token);
|
||||||
|
if ($token === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $db->fetchOne(
|
||||||
|
"SELECT k.*, s.name AS site_name, s.is_active AS site_active,
|
||||||
|
t.name AS template_name, t.max_uses AS tpl_max_uses, t.expire_minutes AS tpl_expire_minutes,
|
||||||
|
t.qos_rate_max_down, t.qos_rate_max_up, t.qos_usage_quota
|
||||||
|
FROM kiosks k
|
||||||
|
INNER JOIN sites s ON s.id = k.site_id
|
||||||
|
LEFT JOIN voucher_templates t ON t.id = k.template_id
|
||||||
|
WHERE k.token = ? AND k.is_active = 1",
|
||||||
|
[$token]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$row || (int)$row['site_active'] !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie viele Codes hat dieser Kiosk heute schon ausgegeben? */
|
||||||
|
public static function usedToday($db, int $kioskId): int
|
||||||
|
{
|
||||||
|
$row = $db->fetchOne(
|
||||||
|
"SELECT COUNT(*) AS c FROM vouchers WHERE kiosk_id = ? AND DATE(created_at) = CURDATE()",
|
||||||
|
[$kioskId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int)($row['c'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darf gerade ein Code geholt werden?
|
||||||
|
*
|
||||||
|
* @return array{allowed:bool,reason:string,wait:int}
|
||||||
|
* reason: '' | 'cooldown' | 'daily_limit'
|
||||||
|
*/
|
||||||
|
public static function checkLimits($db, array $kiosk): array
|
||||||
|
{
|
||||||
|
$cooldown = max(0, (int)$kiosk['cooldown_seconds']);
|
||||||
|
if ($cooldown > 0 && !empty($kiosk['last_used_at'])) {
|
||||||
|
$elapsed = time() - strtotime((string)$kiosk['last_used_at']);
|
||||||
|
if ($elapsed >= 0 && $elapsed < $cooldown) {
|
||||||
|
return ['allowed' => false, 'reason' => 'cooldown', 'wait' => $cooldown - $elapsed];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = max(0, (int)$kiosk['daily_limit']);
|
||||||
|
if ($limit > 0 && self::usedToday($db, (int)$kiosk['id']) >= $limit) {
|
||||||
|
return ['allowed' => false, 'reason' => 'daily_limit', 'wait' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['allowed' => true, 'reason' => '', 'wait' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach erfolgreicher Ausgabe den Zeitstempel fortschreiben. */
|
||||||
|
public static function markUsed($db, int $kioskId): void
|
||||||
|
{
|
||||||
|
$db->execute("UPDATE kiosks SET last_used_at = NOW() WHERE id = ?", [$kioskId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voucher-Eckdaten eines Kiosks: entweder aus dem verknüpften Profil
|
||||||
|
* oder aus den globalen Standardwerten.
|
||||||
|
*/
|
||||||
|
public static function voucherSettings($db, array $kiosk): array
|
||||||
|
{
|
||||||
|
$maxUses = (int)($kiosk['tpl_max_uses'] ?? 0);
|
||||||
|
$expire = (int)($kiosk['tpl_expire_minutes'] ?? 0);
|
||||||
|
|
||||||
|
if ($maxUses < 1) {
|
||||||
|
$maxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
|
||||||
|
}
|
||||||
|
if ($expire < 1) {
|
||||||
|
$expire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'max_uses' => $maxUses,
|
||||||
|
'expire_minutes' => $expire,
|
||||||
|
'qos' => [
|
||||||
|
'down' => max(0, (int)($kiosk['qos_rate_max_down'] ?? 0)),
|
||||||
|
'up' => max(0, (int)($kiosk['qos_rate_max_up'] ?? 0)),
|
||||||
|
'quota_mb' => max(0, (int)($kiosk['qos_usage_quota'] ?? 0)),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestaltung einer Display-Seite: eigene Werte, sonst die des Systems.
|
||||||
|
*
|
||||||
|
* @return array{logo:string,background:string,overlay:float,accent:string,card:string}
|
||||||
|
*/
|
||||||
|
public static function appearance($db, array $kiosk): array
|
||||||
|
{
|
||||||
|
$accent = strtolower(trim((string)($kiosk['accent_color'] ?? '')));
|
||||||
|
if (!preg_match('/^#[0-9a-f]{6}$/', $accent)) {
|
||||||
|
$accent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$overlay = (int)($kiosk['bg_overlay'] ?? 45);
|
||||||
|
$overlay = max(0, min(90, $overlay));
|
||||||
|
|
||||||
|
$logo = trim((string)($kiosk['logo_url'] ?? ''));
|
||||||
|
if ($logo === '' && $db) {
|
||||||
|
$logo = (string)$db->getSetting('logo_url', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'logo' => $logo,
|
||||||
|
'background' => trim((string)($kiosk['background_url'] ?? '')),
|
||||||
|
'overlay' => (float)$overlay / 100, // immer float, auch bei 0
|
||||||
|
'accent' => $accent,
|
||||||
|
'card' => ($kiosk['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffentliche Adresse eines Kiosks. */
|
||||||
|
public static function publicUrl(string $token, string $baseUrl = ''): string
|
||||||
|
{
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
$path = dirname($_SERVER['SCRIPT_NAME'] ?? '/', 2);
|
||||||
|
$path = $path === '/' || $path === '\\' ? '' : $path;
|
||||||
|
$baseUrl = $protocol . '://' . $host . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim($baseUrl, '/') . '/kiosk.php?k=' . $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,265 +1,339 @@
|
||||||
<?php
|
<?php
|
||||||
class Mailer {
|
class Mailer {
|
||||||
private $db;
|
private $db;
|
||||||
private $smtpEnabled;
|
private $smtpEnabled;
|
||||||
private $smtpHost;
|
private $smtpHost;
|
||||||
private $smtpPort;
|
private $smtpPort;
|
||||||
private $smtpUsername;
|
private $smtpUsername;
|
||||||
private $smtpPassword;
|
private $smtpPassword;
|
||||||
private $smtpEncryption;
|
private $smtpEncryption;
|
||||||
private $fromEmail;
|
private $fromEmail;
|
||||||
private $fromName;
|
private $fromName;
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
$this->db = Database::getInstance();
|
$this->db = Database::getInstance();
|
||||||
$this->loadSettings();
|
$this->loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadSettings() {
|
private function loadSettings() {
|
||||||
$this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1';
|
$this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1';
|
||||||
$this->smtpHost = $this->db->getSetting('smtp_host', '');
|
$this->smtpHost = $this->db->getSetting('smtp_host', '');
|
||||||
$this->smtpPort = (int)$this->db->getSetting('smtp_port', '587');
|
$this->smtpPort = (int)$this->db->getSetting('smtp_port', '587');
|
||||||
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
|
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
|
||||||
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
|
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
|
||||||
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
|
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
|
||||||
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']);
|
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']);
|
||||||
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
|
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendRaw($to, $subject, $plainBody) {
|
public function sendRaw($to, $subject, $plainBody) {
|
||||||
return $this->send($to, $subject, $plainBody, false);
|
return $this->send($to, $subject, $plainBody, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send($to, $subject, $body, $isHtml = false) {
|
public function send($to, $subject, $body, $isHtml = false) {
|
||||||
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
|
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
|
||||||
$attempts = 2;
|
$attempts = 2;
|
||||||
for ($i = 1; $i <= $attempts; $i++) {
|
for ($i = 1; $i <= $attempts; $i++) {
|
||||||
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
||||||
$ok = $this->sendWithPhpMail($to, $subject, $body);
|
$ok = $this->sendWithPhpMail($to, $subject, $body);
|
||||||
} else {
|
} else {
|
||||||
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
||||||
}
|
}
|
||||||
if ($ok) {
|
if ($ok) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ($i < $attempts) {
|
if ($i < $attempts) {
|
||||||
usleep(500000); // 0,5s vor erneutem Versuch
|
usleep(500000); // 0,5s vor erneutem Versuch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
|
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sendWithPhpMail($to, $subject, $body) {
|
private function sendWithPhpMail($to, $subject, $body) {
|
||||||
$headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
$headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
||||||
$headers .= "Reply-To: {$this->fromEmail}\r\n";
|
$headers .= "Reply-To: {$this->fromEmail}\r\n";
|
||||||
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
|
|
||||||
return mail($to, $subject, $body, $headers);
|
return mail($to, $subject, $body, $headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
|
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
|
||||||
try {
|
try {
|
||||||
// Verbindung aufbauen
|
// Verbindung aufbauen
|
||||||
$socket = $this->connectToSmtp();
|
$socket = $this->connectToSmtp();
|
||||||
|
|
||||||
// EHLO
|
// EHLO
|
||||||
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
||||||
|
|
||||||
// STARTTLS wenn nötig
|
// STARTTLS wenn nötig
|
||||||
if ($this->smtpEncryption === 'tls') {
|
if ($this->smtpEncryption === 'tls') {
|
||||||
$this->smtpCommand($socket, "STARTTLS");
|
$this->smtpCommand($socket, "STARTTLS");
|
||||||
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
|
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
|
||||||
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// AUTH LOGIN
|
// AUTH LOGIN
|
||||||
$this->smtpCommand($socket, "AUTH LOGIN");
|
$this->smtpCommand($socket, "AUTH LOGIN");
|
||||||
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
|
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
|
||||||
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
|
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
|
||||||
|
|
||||||
// MAIL FROM
|
// MAIL FROM
|
||||||
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
|
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
|
||||||
|
|
||||||
// RCPT TO
|
// RCPT TO
|
||||||
$this->smtpCommand($socket, "RCPT TO:<{$to}>");
|
$this->smtpCommand($socket, "RCPT TO:<{$to}>");
|
||||||
|
|
||||||
// DATA
|
// DATA
|
||||||
$this->smtpCommand($socket, "DATA");
|
$this->smtpCommand($socket, "DATA");
|
||||||
|
|
||||||
// Headers
|
// Headers
|
||||||
$message = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
$message = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
||||||
$message .= "To: {$to}\r\n";
|
$message .= "To: {$to}\r\n";
|
||||||
$message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
|
$message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
|
||||||
$message .= "MIME-Version: 1.0\r\n";
|
$message .= "MIME-Version: 1.0\r\n";
|
||||||
|
|
||||||
if ($isHtml) {
|
if ($isHtml) {
|
||||||
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
|
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||||
} else {
|
} else {
|
||||||
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
$message .= "\r\n";
|
$message .= "\r\n";
|
||||||
|
|
||||||
// Body - bei Plain Text Zeilenumbrüche konvertieren
|
// Body - bei Plain Text Zeilenumbrüche konvertieren
|
||||||
if (!$isHtml) {
|
if (!$isHtml) {
|
||||||
$body = nl2br($body, false); // Für Plain Text
|
$body = nl2br($body, false); // Für Plain Text
|
||||||
$body = str_replace('<br>', "\r\n", $body);
|
$body = str_replace('<br>', "\r\n", $body);
|
||||||
}
|
}
|
||||||
|
|
||||||
$message .= $body;
|
$message .= $body;
|
||||||
$message .= "\r\n.\r\n";
|
$message .= "\r\n.\r\n";
|
||||||
|
|
||||||
fwrite($socket, $message);
|
fwrite($socket, $message);
|
||||||
$response = fgets($socket);
|
$response = fgets($socket);
|
||||||
|
|
||||||
// QUIT
|
// QUIT
|
||||||
$this->smtpCommand($socket, "QUIT");
|
$this->smtpCommand($socket, "QUIT");
|
||||||
fclose($socket);
|
fclose($socket);
|
||||||
|
|
||||||
return strpos($response, '250') === 0;
|
return strpos($response, '250') === 0;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("SMTP Error: " . $e->getMessage());
|
error_log("SMTP Error: " . $e->getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function connectToSmtp() {
|
private function connectToSmtp() {
|
||||||
$context = stream_context_create([
|
$context = stream_context_create([
|
||||||
'ssl' => [
|
'ssl' => [
|
||||||
'verify_peer' => false,
|
'verify_peer' => false,
|
||||||
'verify_peer_name' => false,
|
'verify_peer_name' => false,
|
||||||
'allow_self_signed' => true
|
'allow_self_signed' => true
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->smtpEncryption === 'ssl') {
|
if ($this->smtpEncryption === 'ssl') {
|
||||||
$host = 'ssl://' . $this->smtpHost;
|
$host = 'ssl://' . $this->smtpHost;
|
||||||
} else {
|
} else {
|
||||||
$host = $this->smtpHost;
|
$host = $this->smtpHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
$socket = stream_socket_client(
|
$socket = stream_socket_client(
|
||||||
$host . ':' . $this->smtpPort,
|
$host . ':' . $this->smtpPort,
|
||||||
$errno,
|
$errno,
|
||||||
$errstr,
|
$errstr,
|
||||||
30,
|
30,
|
||||||
STREAM_CLIENT_CONNECT,
|
STREAM_CLIENT_CONNECT,
|
||||||
$context
|
$context
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$socket) {
|
if (!$socket) {
|
||||||
throw new Exception("SMTP Connection failed: $errstr ($errno)");
|
throw new Exception("SMTP Connection failed: $errstr ($errno)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Willkommensnachricht lesen
|
// Willkommensnachricht lesen
|
||||||
fgets($socket);
|
fgets($socket);
|
||||||
|
|
||||||
return $socket;
|
return $socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function smtpCommand($socket, $command) {
|
private function smtpCommand($socket, $command) {
|
||||||
fwrite($socket, $command . "\r\n");
|
fwrite($socket, $command . "\r\n");
|
||||||
$response = fgets($socket);
|
$response = fgets($socket);
|
||||||
|
|
||||||
// Prüfen auf Fehler (4xx oder 5xx)
|
// Prüfen auf Fehler (4xx oder 5xx)
|
||||||
if (preg_match('/^[45]/', $response)) {
|
if (preg_match('/^[45]/', $response)) {
|
||||||
throw new Exception("SMTP Error: $response");
|
throw new Exception("SMTP Error: $response");
|
||||||
}
|
}
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vordefinierte E-Mail-Templates
|
// Vordefinierte E-Mail-Templates
|
||||||
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
|
/**
|
||||||
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
* Legt den Nachrichtentext in ein schlichtes, markentreues HTML-Gerüst.
|
||||||
$instructionHeader = $this->db->getSetting('instruction_header', '');
|
* Bewusst Tabellen + Inline-Styles: nur so rendern Outlook & Co. zuverlässig.
|
||||||
$instructionText = $this->db->getSetting('instruction_text', '');
|
*/
|
||||||
|
private function brandedHtml(string $title, string $contentHtml, string $footerNote = ''): string
|
||||||
// System-URL aus Einstellungen oder automatisch erkennen
|
{
|
||||||
$systemUrl = $this->db->getSetting('system_url', '');
|
$accent = $this->db->getSetting('brand_gradient_from', '') ?: '#5b5bd6';
|
||||||
if (empty($systemUrl)) {
|
$accent2 = $this->db->getSetting('brand_gradient_to', '') ?: '#8b5cf6';
|
||||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent)) { $accent = '#5b5bd6'; }
|
||||||
$host = $_SERVER['HTTP_HOST'];
|
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent2)) { $accent2 = '#8b5cf6'; }
|
||||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
|
||||||
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
|
||||||
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
$year = date('Y');
|
||||||
}
|
$footer = $footerNote !== '' ? '<div style="margin-top:8px;">' . htmlspecialchars($footerNote, ENT_QUOTES, 'UTF-8') . '</div>' : '';
|
||||||
|
|
||||||
// Template aus Datenbank laden
|
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
|
||||||
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang');
|
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
|
||||||
$bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nIhr WLAN-Zugangscode lautet:\n\n<strong>{VOUCHER_CODE}</strong>\n\nGültigkeit: 8 Stunden ab Erstellung\nMaximale Geräte: {MAX_USES}\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
|
. '<title>' . $safeTitle . '</title></head>'
|
||||||
|
. '<body style="margin:0;padding:0;background:#f6f7f9;">'
|
||||||
// Anleitung formatieren
|
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f7f9;padding:28px 12px;">'
|
||||||
$instructions = '';
|
. '<tr><td align="center">'
|
||||||
if ($instructionText) {
|
. '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border:1px solid #e5e8ee;border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;">'
|
||||||
$instructions = $instructionHeader . "\n" . $instructionText;
|
. '<tr><td style="background:' . $accent . ';background-image:linear-gradient(135deg,' . $accent . ' 0%,' . $accent2 . ' 100%);padding:22px 26px;">'
|
||||||
}
|
. '<div style="color:#ffffff;font-size:16px;font-weight:600;letter-spacing:-0.01em;">' . $safeTitle . '</div>'
|
||||||
|
. '</td></tr>'
|
||||||
// Platzhalter ersetzen
|
. '<tr><td style="padding:26px;color:#101625;font-size:15px;line-height:1.6;">' . $contentHtml . '</td></tr>'
|
||||||
$placeholders = [
|
. '<tr><td style="padding:16px 26px;background:#f8f9fb;border-top:1px solid #e5e8ee;color:#6b7280;font-size:12px;">'
|
||||||
'{VOUCHER_CODE}' => $voucherCode,
|
. '© ' . $year . ' ' . $safeTitle . $footer
|
||||||
'{SITE_NAME}' => $siteName,
|
. '</td></tr>'
|
||||||
'{MAX_USES}' => $maxUses,
|
. '</table></td></tr></table></body></html>';
|
||||||
'{APP_TITLE}' => $appTitle,
|
}
|
||||||
'{INSTRUCTIONS}' => $instructions,
|
|
||||||
'{SYSTEM_URL}' => $systemUrl
|
/**
|
||||||
];
|
* Voucher-Code als hervorgehobene Karte für die E-Mail.
|
||||||
|
*/
|
||||||
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
private function voucherCardHtml(string $code, string $siteName, $maxUses): string
|
||||||
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
|
{
|
||||||
|
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
||||||
// HTML oder Plain Text prüfen
|
. 'style="margin:18px 0;background:#f8f9fb;border:1px solid #e5e8ee;border-radius:12px;">'
|
||||||
$isHtml = strip_tags($body) !== $body;
|
. '<tr><td align="center" style="padding:22px;">'
|
||||||
|
. '<div style="font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">'
|
||||||
return $this->send($to, $subject, $body, $isHtml);
|
. htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '</div>'
|
||||||
}
|
. '<div style="margin:10px 0;font-family:Consolas,Menlo,monospace;font-size:28px;font-weight:700;letter-spacing:.12em;color:#101625;">'
|
||||||
|
. htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</div>'
|
||||||
public function sendTestEmail($to) {
|
. '<div style="font-size:13px;color:#525c6e;">'
|
||||||
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
. htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' '
|
||||||
$subject = '[Test] E-Mail-Konfiguration – ' . $appTitle;
|
. htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '</div>'
|
||||||
$body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet.";
|
. '</td></tr></table>';
|
||||||
return $this->send($to, $subject, $body, false);
|
}
|
||||||
}
|
|
||||||
|
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
|
||||||
public function sendUserNotification($to, $userName, $changes) {
|
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
$instructionHeader = $this->db->getSetting('instruction_header', '');
|
||||||
|
$instructionText = $this->db->getSetting('instruction_text', '');
|
||||||
// System-URL aus Einstellungen oder automatisch erkennen
|
|
||||||
$systemUrl = $this->db->getSetting('system_url', '');
|
// System-URL aus Einstellungen oder automatisch erkennen
|
||||||
if (empty($systemUrl)) {
|
$systemUrl = $this->db->getSetting('system_url', '');
|
||||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
if (empty($systemUrl)) {
|
||||||
$host = $_SERVER['HTTP_HOST'];
|
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
$host = $_SERVER['HTTP_HOST'];
|
||||||
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||||
}
|
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
||||||
|
}
|
||||||
// Template aus Datenbank laden
|
|
||||||
$subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert');
|
// Template aus Datenbank laden
|
||||||
$bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
|
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang');
|
||||||
|
$bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\n<strong>Maximale Geräte:</strong> {MAX_USES}<br>\n<strong>Standort:</strong> {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}");
|
||||||
// Änderungen formatieren
|
|
||||||
$changesText = '';
|
// Anleitung formatieren
|
||||||
foreach ($changes as $change) {
|
$instructions = '';
|
||||||
$changesText .= "• $change\n";
|
if ($instructionText) {
|
||||||
}
|
$instructions = $instructionHeader . "\n" . $instructionText;
|
||||||
|
}
|
||||||
// Platzhalter ersetzen
|
|
||||||
$placeholders = [
|
// Platzhalter ersetzen
|
||||||
'{USER_NAME}' => $userName,
|
$placeholders = [
|
||||||
'{CHANGES}' => $changesText,
|
'{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses),
|
||||||
'{APP_TITLE}' => $appTitle,
|
'{VOUCHER_CODE}' => $voucherCode,
|
||||||
'{SYSTEM_URL}' => $systemUrl
|
'{SITE_NAME}' => $siteName,
|
||||||
];
|
'{MAX_USES}' => $maxUses,
|
||||||
|
'{APP_TITLE}' => $appTitle,
|
||||||
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
'{INSTRUCTIONS}' => $instructions,
|
||||||
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
|
'{SYSTEM_URL}' => $systemUrl
|
||||||
|
];
|
||||||
// HTML oder Plain Text prüfen
|
|
||||||
$isHtml = strip_tags($body) !== $body;
|
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
||||||
|
|
||||||
return $this->send($to, $subject, $body, $isHtml);
|
// Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln,
|
||||||
}
|
// sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren.
|
||||||
|
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate || strpos($bodyTemplate, '{VOUCHER_CARD}') !== false;
|
||||||
|
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
|
||||||
|
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
|
||||||
|
|
||||||
|
if ($isHtml) {
|
||||||
|
$body = $this->brandedHtml($appTitle, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->send($to, $subject, $body, $isHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendTestEmail($to) {
|
||||||
|
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
|
$subject = '[Test] E-Mail-Konfiguration – ' . $appTitle;
|
||||||
|
$body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet.";
|
||||||
|
return $this->send($to, $subject, $body, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendUserNotification($to, $userName, $changes) {
|
||||||
|
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
|
|
||||||
|
// System-URL aus Einstellungen oder automatisch erkennen
|
||||||
|
$systemUrl = $this->db->getSetting('system_url', '');
|
||||||
|
if (empty($systemUrl)) {
|
||||||
|
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'];
|
||||||
|
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
|
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||||
|
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template aus Datenbank laden
|
||||||
|
$subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert');
|
||||||
|
$bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
|
||||||
|
|
||||||
|
// Änderungen formatieren
|
||||||
|
$changesText = '';
|
||||||
|
foreach ($changes as $change) {
|
||||||
|
$changesText .= "• $change\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platzhalter ersetzen
|
||||||
|
$placeholders = [
|
||||||
|
'{USER_NAME}' => $userName,
|
||||||
|
'{CHANGES}' => $changesText,
|
||||||
|
'{APP_TITLE}' => $appTitle,
|
||||||
|
'{SYSTEM_URL}' => $systemUrl
|
||||||
|
];
|
||||||
|
|
||||||
|
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
||||||
|
|
||||||
|
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate;
|
||||||
|
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
|
||||||
|
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
|
||||||
|
|
||||||
|
if ($isHtml) {
|
||||||
|
$body = $this->brandedHtml($appTitle, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->send($to, $subject, $body, $isHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeilenumbrüche aus dem Vorlagentext in HTML übernehmen, ohne bereits
|
||||||
|
* vorhandenes Markup (z. B. aus dem WYSIWYG-Editor) zu zerstören.
|
||||||
|
*/
|
||||||
|
private function textToHtml(string $body): string
|
||||||
|
{
|
||||||
|
if (preg_match('#<(p|div|ul|ol|h[1-6])[\s>]#i', $body)) {
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nl2br($body, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
259
includes/Ui.php
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gemeinsame Bausteine fuer den Seitenkopf.
|
||||||
|
*
|
||||||
|
* - liefert versionierte Asset-URLs (Cache-Busting nach Updates)
|
||||||
|
* - bindet die lokal ausgelieferten Assets ein (keine externen CDNs)
|
||||||
|
* - erzeugt die Branding-Overrides aus den Einstellungen
|
||||||
|
*
|
||||||
|
* Alle Methoden funktionieren auch ohne Datenbank ($db = null), damit der
|
||||||
|
* Installer dieselbe Optik nutzen kann.
|
||||||
|
*/
|
||||||
|
class Ui
|
||||||
|
{
|
||||||
|
/** Standardwerte des Design-Systems (siehe assets/global.css). */
|
||||||
|
public const DEFAULT_ACCENT = '#5b5bd6';
|
||||||
|
public const DEFAULT_ACCENT_DARK = '#8b8bf5';
|
||||||
|
public const DEFAULT_GRADIENT_FROM = '#5b5bd6';
|
||||||
|
public const DEFAULT_GRADIENT_TO = '#8b5cf6';
|
||||||
|
public const DEFAULT_RADIUS = 14;
|
||||||
|
|
||||||
|
/** Projektwurzel im Dateisystem. */
|
||||||
|
private static function root(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL eines Projekt-Assets inkl. Versionsstempel.
|
||||||
|
* $base ist der Pfad zur Projektwurzel ('' im Root, '../' in /admin).
|
||||||
|
*/
|
||||||
|
public static function asset(string $path, string $base = ''): string
|
||||||
|
{
|
||||||
|
$file = self::root() . '/' . ltrim($path, '/');
|
||||||
|
$version = is_file($file) ? (string)filemtime($file) : '0';
|
||||||
|
|
||||||
|
return $base . $path . '?v=' . $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <script src> mit Versionsstempel. */
|
||||||
|
public static function script(string $path, string $base = '', bool $defer = false): string
|
||||||
|
{
|
||||||
|
return '<script src="' . htmlspecialchars(self::asset($path, $base)) . '"'
|
||||||
|
. ($defer ? ' defer' : '') . '></script>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme-Bootstrap: gespeicherte Auswahl, sonst Systemeinstellung.
|
||||||
|
* Muss im <head> stehen, damit nichts hell aufblitzt.
|
||||||
|
*/
|
||||||
|
public static function themeScript(): string
|
||||||
|
{
|
||||||
|
return '<script>(function(){'
|
||||||
|
. 'var s=localStorage.getItem("theme");'
|
||||||
|
. 'var t=s||(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");'
|
||||||
|
. 'document.documentElement.setAttribute("data-theme",t);'
|
||||||
|
. '})();</script>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gueltige Hex-Farbe oder Fallback. */
|
||||||
|
private static function color(?string $value, string $fallback): string
|
||||||
|
{
|
||||||
|
$value = trim((string)$value);
|
||||||
|
|
||||||
|
return preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtolower($value) : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS-Overrides fuer die Markenfarben. Gibt einen leeren String zurueck,
|
||||||
|
* wenn nichts vom Standard abweicht.
|
||||||
|
*/
|
||||||
|
public static function brandingStyle($db = null): string
|
||||||
|
{
|
||||||
|
if (!$db) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$accent = self::color($db->getSetting('brand_accent', ''), self::DEFAULT_ACCENT);
|
||||||
|
$accentDark = self::color($db->getSetting('brand_accent_dark', ''), self::DEFAULT_ACCENT_DARK);
|
||||||
|
$from = self::color($db->getSetting('brand_gradient_from', ''), self::DEFAULT_GRADIENT_FROM);
|
||||||
|
$to = self::color($db->getSetting('brand_gradient_to', ''), self::DEFAULT_GRADIENT_TO);
|
||||||
|
$radius = (int)$db->getSetting('brand_radius', (string)self::DEFAULT_RADIUS);
|
||||||
|
$radius = max(0, min(28, $radius));
|
||||||
|
|
||||||
|
$isDefault = $accent === self::DEFAULT_ACCENT
|
||||||
|
&& $accentDark === self::DEFAULT_ACCENT_DARK
|
||||||
|
&& $from === self::DEFAULT_GRADIENT_FROM
|
||||||
|
&& $to === self::DEFAULT_GRADIENT_TO
|
||||||
|
&& $radius === self::DEFAULT_RADIUS;
|
||||||
|
|
||||||
|
if ($isDefault) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abgeleitete Töne über color-mix – so genügt eine einzige Grundfarbe.
|
||||||
|
return '<style>'
|
||||||
|
. ':root{'
|
||||||
|
. "--accent:{$accent};"
|
||||||
|
. "--accent-hover:color-mix(in srgb, {$accent} 84%, #000);"
|
||||||
|
. "--accent-soft:color-mix(in srgb, {$accent} 12%, #fff);"
|
||||||
|
. "--accent-border:color-mix(in srgb, {$accent} 32%, #fff);"
|
||||||
|
. "--accent-2:{$to};"
|
||||||
|
. "--input-focus:{$accent};"
|
||||||
|
. "--ring:0 0 0 4px color-mix(in srgb, {$accent} 22%, transparent);"
|
||||||
|
. "--brand-gradient:linear-gradient(135deg, {$from} 0%, {$to} 100%);"
|
||||||
|
. "--r-lg:{$radius}px;"
|
||||||
|
. "--r-xl:" . ($radius + 6) . 'px;'
|
||||||
|
. '}'
|
||||||
|
. '[data-theme="dark"]{'
|
||||||
|
. "--accent:{$accentDark};"
|
||||||
|
. "--accent-hover:color-mix(in srgb, {$accentDark} 80%, #fff);"
|
||||||
|
. "--accent-soft:color-mix(in srgb, {$accentDark} 20%, #0a0c11);"
|
||||||
|
. "--accent-border:color-mix(in srgb, {$accentDark} 42%, #0a0c11);"
|
||||||
|
. "--input-focus:{$accentDark};"
|
||||||
|
. "--ring:0 0 0 4px color-mix(in srgb, {$accentDark} 26%, transparent);"
|
||||||
|
. '}'
|
||||||
|
. '</style>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildfeld mit Vorschau, Upload und URL-Eingabe.
|
||||||
|
* Wird von den Einstellungen und der Kiosk-Verwaltung genutzt.
|
||||||
|
*/
|
||||||
|
public static function imageField(
|
||||||
|
string $name,
|
||||||
|
string $label,
|
||||||
|
?string $value,
|
||||||
|
string $hint = '',
|
||||||
|
string $accept = 'image/*',
|
||||||
|
string $base = '../'
|
||||||
|
): string {
|
||||||
|
$value = (string)$value;
|
||||||
|
$preview = self::mediaUrl($value, $base);
|
||||||
|
$esc = static fn ($text) => htmlspecialchars((string)$text, ENT_QUOTES);
|
||||||
|
$t = static fn ($key, $fallback) => function_exists('__') ? __($key) : $fallback;
|
||||||
|
|
||||||
|
$thumb = $preview !== ''
|
||||||
|
? '<img src="' . $esc($preview) . '" alt="">'
|
||||||
|
: '<i class="fas fa-image" aria-hidden="true"></i>';
|
||||||
|
|
||||||
|
$remove = $value !== ''
|
||||||
|
? '<label class="chk"><input type="checkbox" name="' . $esc($name) . '_remove" value="1"> '
|
||||||
|
. $esc($t('settings_image_remove', 'Bild entfernen')) . '</label>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return '<div class="form-group">'
|
||||||
|
. '<label>' . $esc($label) . '</label>'
|
||||||
|
. '<div class="image-field">'
|
||||||
|
. '<div class="image-preview">' . $thumb . '</div>'
|
||||||
|
. '<div class="image-field-controls">'
|
||||||
|
. '<input type="file" name="' . $esc($name) . '_file" accept="' . $esc($accept) . '">'
|
||||||
|
. '<input type="text" name="' . $esc($name) . '" value="' . $esc($value) . '" '
|
||||||
|
. 'placeholder="' . $esc($t('settings_image_url_placeholder', 'https://… oder Datei hochladen')) . '">'
|
||||||
|
. $remove
|
||||||
|
. '</div></div>'
|
||||||
|
. ($hint !== '' ? '<div class="help-text">' . $esc($hint) . '</div>' : '')
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version aus der Datei VERSION im Projektstamm.
|
||||||
|
* Damit tragen Oberfläche und Release-Paket dieselbe Nummer.
|
||||||
|
*/
|
||||||
|
public static function version(): string
|
||||||
|
{
|
||||||
|
static $version = null;
|
||||||
|
if ($version === null) {
|
||||||
|
$file = self::root() . '/VERSION';
|
||||||
|
$version = is_file($file) ? trim((string)file_get_contents($file)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entwicklerhinweis – bewusst an einer Stelle gepflegt. */
|
||||||
|
public const CREDIT_NAME = 'Loheide.eu';
|
||||||
|
public const CREDIT_URL = 'https://loheide.eu';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dezenter Hinweis auf den Entwickler, wie er im Seitenfuß erscheint.
|
||||||
|
*/
|
||||||
|
public static function credit(bool $withVersion = false): string
|
||||||
|
{
|
||||||
|
$label = function_exists('__') ? __('credit_by') : 'Entwickelt von';
|
||||||
|
|
||||||
|
$prefix = '';
|
||||||
|
if ($withVersion && self::version() !== '') {
|
||||||
|
$prefix = 'v' . htmlspecialchars(self::version()) . ' · ';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<p class="app-credit">' . $prefix . htmlspecialchars($label) . ' '
|
||||||
|
. '<a href="' . self::CREDIT_URL . '" target="_blank" rel="noopener">'
|
||||||
|
. self::CREDIT_NAME . '</a></p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard-Druckvorlage (wird nur verwendet, solange keine eigene
|
||||||
|
* Vorlage gespeichert ist). {QR_CODE} fuellt der Browser.
|
||||||
|
*/
|
||||||
|
public static function defaultPrintTemplate(): string
|
||||||
|
{
|
||||||
|
$validUntil = function_exists('__') ? __('print_valid_until') : 'Gültig bis';
|
||||||
|
$devices = function_exists('__') ? __('print_devices') : 'Geräte';
|
||||||
|
|
||||||
|
return '<div style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
||||||
|
. 'max-width:420px;margin:0 auto;padding:26px;border:1px dashed #9aa1ae;border-radius:14px;text-align:center;">'
|
||||||
|
. '<div style="font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">{APP_TITLE}</div>'
|
||||||
|
. '<div style="margin:6px 0 18px;font-size:15px;color:#101625;">{SITE_NAME}</div>'
|
||||||
|
. '{QR_CODE}'
|
||||||
|
. '<div style="margin:18px 0 6px;font-family:Consolas,Menlo,monospace;font-size:30px;font-weight:700;letter-spacing:.14em;color:#101625;">{VOUCHER_CODE}</div>'
|
||||||
|
. '<div style="font-size:13px;color:#525c6e;">' . $validUntil . ' {EXPIRY_DATE} {EXPIRY_TIME} · {MAX_USES} ' . $devices . '</div>'
|
||||||
|
. '<div style="margin-top:16px;padding-top:14px;border-top:1px solid #e5e8ee;font-size:12px;color:#525c6e;text-align:left;">{INSTRUCTIONS}</div>'
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL eines Bildes aus den Einstellungen.
|
||||||
|
* Hochgeladene Dateien liegen relativ zur Projektwurzel (uploads/…),
|
||||||
|
* externe Adressen bleiben unveraendert.
|
||||||
|
*/
|
||||||
|
public static function mediaUrl(string $value, string $base = ''): string
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (preg_match('#^(https?:)?//#i', $value) || strncmp($value, 'data:', 5) === 0 || $value[0] === '/') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base . $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompletter Standard-Kopf: Favicon, Schrift, Icons, Design-System,
|
||||||
|
* Theme-Bootstrap und Branding.
|
||||||
|
*/
|
||||||
|
public static function head($db = null, string $base = ''): string
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
$favicon = $db ? self::mediaUrl((string)$db->getSetting('favicon_url', ''), $base) : '';
|
||||||
|
if ($favicon !== '') {
|
||||||
|
$out[] = '<link rel="icon" href="' . htmlspecialchars($favicon) . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = '<meta name="color-scheme" content="light dark">';
|
||||||
|
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/vendor/inter/inter.css', $base)) . '">';
|
||||||
|
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/vendor/fontawesome/fontawesome.css', $base)) . '">';
|
||||||
|
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/global.css', $base)) . '">';
|
||||||
|
$out[] = self::themeScript();
|
||||||
|
|
||||||
|
$branding = self::brandingStyle($db);
|
||||||
|
if ($branding !== '') {
|
||||||
|
$out[] = $branding;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n ", $out);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
includes/Upload.php
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Datei-Uploads fuer Branding-Bilder (Logo, Favicon, Hintergrund).
|
||||||
|
*
|
||||||
|
* Bewusst eng gefasst: nur Bilder, kleine Groesse, zufaelliger Dateiname,
|
||||||
|
* Ablage in uploads/ (dort ist die PHP-Ausfuehrung per .htaccess gesperrt).
|
||||||
|
* SVG-Dateien werden vor dem Speichern von aktiven Inhalten befreit.
|
||||||
|
*/
|
||||||
|
class Upload
|
||||||
|
{
|
||||||
|
public const MAX_BYTES = 3145728; // 3 MB
|
||||||
|
|
||||||
|
/** Erlaubte Endungen je Einsatzzweck. */
|
||||||
|
private const ALLOWED = [
|
||||||
|
'image' => ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'],
|
||||||
|
'favicon' => ['ico', 'png', 'svg'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebersetzte Meldung – faellt auf Deutsch zurueck, wenn die Klasse
|
||||||
|
* ausserhalb einer Seite mit geladener I18n verwendet wird.
|
||||||
|
*/
|
||||||
|
private static function msg(string $key, string $fallback): string
|
||||||
|
{
|
||||||
|
return function_exists('__') ? __($key) : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dir(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__) . '/uploads';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt das Upload-Verzeichnis inkl. Schutzdatei an. */
|
||||||
|
public static function ensureDir(): bool
|
||||||
|
{
|
||||||
|
$dir = self::dir();
|
||||||
|
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$htaccess = $dir . '/.htaccess';
|
||||||
|
if (!file_exists($htaccess)) {
|
||||||
|
@file_put_contents($htaccess, "php_flag engine off\nOptions -ExecCGI\n<FilesMatch \"\\.(php|phtml|phar)$\">\n Require all denied\n</FilesMatch>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_writable($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist der Pfad eine von uns gespeicherte Datei? */
|
||||||
|
public static function isLocal(string $path): bool
|
||||||
|
{
|
||||||
|
return $path !== '' && strncmp($path, 'uploads/', 8) === 0 && strpos($path, '..') === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loescht eine zuvor hochgeladene Datei (externe URLs bleiben unberuehrt). */
|
||||||
|
public static function delete(string $path): void
|
||||||
|
{
|
||||||
|
if (!self::isLocal($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$file = dirname(__DIR__) . '/' . $path;
|
||||||
|
if (is_file($file)) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt einen Upload entgegen und gibt den relativen Pfad zurueck.
|
||||||
|
*
|
||||||
|
* @param array $file Eintrag aus $_FILES
|
||||||
|
* @param string $kind 'image' oder 'favicon'
|
||||||
|
* @throws RuntimeException bei ungueltigen Dateien
|
||||||
|
*/
|
||||||
|
public static function store(array $file, string $kind = 'image'): string
|
||||||
|
{
|
||||||
|
if (!isset($file['error']) || $file['error'] === UPLOAD_ERR_NO_FILE) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.'));
|
||||||
|
}
|
||||||
|
if (!is_uploaded_file($file['tmp_name'])) {
|
||||||
|
throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.'));
|
||||||
|
}
|
||||||
|
if ($file['size'] > self::MAX_BYTES) {
|
||||||
|
throw new RuntimeException(self::msg('upload_error_size', 'Die Datei ist zu groß (maximal 3 MB).'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = self::ALLOWED[$kind] ?? self::ALLOWED['image'];
|
||||||
|
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||||
|
if ($ext === 'jpeg') {
|
||||||
|
$ext = 'jpg';
|
||||||
|
}
|
||||||
|
if (!in_array($ext, $allowed, true)) {
|
||||||
|
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = (string)file_get_contents($file['tmp_name']);
|
||||||
|
|
||||||
|
if ($ext === 'svg') {
|
||||||
|
$data = self::sanitizeSvg($data);
|
||||||
|
} elseif ($ext !== 'ico') {
|
||||||
|
// Raster: muss als Bild lesbar sein
|
||||||
|
if (@getimagesize($file['tmp_name']) === false) {
|
||||||
|
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::ensureDir()) {
|
||||||
|
throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = bin2hex(random_bytes(8)) . '.' . $ext;
|
||||||
|
$dest = self::dir() . '/' . $name;
|
||||||
|
if (file_put_contents($dest, $data) === false) {
|
||||||
|
throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.'));
|
||||||
|
}
|
||||||
|
@chmod($dest, 0644);
|
||||||
|
|
||||||
|
return 'uploads/' . $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuer Wert eines Bildfeldes aus dem Formular.
|
||||||
|
*
|
||||||
|
* Reihenfolge: hochgeladene Datei schlaegt alles, danach der
|
||||||
|
* Entfernen-Schalter, sonst gilt das URL-Feld. Wird eine zuvor
|
||||||
|
* hochgeladene Datei ersetzt oder entfernt, verschwindet sie auch
|
||||||
|
* von der Platte.
|
||||||
|
*
|
||||||
|
* @param string $name Feldname (erwartet <name>, <name>_file, <name>_remove)
|
||||||
|
* @param string $current bisher gespeicherter Wert
|
||||||
|
* @param string $kind 'image' oder 'favicon'
|
||||||
|
*/
|
||||||
|
public static function resolveField(string $name, string $current, string $kind = 'image'): string
|
||||||
|
{
|
||||||
|
$uploaded = self::store($_FILES[$name . '_file'] ?? [], $kind);
|
||||||
|
if ($uploaded !== '') {
|
||||||
|
self::delete($current);
|
||||||
|
return $uploaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($_POST[$name . '_remove'])) {
|
||||||
|
self::delete($current);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim((string)($_POST[$name] ?? ''));
|
||||||
|
if ($value !== $current && self::isLocal($current) && !self::isLocal($value)) {
|
||||||
|
self::delete($current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt aktive Inhalte aus SVG-Dateien (Skripte, Event-Handler,
|
||||||
|
* externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke.
|
||||||
|
*/
|
||||||
|
private static function sanitizeSvg(string $svg): string
|
||||||
|
{
|
||||||
|
if (stripos($svg, '<svg') === false) {
|
||||||
|
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg);
|
||||||
|
$svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*/?>#i', '', $svg);
|
||||||
|
$svg = preg_replace('#\son[a-z]+\s*=\s*"[^"]*"#i', '', $svg);
|
||||||
|
$svg = preg_replace("#\son[a-z]+\s*=\s*'[^']*'#i", '', $svg);
|
||||||
|
$svg = preg_replace('#(href|xlink:href)\s*=\s*([\'"])\s*(javascript|data):[^\'"]*\2#i', '', $svg);
|
||||||
|
$svg = preg_replace('#<!ENTITY[^>]*>#i', '', $svg);
|
||||||
|
|
||||||
|
return (string)$svg;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
includes/VoucherService.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Erstellt Voucher im UniFi-Controller und schreibt sie in die Datenbank.
|
||||||
|
*
|
||||||
|
* Bis hierher lag diese Logik doppelt in index.php und der REST-API; mit der
|
||||||
|
* Kiosk-Seite waere sie ein drittes Mal noetig gewesen.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/UniFiController.php';
|
||||||
|
require_once __DIR__ . '/Crypto.php';
|
||||||
|
|
||||||
|
class VoucherService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array $site Zeile aus `sites`
|
||||||
|
* @param array $qos ['down' => kbit, 'up' => kbit, 'quota_mb' => MB]
|
||||||
|
* @param int|null $userId angemeldeter Benutzer, sonst null
|
||||||
|
* @param int|null $kioskId Herkunft, falls ueber eine Display-Seite geholt
|
||||||
|
*
|
||||||
|
* @return array{code:string,site_name:string,max_uses:int,expire_min:int,expiry_date:string,expiry_time:string}
|
||||||
|
* @throws Exception wenn der Controller keinen gueltigen Voucher liefert
|
||||||
|
*/
|
||||||
|
public static function create(
|
||||||
|
$db,
|
||||||
|
array $site,
|
||||||
|
string $voucherName,
|
||||||
|
int $maxUses,
|
||||||
|
int $expireMinutes,
|
||||||
|
?int $userId = null,
|
||||||
|
array $qos = [],
|
||||||
|
?int $kioskId = null
|
||||||
|
): array {
|
||||||
|
$fullName = date('Y-m-d') . '_' . $voucherName;
|
||||||
|
|
||||||
|
$controller = new UniFiController(
|
||||||
|
$site['unifi_controller_url'],
|
||||||
|
$site['unifi_username'],
|
||||||
|
Crypto::decrypt($site['unifi_password']),
|
||||||
|
$site['site_id']
|
||||||
|
);
|
||||||
|
|
||||||
|
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
|
||||||
|
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
|
||||||
|
throw new Exception(function_exists('__') ? __('error_voucher_invalid') : 'Ungueltige Antwort des Controllers');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->execute(
|
||||||
|
"INSERT INTO vouchers (site_id, user_id, kiosk_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[$site['id'], $userId, $kioskId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
|
||||||
|
);
|
||||||
|
|
||||||
|
$expiryTs = time() + ($expireMinutes * 60);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => $voucher['formatted_code'],
|
||||||
|
'site_name' => $site['name'],
|
||||||
|
'max_uses' => $maxUses,
|
||||||
|
'expire_min' => $expireMinutes,
|
||||||
|
'expiry_date' => date('d.m.Y', $expiryTs),
|
||||||
|
'expiry_time' => date('H:i', $expiryTs),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,142 +4,122 @@
|
||||||
* Expects $currentPage (string), $appTitle (string), $auth, $db to be set before include.
|
* Expects $currentPage (string), $appTitle (string), $auth, $db to be set before include.
|
||||||
* Expects I18n to be initialized.
|
* Expects I18n to be initialized.
|
||||||
*/
|
*/
|
||||||
|
require_once __DIR__ . '/Ui.php';
|
||||||
|
|
||||||
$currentPage = $currentPage ?? '';
|
$currentPage = $currentPage ?? '';
|
||||||
$faviconUrl = isset($db) ? $db->getSetting('favicon_url', '') : '';
|
|
||||||
$currentUser = isset($auth) ? $auth->getCurrentUser() : null;
|
$currentUser = isset($auth) ? $auth->getCurrentUser() : null;
|
||||||
$lang = I18n::getLanguage();
|
$lang = I18n::getLanguage();
|
||||||
?>
|
$base = $adminBase ?? ''; // Prefix bis zum admin/-Ordner
|
||||||
<?php if ($faviconUrl): ?>
|
$rootBase = $base === '' ? '../' : ''; // Prefix bis zum Projekt-Root
|
||||||
<link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($faviconUrl) ?>">
|
|
||||||
<?php endif; ?>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
||||||
<link rel="stylesheet" href="<?= $adminBase ?? '../' ?>assets/global.css">
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
const t = localStorage.getItem('theme') || 'light';
|
|
||||||
document.documentElement.setAttribute('data-theme', t);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
/** Navigation als Datenstruktur – Reihenfolge = Darstellung. */
|
||||||
/* Base admin layout using CSS variables */
|
$navGroups = [
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
'nav_group_overview' => [
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: var(--bg-body); color: var(--text-primary); }
|
['dashboard', 'index.php', 'fa-chart-pie', 'nav_dashboard'],
|
||||||
.header { background: var(--bg-header); border-bottom: 1px solid var(--border-color); padding: 0 30px; height: 70px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 150; box-shadow: 0 2px 10px var(--shadow); }
|
['reports', 'reports.php', 'fa-chart-line', 'nav_reports'],
|
||||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
['audit_log', 'audit_log.php', 'fa-clock-rotate-left','nav_audit_log'],
|
||||||
.header-title { font-size: 20px; font-weight: 600; color: var(--text-primary); }
|
],
|
||||||
.header-right { display: flex; align-items: center; gap: 10px; }
|
'nav_group_manage' => [
|
||||||
.sidebar { position: fixed; left: 0; top: 70px; bottom: 0; width: 260px; background: var(--bg-sidebar); border-right: 1px solid var(--border-color); padding: 20px 0; overflow-y: auto; z-index: 100; }
|
['vouchers', 'vouchers.php', 'fa-ticket', 'nav_vouchers'],
|
||||||
.sidebar-nav { list-style: none; }
|
['templates', 'templates.php', 'fa-layer-group', 'nav_templates'],
|
||||||
.sidebar-nav li { margin-bottom: 2px; }
|
['import', 'import.php', 'fa-file-arrow-up', 'nav_import'],
|
||||||
.sidebar-nav a { display: flex; align-items: center; gap: 12px; padding: 11px 25px; color: var(--text-secondary); text-decoration: none; transition: all 0.2s; font-size: 14px; border-radius: 0 8px 8px 0; margin-right: 12px; }
|
['kiosks', 'kiosks.php', 'fa-display', 'nav_kiosks'],
|
||||||
.sidebar-nav a:hover, .sidebar-nav a.active { background: var(--bg-hover); color: var(--accent); }
|
['sites', 'sites.php', 'fa-location-dot', 'nav_sites'],
|
||||||
.sidebar-nav i { width: 18px; text-align: center; font-size: 15px; }
|
['users', 'users.php', 'fa-users', 'nav_users'],
|
||||||
.sidebar-section { padding: 16px 25px 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
|
],
|
||||||
.main-content { margin-left: 260px; padding: 30px; min-height: calc(100vh - 70px); }
|
'nav_group_system' => [
|
||||||
.user-menu { display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: var(--bg-hover); border-radius: 10px; }
|
['settings', 'settings.php', 'fa-sliders', 'nav_settings'],
|
||||||
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 14px; flex-shrink: 0; }
|
['integrations', 'integrations.php', 'fa-plug', 'nav_integrations'],
|
||||||
.user-name { font-weight: 500; font-size: 13px; color: var(--text-primary); }
|
['api_keys', 'api_keys.php', 'fa-key', 'nav_api_keys'],
|
||||||
.btn { padding: 8px 16px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all 0.2s; font-size: 13px; }
|
['security', 'security.php', 'fa-shield-halved','nav_security'],
|
||||||
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
['backup', 'backup.php', 'fa-database', 'nav_backup'],
|
||||||
.btn-secondary:hover { background: var(--border-color); color: var(--text-primary); }
|
['update', 'update.php', 'fa-rotate', 'nav_update'],
|
||||||
</style>
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Aktuellen Seitentitel für die Breadcrumb finden. */
|
||||||
|
$currentLabel = __('nav_dashboard');
|
||||||
|
foreach ($navGroups as $items) {
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item[0] === $currentPage) { $currentLabel = __($item[3]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<?= Ui::head($db ?? null, $rootBase) ?>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Sidebar overlay for mobile -->
|
<a class="skip-link" href="#main-content"><?= __('a11y_skip') ?></a>
|
||||||
<div class="sidebar-overlay" onclick="closeMobileSidebar()"></div>
|
<div class="sidebar-overlay" onclick="closeMobileSidebar()"></div>
|
||||||
|
|
||||||
<div class="header">
|
<aside class="sidebar" id="adminSidebar" aria-label="<?= __('nav_administration') ?>">
|
||||||
|
<a href="<?= $rootBase ?>index.php" class="sidebar-brand">
|
||||||
|
<span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||||||
|
<span class="brand-text">
|
||||||
|
<span class="brand-name"><?= htmlspecialchars($appTitle ?? 'Voucher Tool') ?></span>
|
||||||
|
<span class="brand-sub"><?= __('nav_administration') ?></span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<?php foreach ($navGroups as $groupKey => $items): ?>
|
||||||
|
<div class="sidebar-section"><?= __($groupKey) ?></div>
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<?php foreach ($items as [$key, $href, $icon, $labelKey]): ?>
|
||||||
|
<li>
|
||||||
|
<a href="<?= $base . $href ?>" class="<?= $currentPage === $key ? 'active' : '' ?>"
|
||||||
|
<?= $currentPage === $key ? 'aria-current="page"' : '' ?>>
|
||||||
|
<i class="fas <?= $icon ?>" aria-hidden="true"></i> <?= __($labelKey) ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-foot">
|
||||||
|
<?php if ($currentUser): ?>
|
||||||
|
<div class="user-menu" style="width:100%;justify-content:flex-start;">
|
||||||
|
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
|
||||||
|
<div style="min-width:0;flex:1;">
|
||||||
|
<div class="user-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?= htmlspecialchars($currentUser['name']) ?></div>
|
||||||
|
<div class="user-role"><?= htmlspecialchars($currentUser['role'] ?? '') ?></div>
|
||||||
|
</div>
|
||||||
|
<a href="<?= $rootBase ?>logout.php" class="icon-btn" title="<?= __('btn_logout') ?>" aria-label="<?= __('btn_logout') ?>" style="width:30px;height:30px;font-size:12px;">
|
||||||
|
<i class="fas fa-arrow-right-from-bracket" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?= Ui::credit(true) ?>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" title="Menu">
|
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" aria-label="<?= __('a11y_menu') ?>" title="<?= __('a11y_menu') ?>">
|
||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-bars" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="header-title">
|
<div class="breadcrumb">
|
||||||
<i class="fas fa-shield-alt" style="color: var(--accent);"></i>
|
<span><?= __('nav_administration') ?></span>
|
||||||
<?= __('nav_administration') ?>
|
<span class="sep">/</span>
|
||||||
|
<span class="current"><?= htmlspecialchars($currentLabel) ?></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<!-- Language switcher -->
|
<div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
|
||||||
<div class="lang-switcher">
|
|
||||||
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
||||||
<button class="lang-btn <?= $lang === $code ? 'active' : '' ?>"
|
<button class="lang-btn <?= $lang === $code ? 'active' : '' ?>"
|
||||||
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<!-- Dark mode toggle -->
|
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
|
||||||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">🌙</button>
|
<i class="fas fa-moon" aria-hidden="true"></i>
|
||||||
<!-- Back link -->
|
</button>
|
||||||
<a href="<?= $adminBase ?? '../' ?>index.php" class="btn btn-secondary">
|
<a href="<?= $rootBase ?>index.php" class="btn btn-secondary">
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left" aria-hidden="true"></i>
|
||||||
<span class="hide-mobile"><?= __('nav_back') ?></span>
|
<span class="hide-mobile"><?= __('nav_back') ?></span>
|
||||||
</a>
|
</a>
|
||||||
<!-- User menu -->
|
|
||||||
<?php if ($currentUser): ?>
|
|
||||||
<div class="user-menu">
|
|
||||||
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
|
|
||||||
<div class="user-name hide-mobile"><?= htmlspecialchars($currentUser['name']) ?></div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div class="sidebar" id="adminSidebar">
|
<main class="main-content" id="main-content">
|
||||||
<nav>
|
|
||||||
<div class="sidebar-section">Main</div>
|
|
||||||
<ul class="sidebar-nav">
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>index.php" class="<?= $currentPage === 'dashboard' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-home"></i> <?= __('nav_dashboard') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>sites.php" class="<?= $currentPage === 'sites' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-map-marker-alt"></i> <?= __('nav_sites') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>users.php" class="<?= $currentPage === 'users' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-users"></i> <?= __('nav_users') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>vouchers.php" class="<?= $currentPage === 'vouchers' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-ticket-alt"></i> <?= __('nav_vouchers') ?>
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
<div class="sidebar-section" style="margin-top: 10px;">Tools</div>
|
|
||||||
<ul class="sidebar-nav">
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>templates.php" class="<?= $currentPage === 'templates' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-layer-group"></i> <?= __('nav_templates') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>audit_log.php" class="<?= $currentPage === 'audit_log' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-history"></i> <?= __('nav_audit_log') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>reports.php" class="<?= $currentPage === 'reports' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-chart-line"></i> <?= __('nav_reports') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>import.php" class="<?= $currentPage === 'import' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-file-import"></i> <?= __('nav_import') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>api_keys.php" class="<?= $currentPage === 'api_keys' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-key"></i> <?= __('nav_api_keys') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>security.php" class="<?= $currentPage === 'security' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-user-shield"></i> <?= __('nav_security') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>integrations.php" class="<?= $currentPage === 'integrations' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-plug"></i> <?= __('nav_integrations') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>backup.php" class="<?= $currentPage === 'backup' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-database"></i> <?= __('nav_backup') ?>
|
|
||||||
</a></li>
|
|
||||||
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<style>
|
|
||||||
.hide-mobile { }
|
|
||||||
@media(max-width:600px) { .hide-mobile { display: none; } }
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
272
index.php
|
|
@ -19,6 +19,8 @@ require_once __DIR__ . '/includes/Mailer.php';
|
||||||
require_once __DIR__ . '/includes/Notifier.php';
|
require_once __DIR__ . '/includes/Notifier.php';
|
||||||
require_once __DIR__ . '/includes/Captcha.php';
|
require_once __DIR__ . '/includes/Captcha.php';
|
||||||
require_once __DIR__ . '/includes/Sms.php';
|
require_once __DIR__ . '/includes/Sms.php';
|
||||||
|
require_once __DIR__ . '/includes/Ui.php';
|
||||||
|
require_once __DIR__ . '/includes/VoucherService.php';
|
||||||
require_once __DIR__ . '/includes/I18n.php';
|
require_once __DIR__ . '/includes/I18n.php';
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
|
|
@ -74,7 +76,7 @@ $instructionHeader = $db->getSetting('instruction_header', '');
|
||||||
$instructionText = $db->getSetting('instruction_text', '');
|
$instructionText = $db->getSetting('instruction_text', '');
|
||||||
$publicAccess = $db->getSetting('public_access', 0);
|
$publicAccess = $db->getSetting('public_access', 0);
|
||||||
$smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1';
|
$smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1';
|
||||||
$printTemplate = $db->getSetting('print_template', '<div style="text-align:center;padding:40px;font-family:sans-serif;"><h1>{APP_TITLE}</h1><h2>WLAN Zugangscode</h2><div style="font-size:48px;font-weight:bold;margin:30px 0;font-family:monospace;letter-spacing:4px;">{VOUCHER_CODE}</div><p><strong>Gültig bis:</strong> {EXPIRY_DATE} {EXPIRY_TIME}</p><p><strong>Standort:</strong> {SITE_NAME}</p><p><strong>Maximale Geräte:</strong> {MAX_USES}</p><hr style="margin:30px 0;"><div style="font-size:14px;text-align:left;">{INSTRUCTIONS}</div></div>');
|
$printTemplate = $db->getSetting('print_template', Ui::defaultPrintTemplate());
|
||||||
$defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
|
$defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
|
||||||
$defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
|
$defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
|
||||||
$maxUsesLimit = max(1, (int)$db->getSetting('max_uses_limit', 10));
|
$maxUsesLimit = max(1, (int)$db->getSetting('max_uses_limit', 10));
|
||||||
|
|
@ -114,34 +116,9 @@ if ($auth->isLoggedIn()) {
|
||||||
|
|
||||||
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
|
$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 = []) {
|
function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos = []) {
|
||||||
$datum = date('Y-m-d');
|
return VoucherService::create($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos);
|
||||||
$fullName = $datum . '_' . $voucherName;
|
|
||||||
$controller = new UniFiController(
|
|
||||||
$site['unifi_controller_url'],
|
|
||||||
$site['unifi_username'],
|
|
||||||
Crypto::decrypt($site['unifi_password']),
|
|
||||||
$site['site_id']
|
|
||||||
);
|
|
||||||
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
|
|
||||||
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
|
|
||||||
throw new Exception(__('error_voucher_invalid'));
|
|
||||||
}
|
|
||||||
$db->execute(
|
|
||||||
"INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
[$site['id'], $userId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
|
|
||||||
);
|
|
||||||
$expiryTs = time() + ($expireMinutes * 60);
|
|
||||||
return [
|
|
||||||
'code' => $voucher['formatted_code'],
|
|
||||||
'site_name' => $site['name'],
|
|
||||||
'max_uses' => $maxUses,
|
|
||||||
'expire_min' => $expireMinutes,
|
|
||||||
'expiry_date' => date('d.m.Y', $expiryTs),
|
|
||||||
'expiry_time' => date('H:i', $expiryTs),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single voucher
|
// Single voucher
|
||||||
|
|
@ -271,9 +248,12 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
$instructions = $instructionHeader || $instructionText
|
$instructions = $instructionHeader || $instructionText
|
||||||
? htmlspecialchars($instructionHeader) . "\n" . $instructionText
|
? htmlspecialchars($instructionHeader) . "\n" . $instructionText
|
||||||
: '';
|
: '';
|
||||||
|
// {QR_CODE} wird erst im Browser gefuellt (siehe renderPrintQr()).
|
||||||
|
$qr = '<div class="print-qr" data-code="' . htmlspecialchars(str_replace('-', '', (string)$data['code']), ENT_QUOTES) . '"></div>';
|
||||||
|
|
||||||
return str_replace(
|
return str_replace(
|
||||||
['{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'],
|
['{QR_CODE}', '{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'],
|
||||||
[$data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']],
|
[$qr, $data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']],
|
||||||
$template
|
$template
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -284,120 +264,76 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= htmlspecialchars($appTitle) ?></title>
|
<title><?= htmlspecialchars($appTitle) ?></title>
|
||||||
<link rel="stylesheet" href="assets/global.css">
|
<?= Ui::head($db) ?>
|
||||||
<?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?>
|
<?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?>
|
||||||
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
|
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
<?php if ($voucherCreated || $bulkCreated): ?>
|
||||||
<?php if ($voucherCreated): ?>
|
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js') ?>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
/* Seitenspezifisch: Druckansicht der Voucher-Karten.
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; }
|
Die Druckvorlage ist am Bildschirm ausgeblendet und erscheint nur im Druck. */
|
||||||
.header { max-width: 1200px; margin: 0 auto 30px; display: flex; justify-content: space-between; align-items: center; background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); padding: 15px 25px; border-radius: 15px; }
|
#printArea { display: none; }
|
||||||
.header-title { color: white; font-size: 20px; font-weight: 600; }
|
|
||||||
.header-right { display: flex; align-items: center; gap: 10px; }
|
|
||||||
.btn-header { background: white; color: #667eea; padding: 10px 20px; border-radius: 8px; text-decoration: none; font-weight: 500; font-size: 14px; transition: all 0.2s; border: none; cursor: pointer; }
|
|
||||||
.btn-header:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); padding: 40px; }
|
|
||||||
h1 { text-align: center; color: var(--text-primary); margin-bottom: 30px; font-size: 28px; }
|
|
||||||
.logo { max-width: 250px; display: block; margin: 0 auto 30px; }
|
|
||||||
.alert { padding: 14px; border-radius: 10px; margin-bottom: 25px; font-size: 14px; }
|
|
||||||
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
|
|
||||||
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
|
|
||||||
.form-group { margin-bottom: 20px; }
|
|
||||||
label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
|
||||||
input[type="text"], input[type="number"], input[type="email"], select { width: 100%; padding: 14px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; transition: all 0.2s; background: var(--bg-input); color: var(--text-primary); }
|
|
||||||
input:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
|
|
||||||
.btn { width: 100%; padding: 16px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
|
||||||
.btn:hover { background: var(--accent-hover); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102,126,234,0.4); }
|
|
||||||
.btn:disabled { background: #ccc; cursor: not-allowed; transform: none; }
|
|
||||||
.btn-outline { background: transparent; color: var(--accent); border: 2px solid var(--accent); margin-top: 10px; }
|
|
||||||
.btn-outline:hover { background: var(--accent); color: white; }
|
|
||||||
.mode-tabs { display: flex; gap: 8px; margin-bottom: 25px; background: var(--bg-hover); padding: 6px; border-radius: 12px; }
|
|
||||||
.mode-tab { flex: 1; padding: 10px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; background: transparent; color: var(--text-secondary); transition: all 0.2s; }
|
|
||||||
.mode-tab.active { background: var(--bg-card); color: var(--accent); box-shadow: 0 2px 8px var(--shadow); }
|
|
||||||
.template-dropdown { margin-bottom: 20px; padding: 15px; background: var(--bg-hover); border-radius: 12px; border: 2px solid var(--border-color); }
|
|
||||||
.template-dropdown label { color: var(--text-secondary); font-size: 13px; margin-bottom: 6px; }
|
|
||||||
.template-hint { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
|
|
||||||
.voucher-result { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 15px; text-align: center; margin-bottom: 25px; }
|
|
||||||
.voucher-code { font-size: 32px; font-weight: bold; letter-spacing: 2px; margin: 20px 0; font-family: 'Courier New', monospace; cursor: pointer; }
|
|
||||||
.voucher-code:hover { opacity: 0.85; }
|
|
||||||
.voucher-info { font-size: 14px; opacity: 0.9; margin-top: 10px; }
|
|
||||||
.qr-wrapper { display: flex; flex-direction: column; align-items: center; margin: 20px 0 0; }
|
|
||||||
.qr-wrapper canvas, .qr-wrapper img { border: 6px solid white; border-radius: 8px; }
|
|
||||||
.qr-label { font-size: 12px; opacity: 0.8; margin-top: 8px; }
|
|
||||||
.instruction-box { background: var(--bg-hover); padding: 20px; border-radius: 10px; margin-top: 25px; }
|
|
||||||
.instruction-box h3 { color: var(--text-primary); margin-bottom: 10px; font-size: 16px; }
|
|
||||||
.instruction-box p, .instruction-box div { color: var(--text-secondary); line-height: 1.6; font-size: 14px; }
|
|
||||||
.empty-state { text-align: center; padding: 40px; color: var(--text-muted); }
|
|
||||||
.email-option { background: var(--bg-hover); border: 2px solid var(--border-color); border-radius: 12px; padding: 20px; margin: 20px 0; transition: all 0.2s; }
|
|
||||||
.email-option.active { background: var(--bg-hover); border-color: var(--accent); }
|
|
||||||
.email-checkbox-wrapper { display: flex; align-items: center; gap: 12px; cursor: pointer; }
|
|
||||||
.email-checkbox-wrapper input[type="checkbox"] { width: 20px; height: 20px; cursor: pointer; accent-color: var(--accent); }
|
|
||||||
.email-checkbox-wrapper label { margin: 0; cursor: pointer; font-size: 15px; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
|
|
||||||
.email-input-wrapper { max-height: 0; overflow: hidden; opacity: 0; transition: all 0.3s; }
|
|
||||||
.email-input-wrapper.show { max-height: 120px; opacity: 1; margin-top: 15px; }
|
|
||||||
.bulk-table { width: 100%; border-collapse: collapse; margin-top: 15px; }
|
|
||||||
.bulk-table th { background: var(--bg-hover); color: var(--text-secondary); padding: 10px 12px; text-align: left; font-size: 12px; text-transform: uppercase; }
|
|
||||||
.bulk-table td { padding: 12px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
|
|
||||||
.bulk-table tr:last-child td { border-bottom: none; }
|
|
||||||
.bulk-table code { background: var(--bg-hover); padding: 4px 8px; border-radius: 4px; font-family: monospace; letter-spacing: 1px; cursor: pointer; }
|
|
||||||
.bulk-table code:hover { opacity: 0.75; }
|
|
||||||
.copy-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
|
||||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
||||||
@media print {
|
@media print {
|
||||||
|
#printArea { display: block; }
|
||||||
body * { visibility: hidden; }
|
body * { visibility: hidden; }
|
||||||
#printArea, #printArea * { visibility: visible; }
|
#printArea, #printArea * { visibility: visible; }
|
||||||
#printArea { position: absolute; left: 0; top: 0; width: 100%; background: white; }
|
#printArea { position: absolute; left: 0; top: 0; width: 100%; background: #fff; }
|
||||||
.no-print { display: none !important; }
|
.no-print { display: none !important; }
|
||||||
.print-page-break { page-break-after: always; }
|
.print-page-break { page-break-after: always; }
|
||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
|
||||||
.container { padding: 25px 20px; border-radius: 15px; }
|
|
||||||
.header { padding: 12px 15px; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app-body">
|
||||||
|
|
||||||
<div style="position:fixed;top:15px;right:20px;display:flex;gap:8px;z-index:10;" class="no-print">
|
<a class="skip-link no-print" href="#main-content"><?= __('a11y_skip') ?></a>
|
||||||
<div class="lang-switcher">
|
<header class="app-topbar no-print">
|
||||||
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
<a href="index.php" class="brand">
|
||||||
<button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>"
|
<span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||||||
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
<span class="brand-name"><?= htmlspecialchars($appTitle) ?></span>
|
||||||
<?php endforeach; ?>
|
</a>
|
||||||
</div>
|
<div class="app-actions">
|
||||||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">🌙</button>
|
<div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
|
||||||
</div>
|
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
||||||
|
<button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>"
|
||||||
<?php if ($currentUser): ?>
|
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
||||||
<div class="header no-print">
|
<?php endforeach; ?>
|
||||||
<div class="header-title">👋 <?= __('hello', ['name' => htmlspecialchars($currentUser['name'])]) ?></div>
|
</div>
|
||||||
<div class="header-right">
|
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
|
||||||
<?php if ($auth->isAdmin()): ?>
|
<i class="fas fa-moon" aria-hidden="true"></i>
|
||||||
<a href="admin/" class="btn-header">⚙️ <?= __('nav_administration') ?></a>
|
</button>
|
||||||
|
<?php if ($currentUser): ?>
|
||||||
|
<?php if ($auth->isAdmin()): ?>
|
||||||
|
<a href="admin/" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-sliders" aria-hidden="true"></i> <span class="hide-mobile"><?= __('nav_administration') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="user-menu">
|
||||||
|
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
|
||||||
|
<div class="user-name hide-mobile"><?= htmlspecialchars($currentUser['name']) ?></div>
|
||||||
|
<a href="logout.php" class="icon-btn" title="<?= __('btn_logout') ?>" aria-label="<?= __('btn_logout') ?>" style="width:28px;height:28px;font-size:11px;">
|
||||||
|
<i class="fas fa-arrow-right-from-bracket" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($publicAccess): ?>
|
||||||
|
<a href="login.php" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('btn_login') ?>
|
||||||
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<a href="logout.php" class="btn-header"><?= __('btn_logout') ?></a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
<?php elseif ($publicAccess): ?>
|
|
||||||
<div class="header no-print">
|
|
||||||
<div class="header-title"><?= htmlspecialchars($appTitle) ?></div>
|
|
||||||
<div class="header-right">
|
|
||||||
<a href="login.php" class="btn-header">🔐 <?= __('btn_login') ?></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="container">
|
<main class="container" id="main-content">
|
||||||
<?php if ($logoUrl && !$voucherCreated && !$bulkCreated): ?>
|
<?php if ($logoUrl && !$voucherCreated && !$bulkCreated): ?>
|
||||||
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
|
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (!$voucherCreated && !$bulkCreated): ?>
|
<?php if (!$voucherCreated && !$bulkCreated): ?>
|
||||||
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
<div class="app-card-head">
|
||||||
|
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
||||||
|
<p><?= __('app_subtitle') ?></p>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
|
|
@ -410,13 +346,19 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="voucher-result no-print">
|
<div class="voucher-result no-print">
|
||||||
<div style="font-size:18px;margin-bottom:10px;"><?= __('voucher_success_title') ?></div>
|
<div class="result-label"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('voucher_success_title') ?></div>
|
||||||
<div class="voucher-code" id="voucherCode" onclick="copyCode()" title="Klicken zum Kopieren">
|
<div class="voucher-code" id="voucherCode" onclick="copyCode()" title="<?= __('js_click_to_copy') ?>">
|
||||||
<?= htmlspecialchars($voucherCode) ?>
|
<?= htmlspecialchars($voucherCode) ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="voucher-info">
|
<div class="voucher-info">
|
||||||
<?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?>
|
<i class="fas fa-copy" aria-hidden="true"></i> <?= __('voucher_copy_hint') ?>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="voucher-meta">
|
||||||
|
<span><i class="fas fa-clock" aria-hidden="true"></i> <?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?></span>
|
||||||
|
<span><i class="fas fa-location-dot" aria-hidden="true"></i> <?= htmlspecialchars($voucherData['site_name']) ?></span>
|
||||||
|
<span><i class="fas fa-mobile-screen" aria-hidden="true"></i> <?= (int)$voucherData['max_uses'] ?> <?= __('label_devices') ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-divider"></div>
|
||||||
<div class="qr-wrapper no-print">
|
<div class="qr-wrapper no-print">
|
||||||
<div id="qrcode"></div>
|
<div id="qrcode"></div>
|
||||||
<div class="qr-label"><?= __('voucher_qr_label') ?></div>
|
<div class="qr-label"><?= __('voucher_qr_label') ?></div>
|
||||||
|
|
@ -430,15 +372,22 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="get" class="no-print" style="margin-top:15px;">
|
<div class="no-print" style="display:flex;gap:10px;margin-top:18px;">
|
||||||
<button type="submit" class="btn"><?= __('btn_new_code') ?></button>
|
<form method="get" style="flex:1;">
|
||||||
</form>
|
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||||
<button onclick="window.print()" class="btn btn-outline no-print" style="margin-top:10px;">
|
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('btn_new_code') ?>
|
||||||
🖨️ <?= __('voucher_print_btn') ?>
|
</button>
|
||||||
</button>
|
</form>
|
||||||
|
<button onclick="window.print()" class="btn btn-secondary btn-lg">
|
||||||
|
<i class="fas fa-print" aria-hidden="true"></i> <?= __('voucher_print_btn') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php elseif ($bulkCreated): ?>
|
<?php elseif ($bulkCreated): ?>
|
||||||
<h1 style="font-size:22px;margin-bottom:20px;"><?= str_replace('{count}', count($bulkVouchers), __('bulk_results')) ?></h1>
|
<div class="app-card-head">
|
||||||
|
<h1><?= str_replace('{count}', count($bulkVouchers), __('bulk_results')) ?></h1>
|
||||||
|
<p><?= __('bulk_results_hint') ?></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="printArea">
|
<div id="printArea">
|
||||||
<?php foreach ($bulkVouchers as $idx => $bv): ?>
|
<?php foreach ($bulkVouchers as $idx => $bv): ?>
|
||||||
|
|
@ -463,8 +412,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= $i + 1 ?></td>
|
<td><?= $i + 1 ?></td>
|
||||||
<td>
|
<td>
|
||||||
<code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', 'Kopiert!')"
|
<code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', '<?= __('js_copied') ?>')"
|
||||||
title="Klicken zum Kopieren"><?= htmlspecialchars($bv['code']) ?></code>
|
title="<?= __('js_click_to_copy') ?>"><?= htmlspecialchars($bv['code']) ?></code>
|
||||||
</td>
|
</td>
|
||||||
<td><?= htmlspecialchars($bv['site_name']) ?></td>
|
<td><?= htmlspecialchars($bv['site_name']) ?></td>
|
||||||
<td><?= $bv['expiry_date'] ?> <?= $bv['expiry_time'] ?></td>
|
<td><?= $bv['expiry_date'] ?> <?= $bv['expiry_time'] ?></td>
|
||||||
|
|
@ -472,21 +421,21 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p class="copy-hint" style="margin-top:8px;">Code anklicken zum Kopieren</p>
|
<p class="copy-hint"><i class="fas fa-copy" aria-hidden="true"></i> <?= __('voucher_copy_hint') ?></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="no-print" style="display:flex;gap:10px;margin-top:20px;">
|
<div class="no-print" style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button onclick="window.print()" class="btn" style="flex:1;">
|
<button onclick="window.print()" class="btn btn-primary btn-lg" style="flex:1;">
|
||||||
🖨️ <?= __('bulk_print_all') ?>
|
<i class="fas fa-print" aria-hidden="true"></i> <?= __('bulk_print_all') ?>
|
||||||
</button>
|
</button>
|
||||||
<form method="get" style="flex:1;">
|
<form method="get" style="flex:1;">
|
||||||
<button type="submit" class="btn btn-outline" style="width:100%;"><?= __('btn_new_code') ?></button>
|
<button type="submit" class="btn btn-secondary btn-lg btn-block"><?= __('btn_new_code') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php elseif (empty($sites)): ?>
|
<?php elseif (empty($sites)): ?>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div style="font-size:60px;margin-bottom:20px;opacity:0.3;">📶</div>
|
<div class="empty-icon"><i class="fas fa-wifi" aria-hidden="true"></i></div>
|
||||||
<p><?= __('voucher_no_sites') ?><br>
|
<p><?= __('voucher_no_sites') ?><br>
|
||||||
<?php if ($auth->isAdmin()): ?>
|
<?php if ($auth->isAdmin()): ?>
|
||||||
<a href="admin/" style="color:var(--accent);"><?= __('voucher_no_sites_admin') ?></a>
|
<a href="admin/" style="color:var(--accent);"><?= __('voucher_no_sites_admin') ?></a>
|
||||||
|
|
@ -570,7 +519,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<div class="email-checkbox-wrapper">
|
<div class="email-checkbox-wrapper">
|
||||||
<input type="checkbox" id="send_email" name="send_email" onchange="toggleEmailField()">
|
<input type="checkbox" id="send_email" name="send_email" onchange="toggleEmailField()">
|
||||||
<label for="send_email">
|
<label for="send_email">
|
||||||
✉️ <?= __('voucher_email_send') ?>
|
<i class="fas fa-envelope" style="color:var(--accent);" aria-hidden="true"></i> <?= __('voucher_email_send') ?>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="email-input-wrapper" id="email_field">
|
<div class="email-input-wrapper" id="email_field">
|
||||||
|
|
@ -587,7 +536,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<div class="email-option">
|
<div class="email-option">
|
||||||
<div class="email-checkbox-wrapper">
|
<div class="email-checkbox-wrapper">
|
||||||
<input type="checkbox" id="send_sms" name="send_sms" onchange="document.getElementById('sms_field').style.display=this.checked?'block':'none'">
|
<input type="checkbox" id="send_sms" name="send_sms" onchange="document.getElementById('sms_field').style.display=this.checked?'block':'none'">
|
||||||
<label for="send_sms">📱 Code per SMS versenden</label>
|
<label for="send_sms"><i class="fas fa-comment-sms" style="color:var(--accent);" aria-hidden="true"></i> Code per SMS versenden</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="sms_field" style="display:none;margin-top:12px;">
|
<div id="sms_field" style="display:none;margin-top:12px;">
|
||||||
<label for="recipient_phone">Telefonnummer (international, z.B. +49170…)</label>
|
<label for="recipient_phone">Telefonnummer (international, z.B. +49170…)</label>
|
||||||
|
|
@ -596,8 +545,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<button type="submit" class="btn" id="submitBtn">
|
<button type="submit" class="btn btn-primary btn-lg btn-block" id="submitBtn">
|
||||||
<?= __('voucher_create_btn') ?>
|
<i class="fas fa-ticket" aria-hidden="true"></i> <?= __('voucher_create_btn') ?>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -617,7 +566,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
|
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
|
||||||
<input type="number" id="bulk_count" name="bulk_count"
|
<input type="number" id="bulk_count" name="bulk_count"
|
||||||
min="1" max="20" value="5" required>
|
min="1" max="20" value="5" required>
|
||||||
<p style="font-size:12px;color:var(--text-muted);margin-top:5px;"><?= __('bulk_quantity_hint') ?></p>
|
<p class="form-hint"><?= __('bulk_quantity_hint') ?></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -646,7 +595,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn" id="bulkSubmitBtn">
|
<button type="submit" class="btn btn-primary btn-lg btn-block" id="bulkSubmitBtn">
|
||||||
<?= str_replace('{count}', '<span id="bulkCountLabel">5</span>', __('bulk_create_btn')) ?>
|
<?= str_replace('{count}', '<span id="bulkCountLabel">5</span>', __('bulk_create_btn')) ?>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -660,23 +609,42 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<div id="toast-container"></div>
|
<footer class="app-footer no-print"><?= Ui::credit() ?></footer>
|
||||||
|
|
||||||
|
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||||
<script src="assets/global.js"></script>
|
<script src="assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
<?php if ($voucherCreated || $bulkCreated): ?>
|
||||||
|
// QR-Codes der Druckkarten erzeugen (im Browser, ohne externen Dienst)
|
||||||
|
function renderPrintQr() {
|
||||||
|
document.querySelectorAll('.print-qr').forEach(function (el) {
|
||||||
|
if (el.dataset.done) return;
|
||||||
|
el.dataset.done = '1';
|
||||||
|
new QRCode(el, {
|
||||||
|
text: el.dataset.code || '',
|
||||||
|
width: 132, height: 132,
|
||||||
|
colorDark: '#101625', colorLight: '#ffffff',
|
||||||
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', renderPrintQr);
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($voucherCreated): ?>
|
<?php if ($voucherCreated): ?>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
new QRCode(document.getElementById('qrcode'), {
|
new QRCode(document.getElementById('qrcode'), {
|
||||||
text: '<?= addslashes($voucherCode) ?>',
|
text: '<?= addslashes($voucherCode) ?>',
|
||||||
width: 160, height: 160,
|
width: 160, height: 160,
|
||||||
colorDark: '#ffffff', colorLight: 'transparent',
|
colorDark: '#101625', colorLight: '#ffffff',
|
||||||
correctLevel: QRCode.CorrectLevel.M
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function copyCode() {
|
function copyCode() {
|
||||||
copyToClipboard('<?= addslashes($voucherCode) ?>', 'Code kopiert!');
|
copyToClipboard('<?= addslashes($voucherCode) ?>', '<?= __('js_code_copied') ?>');
|
||||||
}
|
}
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
@ -742,7 +710,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
const btn = document.getElementById('submitBtn');
|
const btn = document.getElementById('submitBtn');
|
||||||
if (!btn || btn.disabled) { e.preventDefault(); return; }
|
if (!btn || btn.disabled) { e.preventDefault(); return; }
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '⏳ <?= __('voucher_creating') ?>';
|
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> <?= __('voucher_creating') ?>';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('bulkForm')?.addEventListener('submit', function(e) {
|
document.getElementById('bulkForm')?.addEventListener('submit', function(e) {
|
||||||
|
|
@ -750,7 +718,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
if (!btn || btn.disabled) { e.preventDefault(); return; }
|
if (!btn || btn.disabled) { e.preventDefault(); return; }
|
||||||
const count = document.getElementById('bulk_count').value;
|
const count = document.getElementById('bulk_count').value;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '⏳ <?= addslashes(str_replace('{count}', "' + count + '", __('bulk_creating'))) ?>';
|
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> <?= addslashes(str_replace('{count}', "' + count + '", __('bulk_creating'))) ?>';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
|
||||||
861
install.php
|
|
@ -1,482 +1,381 @@
|
||||||
<?php
|
<?php
|
||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
// Prüfen ob bereits installiert.
|
// Prüfen ob bereits installiert.
|
||||||
// Wenn eine config.php existiert, darf der Installer NICHT mehr ohne Weiteres
|
// Wenn eine config.php existiert, darf der Installer NICHT mehr ohne Weiteres
|
||||||
// erreichbar sein – sonst koennte jeder die Konfiguration ueberschreiben und
|
// erreichbar sein – sonst koennte jeder die Konfiguration ueberschreiben und
|
||||||
// einen neuen Admin anlegen. Reinstall ist nur fuer angemeldete Admins erlaubt.
|
// einen neuen Admin anlegen. Reinstall ist nur fuer angemeldete Admins erlaubt.
|
||||||
if (file_exists(__DIR__ . '/config.php')) {
|
if (file_exists(__DIR__ . '/config.php')) {
|
||||||
if (!isset($_GET['reinstall'])) {
|
if (!isset($_GET['reinstall'])) {
|
||||||
die('System bereits installiert. Eine Neuinstallation ist nur fuer angemeldete Administratoren ueber install.php?reinstall=1 moeglich.');
|
die('System bereits installiert. Eine Neuinstallation ist nur fuer angemeldete Administratoren ueber install.php?reinstall=1 moeglich.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reinstall angefordert -> Admin-Authentifizierung erzwingen
|
// Reinstall angefordert -> Admin-Authentifizierung erzwingen
|
||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
require_once __DIR__ . '/includes/Database.php';
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
require_once __DIR__ . '/includes/Auth.php';
|
require_once __DIR__ . '/includes/Auth.php';
|
||||||
try {
|
try {
|
||||||
$reinstallAuth = new Auth();
|
$reinstallAuth = new Auth();
|
||||||
if (!$reinstallAuth->isAdmin()) {
|
if (!$reinstallAuth->isAdmin()) {
|
||||||
die('Neuinstallation nicht erlaubt: Bitte zuerst als Administrator <a href="login.php">anmelden</a>.');
|
die('Neuinstallation nicht erlaubt: Bitte zuerst als Administrator <a href="login.php">anmelden</a>.');
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
die('Neuinstallation nicht moeglich (Konfigurationsfehler).');
|
die('Neuinstallation nicht moeglich (Konfigurationsfehler).');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$step = isset($_POST['step']) ? (int)$_POST['step'] : 1;
|
$step = isset($_POST['step']) ? (int)$_POST['step'] : 1;
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$success = false;
|
$success = false;
|
||||||
|
|
||||||
// Step 1: Datenbankverbindung testen
|
// Step 1: Datenbankverbindung testen
|
||||||
if ($step === 2 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($step === 2 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$db_host = $_POST['db_host'] ?? '';
|
$db_host = $_POST['db_host'] ?? '';
|
||||||
$db_name = $_POST['db_name'] ?? '';
|
$db_name = $_POST['db_name'] ?? '';
|
||||||
$db_user = $_POST['db_user'] ?? '';
|
$db_user = $_POST['db_user'] ?? '';
|
||||||
$db_pass = $_POST['db_pass'] ?? '';
|
$db_pass = $_POST['db_pass'] ?? '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = new PDO("mysql:host=$db_host;charset=utf8mb4", $db_user, $db_pass);
|
$pdo = new PDO("mysql:host=$db_host;charset=utf8mb4", $db_user, $db_pass);
|
||||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
// Datenbank erstellen falls nicht vorhanden
|
// Datenbank erstellen falls nicht vorhanden
|
||||||
$pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
$pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||||
$pdo->exec("USE `$db_name`");
|
$pdo->exec("USE `$db_name`");
|
||||||
|
|
||||||
// Tabellen erstellen
|
// Tabellen erstellen
|
||||||
$sql = file_get_contents(__DIR__ . '/database.sql');
|
$sql = file_get_contents(__DIR__ . '/database.sql');
|
||||||
$pdo->exec($sql);
|
$pdo->exec($sql);
|
||||||
|
|
||||||
$_SESSION['install_db'] = [
|
$_SESSION['install_db'] = [
|
||||||
'host' => $db_host,
|
'host' => $db_host,
|
||||||
'name' => $db_name,
|
'name' => $db_name,
|
||||||
'user' => $db_user,
|
'user' => $db_user,
|
||||||
'pass' => $db_pass
|
'pass' => $db_pass
|
||||||
];
|
];
|
||||||
|
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
$errors[] = "Datenbankfehler: " . $e->getMessage();
|
$errors[] = "Datenbankfehler: " . $e->getMessage();
|
||||||
$step = 1;
|
$step = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Admin-Account erstellen
|
// Step 2: Admin-Account erstellen
|
||||||
if ($step === 3 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($step === 3 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$admin_email = filter_var($_POST['admin_email'] ?? '', FILTER_VALIDATE_EMAIL);
|
$admin_email = filter_var($_POST['admin_email'] ?? '', FILTER_VALIDATE_EMAIL);
|
||||||
$admin_name = $_POST['admin_name'] ?? '';
|
$admin_name = $_POST['admin_name'] ?? '';
|
||||||
$admin_password = $_POST['admin_password'] ?? '';
|
$admin_password = $_POST['admin_password'] ?? '';
|
||||||
$admin_password_confirm = $_POST['admin_password_confirm'] ?? '';
|
$admin_password_confirm = $_POST['admin_password_confirm'] ?? '';
|
||||||
|
|
||||||
if (!$admin_email) {
|
if (!$admin_email) {
|
||||||
$errors[] = "Ungültige E-Mail-Adresse";
|
$errors[] = "Ungültige E-Mail-Adresse";
|
||||||
$step = 2;
|
$step = 2;
|
||||||
} elseif (strlen($admin_password) < 8) {
|
} elseif (strlen($admin_password) < 8) {
|
||||||
$errors[] = "Passwort muss mindestens 8 Zeichen lang sein";
|
$errors[] = "Passwort muss mindestens 8 Zeichen lang sein";
|
||||||
$step = 2;
|
$step = 2;
|
||||||
} elseif ($admin_password !== $admin_password_confirm) {
|
} elseif ($admin_password !== $admin_password_confirm) {
|
||||||
$errors[] = "Passwörter stimmen nicht überein";
|
$errors[] = "Passwörter stimmen nicht überein";
|
||||||
$step = 2;
|
$step = 2;
|
||||||
} else {
|
} else {
|
||||||
$_SESSION['install_admin'] = [
|
$_SESSION['install_admin'] = [
|
||||||
'email' => $admin_email,
|
'email' => $admin_email,
|
||||||
'name' => $admin_name,
|
'name' => $admin_name,
|
||||||
'password' => password_hash($admin_password, PASSWORD_DEFAULT)
|
'password' => password_hash($admin_password, PASSWORD_DEFAULT)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Allgemeine Einstellungen
|
// Step 3: Allgemeine Einstellungen
|
||||||
if ($step === 4 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($step === 4 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$app_title = $_POST['app_title'] ?? 'UniFi Voucher System';
|
$app_title = $_POST['app_title'] ?? 'UniFi Voucher System';
|
||||||
$logo_url = $_POST['logo_url'] ?? '';
|
$logo_url = $_POST['logo_url'] ?? '';
|
||||||
$instruction_header = $_POST['instruction_header'] ?? '';
|
$instruction_header = $_POST['instruction_header'] ?? '';
|
||||||
$instruction_text = $_POST['instruction_text'] ?? '';
|
$instruction_text = $_POST['instruction_text'] ?? '';
|
||||||
$public_access = isset($_POST['public_access']) ? 1 : 0;
|
$public_access = isset($_POST['public_access']) ? 1 : 0;
|
||||||
|
|
||||||
// Microsoft 365 OAuth (optional)
|
// Microsoft 365 OAuth (optional)
|
||||||
$m365_client_id = $_POST['m365_client_id'] ?? '';
|
$m365_client_id = $_POST['m365_client_id'] ?? '';
|
||||||
$m365_client_secret = $_POST['m365_client_secret'] ?? '';
|
$m365_client_secret = $_POST['m365_client_secret'] ?? '';
|
||||||
$m365_tenant_id = $_POST['m365_tenant_id'] ?? '';
|
$m365_tenant_id = $_POST['m365_tenant_id'] ?? '';
|
||||||
|
|
||||||
$_SESSION['install_settings'] = [
|
$_SESSION['install_settings'] = [
|
||||||
'app_title' => $app_title,
|
'app_title' => $app_title,
|
||||||
'logo_url' => $logo_url,
|
'logo_url' => $logo_url,
|
||||||
'instruction_header' => $instruction_header,
|
'instruction_header' => $instruction_header,
|
||||||
'instruction_text' => $instruction_text,
|
'instruction_text' => $instruction_text,
|
||||||
'public_access' => $public_access,
|
'public_access' => $public_access,
|
||||||
'm365_client_id' => $m365_client_id,
|
'm365_client_id' => $m365_client_id,
|
||||||
'm365_client_secret' => $m365_client_secret,
|
'm365_client_secret' => $m365_client_secret,
|
||||||
'm365_tenant_id' => $m365_tenant_id
|
'm365_tenant_id' => $m365_tenant_id
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Installation abschließen
|
// Step 4: Installation abschließen
|
||||||
if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
try {
|
try {
|
||||||
$db = $_SESSION['install_db'];
|
$db = $_SESSION['install_db'];
|
||||||
$admin = $_SESSION['install_admin'];
|
$admin = $_SESSION['install_admin'];
|
||||||
$settings = $_SESSION['install_settings'];
|
$settings = $_SESSION['install_settings'];
|
||||||
|
|
||||||
$pdo = new PDO("mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4", $db['user'], $db['pass']);
|
$pdo = new PDO("mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4", $db['user'], $db['pass']);
|
||||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
// Admin-User erstellen
|
// Admin-User erstellen
|
||||||
$stmt = $pdo->prepare("INSERT INTO users (email, name, password_hash, is_admin, is_active) VALUES (?, ?, ?, 1, 1)");
|
$stmt = $pdo->prepare("INSERT INTO users (email, name, password_hash, is_admin, is_active) VALUES (?, ?, ?, 1, 1)");
|
||||||
$stmt->execute([$admin['email'], $admin['name'], $admin['password']]);
|
$stmt->execute([$admin['email'], $admin['name'], $admin['password']]);
|
||||||
|
|
||||||
// Settings speichern
|
// Settings speichern
|
||||||
$settingsData = [
|
$settingsData = [
|
||||||
'app_title' => $settings['app_title'],
|
'app_title' => $settings['app_title'],
|
||||||
'logo_url' => $settings['logo_url'],
|
'logo_url' => $settings['logo_url'],
|
||||||
'instruction_header' => $settings['instruction_header'],
|
'instruction_header' => $settings['instruction_header'],
|
||||||
'instruction_text' => $settings['instruction_text'],
|
'instruction_text' => $settings['instruction_text'],
|
||||||
'public_access' => $settings['public_access'],
|
'public_access' => $settings['public_access'],
|
||||||
'm365_client_id' => $settings['m365_client_id'],
|
'm365_client_id' => $settings['m365_client_id'],
|
||||||
'm365_client_secret' => $settings['m365_client_secret'],
|
'm365_client_secret' => $settings['m365_client_secret'],
|
||||||
'm365_tenant_id' => $settings['m365_tenant_id']
|
'm365_tenant_id' => $settings['m365_tenant_id']
|
||||||
];
|
];
|
||||||
|
|
||||||
$stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)");
|
$stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)");
|
||||||
foreach ($settingsData as $key => $value) {
|
foreach ($settingsData as $key => $value) {
|
||||||
$stmt->execute([$key, $value]);
|
$stmt->execute([$key, $value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// config.php erstellen
|
// config.php erstellen
|
||||||
$configContent = "<?php\n";
|
$configContent = "<?php\n";
|
||||||
$configContent .= "// UniFi Voucher Management System - Configuration\n\n";
|
$configContent .= "// UniFi Voucher Management System - Configuration\n\n";
|
||||||
$configContent .= "define('DB_HOST', '{$db['host']}');\n";
|
$configContent .= "define('DB_HOST', '{$db['host']}');\n";
|
||||||
$configContent .= "define('DB_NAME', '{$db['name']}');\n";
|
$configContent .= "define('DB_NAME', '{$db['name']}');\n";
|
||||||
$configContent .= "define('DB_USER', '{$db['user']}');\n";
|
$configContent .= "define('DB_USER', '{$db['user']}');\n";
|
||||||
$configContent .= "define('DB_PASS', '" . addslashes($db['pass']) . "');\n\n";
|
$configContent .= "define('DB_PASS', '" . addslashes($db['pass']) . "');\n\n";
|
||||||
$configContent .= "// Anwendungs-Schluessel fuer Verschluesselung-at-rest (z.B. UniFi-Passwoerter)\n";
|
$configContent .= "// Anwendungs-Schluessel fuer Verschluesselung-at-rest (z.B. UniFi-Passwoerter)\n";
|
||||||
$configContent .= "// NICHT aendern, sonst koennen bestehende verschluesselte Werte nicht mehr gelesen werden.\n";
|
$configContent .= "// NICHT aendern, sonst koennen bestehende verschluesselte Werte nicht mehr gelesen werden.\n";
|
||||||
$configContent .= "define('APP_KEY', '" . base64_encode(random_bytes(32)) . "');\n\n";
|
$configContent .= "define('APP_KEY', '" . base64_encode(random_bytes(32)) . "');\n\n";
|
||||||
$configContent .= "// Sitzungs-Einstellungen\n";
|
$configContent .= "// Sitzungs-Einstellungen\n";
|
||||||
$configContent .= "define('SESSION_LIFETIME', 3600); // 1 Stunde\n\n";
|
$configContent .= "define('SESSION_LIFETIME', 3600); // 1 Stunde\n\n";
|
||||||
$configContent .= "// Zeitzone\n";
|
$configContent .= "// Zeitzone\n";
|
||||||
$configContent .= "date_default_timezone_set('Europe/Berlin');\n";
|
$configContent .= "date_default_timezone_set('Europe/Berlin');\n";
|
||||||
|
|
||||||
file_put_contents(__DIR__ . '/config.php', $configContent);
|
file_put_contents(__DIR__ . '/config.php', $configContent);
|
||||||
|
|
||||||
// .htaccess erstellen (ohne Rewrite Rules die Probleme machen)
|
// .htaccess erstellen (ohne Rewrite Rules die Probleme machen)
|
||||||
$htaccess = "# UniFi Voucher System\n\n";
|
$htaccess = "# UniFi Voucher System\n\n";
|
||||||
$htaccess .= "# Security\n";
|
$htaccess .= "# Security\n";
|
||||||
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|\\.md)$\">\n";
|
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|\\.md)$\">\n";
|
||||||
$htaccess .= " Order Allow,Deny\n";
|
$htaccess .= " Order Allow,Deny\n";
|
||||||
$htaccess .= " Deny from all\n";
|
$htaccess .= " Deny from all\n";
|
||||||
$htaccess .= "</FilesMatch>\n\n";
|
$htaccess .= "</FilesMatch>\n\n";
|
||||||
$htaccess .= "DirectoryIndex index.php\n";
|
$htaccess .= "DirectoryIndex index.php\n";
|
||||||
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
|
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
|
||||||
|
|
||||||
$success = true;
|
$success = true;
|
||||||
|
|
||||||
// Session-Daten löschen
|
// Session-Daten löschen
|
||||||
unset($_SESSION['install_db'], $_SESSION['install_admin'], $_SESSION['install_settings']);
|
unset($_SESSION['install_db'], $_SESSION['install_admin'], $_SESSION['install_settings']);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$errors[] = "Fehler bei der Installation: " . $e->getMessage();
|
$errors[] = "Fehler bei der Installation: " . $e->getMessage();
|
||||||
$step = 4;
|
$step = 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>UniFi Voucher System - Installation</title>
|
<title>UniFi Voucher System - Installation</title>
|
||||||
<style>
|
<?php require_once __DIR__ . '/includes/Ui.php'; ?>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
<?= Ui::head(null) ?>
|
||||||
body {
|
<style>
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
/* Installer-spezifisch: Fortschrittsanzeige und Abschnitte */
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
.install-head { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; }
|
||||||
min-height: 100vh;
|
.progress { position: relative; display: flex; justify-content: space-between; margin: 26px 0 28px; }
|
||||||
display: flex;
|
.progress::before {
|
||||||
align-items: center;
|
content: ''; position: absolute; top: 14px; left: 14px; right: 14px;
|
||||||
justify-content: center;
|
height: 2px; background: var(--border-color); z-index: 0;
|
||||||
padding: 20px;
|
}
|
||||||
}
|
.progress-step {
|
||||||
.container {
|
position: relative; z-index: 1;
|
||||||
background: white;
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
border-radius: 16px;
|
display: flex; align-items: center; justify-content: center;
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||||
max-width: 600px;
|
color: var(--text-muted); font-size: 12px; font-weight: 650;
|
||||||
width: 100%;
|
}
|
||||||
padding: 40px;
|
.progress-step.active { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--ring); }
|
||||||
}
|
.progress-step.completed { background: var(--success); border-color: var(--success); color: #fff; }
|
||||||
h1 { color: #333; margin-bottom: 10px; font-size: 28px; }
|
.section {
|
||||||
h2 { color: #667eea; margin-bottom: 20px; font-size: 20px; font-weight: 500; }
|
padding: 18px;
|
||||||
.progress {
|
margin-bottom: 20px;
|
||||||
display: flex;
|
background: var(--bg-subtle);
|
||||||
justify-content: space-between;
|
border: 1px solid var(--border-color);
|
||||||
margin: 30px 0;
|
border-radius: var(--r-md);
|
||||||
position: relative;
|
}
|
||||||
}
|
.section h3 { font-size: 14px; margin-bottom: 14px; }
|
||||||
.progress::before {
|
.error, .success {
|
||||||
content: '';
|
padding: 12px 15px; margin-bottom: 20px;
|
||||||
position: absolute;
|
border-radius: var(--r-md); font-size: 13.5px;
|
||||||
top: 15px;
|
border: 1px solid var(--danger-border); background: var(--danger-soft); color: var(--danger);
|
||||||
left: 0;
|
}
|
||||||
right: 0;
|
.success { border-color: var(--success-border); background: var(--success-soft); color: var(--success); }
|
||||||
height: 2px;
|
form > h3 { font-size: 15px; margin-bottom: 16px; }
|
||||||
background: #e0e0e0;
|
</style>
|
||||||
z-index: 0;
|
</head>
|
||||||
}
|
<body class="app-body focus-page">
|
||||||
.progress-step {
|
<div class="focus-card card">
|
||||||
width: 30px;
|
<div class="install-head">
|
||||||
height: 30px;
|
<span class="focus-icon"><i class="fas fa-rocket" aria-hidden="true"></i></span>
|
||||||
border-radius: 50%;
|
<div>
|
||||||
background: #e0e0e0;
|
<h1 style="font-size:20px;">UniFi Voucher System</h1>
|
||||||
display: flex;
|
<p class="sub">Installation in fünf Schritten</p>
|
||||||
align-items: center;
|
</div>
|
||||||
justify-content: center;
|
</div>
|
||||||
font-weight: bold;
|
|
||||||
color: #999;
|
<div class="progress">
|
||||||
position: relative;
|
<div class="progress-step <?= $step >= 1 ? 'completed' : '' ?>">1</div>
|
||||||
z-index: 1;
|
<div class="progress-step <?= $step >= 2 ? 'completed' : ($step === 1 ? 'active' : '') ?>">2</div>
|
||||||
}
|
<div class="progress-step <?= $step >= 3 ? 'completed' : ($step === 2 ? 'active' : '') ?>">3</div>
|
||||||
.progress-step.active {
|
<div class="progress-step <?= $step >= 4 ? 'completed' : ($step === 3 ? 'active' : '') ?>">4</div>
|
||||||
background: #667eea;
|
<div class="progress-step <?= $step >= 5 ? 'active' : '' ?>">5</div>
|
||||||
color: white;
|
</div>
|
||||||
}
|
|
||||||
.progress-step.completed {
|
<?php if (!empty($errors)): ?>
|
||||||
background: #4caf50;
|
<div class="error">
|
||||||
color: white;
|
<?php foreach ($errors as $error): ?>
|
||||||
}
|
<div><?= htmlspecialchars($error) ?></div>
|
||||||
.form-group {
|
<?php endforeach; ?>
|
||||||
margin-bottom: 20px;
|
</div>
|
||||||
}
|
<?php endif; ?>
|
||||||
label {
|
|
||||||
display: block;
|
<?php if ($success): ?>
|
||||||
margin-bottom: 8px;
|
<div class="success">
|
||||||
color: #555;
|
<strong>✓ Installation erfolgreich abgeschlossen!</strong><br>
|
||||||
font-weight: 500;
|
Sie können sich jetzt mit Ihren Admin-Zugangsdaten anmelden.
|
||||||
}
|
</div>
|
||||||
input[type="text"],
|
<a href="index.php" class="btn btn-primary btn-lg btn-block">Zum Login</a>
|
||||||
input[type="email"],
|
<?php elseif ($step === 1): ?>
|
||||||
input[type="password"],
|
<form method="post">
|
||||||
textarea {
|
<input type="hidden" name="step" value="2">
|
||||||
width: 100%;
|
<h3>Schritt 1: Datenbank-Konfiguration</h3>
|
||||||
padding: 12px;
|
|
||||||
border: 2px solid #e0e0e0;
|
<div class="form-group">
|
||||||
border-radius: 8px;
|
<label>Datenbank-Host</label>
|
||||||
font-size: 14px;
|
<input type="text" name="db_host" value="localhost" required>
|
||||||
transition: border-color 0.3s;
|
<div class="help-text">Meist "localhost"</div>
|
||||||
}
|
</div>
|
||||||
input:focus, textarea:focus {
|
|
||||||
outline: none;
|
<div class="form-group">
|
||||||
border-color: #667eea;
|
<label>Datenbankname</label>
|
||||||
}
|
<input type="text" name="db_name" value="unifi_voucher" required>
|
||||||
textarea {
|
<div class="help-text">Name der Datenbank (wird erstellt falls nicht vorhanden)</div>
|
||||||
resize: vertical;
|
</div>
|
||||||
min-height: 80px;
|
|
||||||
}
|
<div class="form-group">
|
||||||
.checkbox-group {
|
<label>Datenbank-Benutzer</label>
|
||||||
display: flex;
|
<input type="text" name="db_user" required>
|
||||||
align-items: center;
|
</div>
|
||||||
}
|
|
||||||
.checkbox-group input {
|
<div class="form-group">
|
||||||
width: auto;
|
<label>Datenbank-Passwort</label>
|
||||||
margin-right: 10px;
|
<input type="password" name="db_pass">
|
||||||
}
|
</div>
|
||||||
.btn {
|
|
||||||
background: #667eea;
|
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button>
|
||||||
color: white;
|
</form>
|
||||||
padding: 14px 30px;
|
|
||||||
border: none;
|
<?php elseif ($step === 2): ?>
|
||||||
border-radius: 8px;
|
<form method="post">
|
||||||
font-size: 16px;
|
<input type="hidden" name="step" value="3">
|
||||||
font-weight: 600;
|
<h3>Schritt 2: Administrator-Account</h3>
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.3s;
|
<div class="form-group">
|
||||||
width: 100%;
|
<label>Name</label>
|
||||||
}
|
<input type="text" name="admin_name" required>
|
||||||
.btn:hover {
|
</div>
|
||||||
background: #5568d3;
|
|
||||||
}
|
<div class="form-group">
|
||||||
.error {
|
<label>E-Mail</label>
|
||||||
background: #fee;
|
<input type="email" name="admin_email" required>
|
||||||
border: 1px solid #fcc;
|
</div>
|
||||||
color: #c33;
|
|
||||||
padding: 12px;
|
<div class="form-group">
|
||||||
border-radius: 8px;
|
<label>Passwort</label>
|
||||||
margin-bottom: 20px;
|
<input type="password" name="admin_password" required minlength="8">
|
||||||
}
|
<div class="help-text">Mindestens 8 Zeichen</div>
|
||||||
.success {
|
</div>
|
||||||
background: #efe;
|
|
||||||
border: 1px solid #cfc;
|
<div class="form-group">
|
||||||
color: #3c3;
|
<label>Passwort bestätigen</label>
|
||||||
padding: 12px;
|
<input type="password" name="admin_password_confirm" required>
|
||||||
border-radius: 8px;
|
</div>
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button>
|
||||||
.help-text {
|
</form>
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
<?php elseif ($step === 3): ?>
|
||||||
margin-top: 4px;
|
<form method="post">
|
||||||
}
|
<input type="hidden" name="step" value="4">
|
||||||
.section {
|
<h3>Schritt 3: Allgemeine Einstellungen</h3>
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 20px;
|
<div class="form-group">
|
||||||
border-radius: 8px;
|
<label>Anwendungs-Titel</label>
|
||||||
margin-bottom: 20px;
|
<input type="text" name="app_title" value="UniFi Voucher System" required>
|
||||||
}
|
</div>
|
||||||
.section h3 {
|
|
||||||
margin-bottom: 15px;
|
<div class="form-group">
|
||||||
color: #333;
|
<label>Logo-URL (optional)</label>
|
||||||
font-size: 16px;
|
<input type="text" name="logo_url" placeholder="https://example.com/logo.png">
|
||||||
}
|
</div>
|
||||||
</style>
|
|
||||||
</head>
|
<div class="form-group">
|
||||||
<body>
|
<label>Anleitung Überschrift</label>
|
||||||
<div class="container">
|
<input type="text" name="instruction_header" value="So verwenden Sie Ihren Code">
|
||||||
<h1>🚀 UniFi Voucher System</h1>
|
</div>
|
||||||
<h2>Installation</h2>
|
|
||||||
|
<div class="form-group">
|
||||||
<div class="progress">
|
<label>Anleitung Text</label>
|
||||||
<div class="progress-step <?= $step >= 1 ? 'completed' : '' ?>">1</div>
|
<textarea name="instruction_text">Verbinden Sie sich mit dem WLAN und geben Sie den Code auf der Anmeldeseite ein.</textarea>
|
||||||
<div class="progress-step <?= $step >= 2 ? 'completed' : ($step === 1 ? 'active' : '') ?>">2</div>
|
</div>
|
||||||
<div class="progress-step <?= $step >= 3 ? 'completed' : ($step === 2 ? 'active' : '') ?>">3</div>
|
|
||||||
<div class="progress-step <?= $step >= 4 ? 'completed' : ($step === 3 ? 'active' : '') ?>">4</div>
|
<div class="form-group checkbox-group">
|
||||||
<div class="progress-step <?= $step >= 5 ? 'active' : '' ?>">5</div>
|
<input type="checkbox" name="public_access" id="public_access">
|
||||||
</div>
|
<label for="public_access" style="margin: 0;">Öffentlicher Zugriff auf Code-Erstellung</label>
|
||||||
|
</div>
|
||||||
<?php if (!empty($errors)): ?>
|
|
||||||
<div class="error">
|
<div class="section">
|
||||||
<?php foreach ($errors as $error): ?>
|
<h3>Microsoft 365 Login (Optional)</h3>
|
||||||
<div><?= htmlspecialchars($error) ?></div>
|
<div class="form-group">
|
||||||
<?php endforeach; ?>
|
<label>Client ID</label>
|
||||||
</div>
|
<input type="text" name="m365_client_id">
|
||||||
<?php endif; ?>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
<?php if ($success): ?>
|
<label>Client Secret</label>
|
||||||
<div class="success">
|
<input type="password" name="m365_client_secret">
|
||||||
<strong>✓ Installation erfolgreich abgeschlossen!</strong><br>
|
</div>
|
||||||
Sie können sich jetzt mit Ihren Admin-Zugangsdaten anmelden.
|
<div class="form-group">
|
||||||
</div>
|
<label>Tenant ID</label>
|
||||||
<a href="index.php" class="btn">Zum Login</a>
|
<input type="text" name="m365_tenant_id">
|
||||||
<?php elseif ($step === 1): ?>
|
</div>
|
||||||
<form method="post">
|
<div class="help-text">Leer lassen, wenn M365-Login nicht verwendet werden soll</div>
|
||||||
<input type="hidden" name="step" value="2">
|
</div>
|
||||||
<h3>Schritt 1: Datenbank-Konfiguration</h3>
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button>
|
||||||
<div class="form-group">
|
</form>
|
||||||
<label>Datenbank-Host</label>
|
|
||||||
<input type="text" name="db_host" value="localhost" required>
|
<?php elseif ($step === 4): ?>
|
||||||
<div class="help-text">Meist "localhost"</div>
|
<form method="post">
|
||||||
</div>
|
<input type="hidden" name="step" value="5">
|
||||||
|
<h3>Schritt 4: Installation abschließen</h3>
|
||||||
<div class="form-group">
|
|
||||||
<label>Datenbankname</label>
|
<p style="margin-bottom: 20px; color: #666;">
|
||||||
<input type="text" name="db_name" value="unifi_voucher" required>
|
Klicken Sie auf "Installation abschließen", um die Einrichtung zu beenden.
|
||||||
<div class="help-text">Name der Datenbank (wird erstellt falls nicht vorhanden)</div>
|
Die Datenbank und alle notwendigen Dateien werden erstellt.
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<div class="form-group">
|
<button type="submit" class="btn btn-primary btn-lg btn-block">Installation abschließen</button>
|
||||||
<label>Datenbank-Benutzer</label>
|
</form>
|
||||||
<input type="text" name="db_user" required>
|
<?php endif; ?>
|
||||||
</div>
|
<?= Ui::credit() ?>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
</body>
|
||||||
<label>Datenbank-Passwort</label>
|
|
||||||
<input type="password" name="db_pass">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn">Weiter →</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php elseif ($step === 2): ?>
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="step" value="3">
|
|
||||||
<h3>Schritt 2: Administrator-Account</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Name</label>
|
|
||||||
<input type="text" name="admin_name" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>E-Mail</label>
|
|
||||||
<input type="email" name="admin_email" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Passwort</label>
|
|
||||||
<input type="password" name="admin_password" required minlength="8">
|
|
||||||
<div class="help-text">Mindestens 8 Zeichen</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Passwort bestätigen</label>
|
|
||||||
<input type="password" name="admin_password_confirm" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn">Weiter →</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php elseif ($step === 3): ?>
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="step" value="4">
|
|
||||||
<h3>Schritt 3: Allgemeine Einstellungen</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Anwendungs-Titel</label>
|
|
||||||
<input type="text" name="app_title" value="UniFi Voucher System" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Logo-URL (optional)</label>
|
|
||||||
<input type="text" name="logo_url" placeholder="https://example.com/logo.png">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Anleitung Überschrift</label>
|
|
||||||
<input type="text" name="instruction_header" value="So verwenden Sie Ihren Code">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Anleitung Text</label>
|
|
||||||
<textarea name="instruction_text">Verbinden Sie sich mit dem WLAN und geben Sie den Code auf der Anmeldeseite ein.</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group checkbox-group">
|
|
||||||
<input type="checkbox" name="public_access" id="public_access">
|
|
||||||
<label for="public_access" style="margin: 0;">Öffentlicher Zugriff auf Code-Erstellung</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Microsoft 365 Login (Optional)</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Client ID</label>
|
|
||||||
<input type="text" name="m365_client_id">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Client Secret</label>
|
|
||||||
<input type="password" name="m365_client_secret">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Tenant ID</label>
|
|
||||||
<input type="text" name="m365_tenant_id">
|
|
||||||
</div>
|
|
||||||
<div class="help-text">Leer lassen, wenn M365-Login nicht verwendet werden soll</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn">Weiter →</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php elseif ($step === 4): ?>
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="step" value="5">
|
|
||||||
<h3>Schritt 4: Installation abschließen</h3>
|
|
||||||
|
|
||||||
<p style="margin-bottom: 20px; color: #666;">
|
|
||||||
Klicken Sie auf "Installation abschließen", um die Einrichtung zu beenden.
|
|
||||||
Die Datenbank und alle notwendigen Dateien werden erstellt.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button type="submit" class="btn">Installation abschließen</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
245
kiosk.php
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Öffentliche Display-Seite ("Kiosk").
|
||||||
|
*
|
||||||
|
* Aufruf: kiosk.php?k=<token>
|
||||||
|
*
|
||||||
|
* Gedacht für ein Tablet oder einen Bildschirm im Empfangsbereich: ein großer
|
||||||
|
* Knopf, ein Klick, ein Zugangscode. Gäste ohne Zugriff auf den Bildschirm
|
||||||
|
* können denselben Link über den QR-Code am Handy öffnen.
|
||||||
|
*/
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
|
require_once __DIR__ . '/includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/includes/I18n.php';
|
||||||
|
require_once __DIR__ . '/includes/Ui.php';
|
||||||
|
require_once __DIR__ . '/includes/Kiosk.php';
|
||||||
|
require_once __DIR__ . '/includes/VoucherService.php';
|
||||||
|
|
||||||
|
I18n::init();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$auth = new Auth();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
die('Datenbankfehler');
|
||||||
|
}
|
||||||
|
|
||||||
|
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
|
$token = Kiosk::sanitizeToken($_GET['k'] ?? '');
|
||||||
|
$kiosk = $token !== '' ? Kiosk::findByToken($db, $token) : null;
|
||||||
|
|
||||||
|
if (!$kiosk) {
|
||||||
|
http_response_code(404);
|
||||||
|
$notFound = true;
|
||||||
|
} else {
|
||||||
|
$notFound = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$voucher = null; // erzeugter Code
|
||||||
|
$error = '';
|
||||||
|
$waitSecs = 0;
|
||||||
|
|
||||||
|
if (!$notFound && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
|
$error = __('error_csrf');
|
||||||
|
} else {
|
||||||
|
$limits = Kiosk::checkLimits($db, $kiosk);
|
||||||
|
if (!$limits['allowed']) {
|
||||||
|
$waitSecs = (int)$limits['wait'];
|
||||||
|
$error = $limits['reason'] === 'cooldown'
|
||||||
|
? str_replace('{seconds}', (string)$waitSecs, __('kiosk_error_cooldown'))
|
||||||
|
: __('kiosk_error_limit');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [(int)$kiosk['site_id']]);
|
||||||
|
if (!$site) {
|
||||||
|
throw new Exception(__('error_site_not_found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = Kiosk::voucherSettings($db, $kiosk);
|
||||||
|
$voucher = VoucherService::create(
|
||||||
|
$db,
|
||||||
|
$site,
|
||||||
|
$kiosk['name'],
|
||||||
|
$settings['max_uses'],
|
||||||
|
$settings['expire_minutes'],
|
||||||
|
null,
|
||||||
|
$settings['qos'],
|
||||||
|
(int)$kiosk['id']
|
||||||
|
);
|
||||||
|
|
||||||
|
Kiosk::markUsed($db, (int)$kiosk['id']);
|
||||||
|
$auth->writeAuditLog(null, 'voucher_kiosk', 'kiosk', (int)$kiosk['id'],
|
||||||
|
$kiosk['name'] . ' · ' . $voucher['code']);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('Kiosk-Fehler: ' . $e->getMessage());
|
||||||
|
$error = __('kiosk_error_generic');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$headline = trim((string)($kiosk['headline'] ?? '')) ?: __('kiosk_default_headline');
|
||||||
|
$subline = trim((string)($kiosk['subline'] ?? '')) ?: __('kiosk_default_subline');
|
||||||
|
$display = max(10, (int)($kiosk['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS));
|
||||||
|
$selfUrl = $kiosk ? Kiosk::publicUrl($kiosk['token']) : '';
|
||||||
|
|
||||||
|
// Gestaltung dieser Display-Seite (eigene Werte, sonst die des Systems)
|
||||||
|
$look = $kiosk ? Kiosk::appearance($db, $kiosk) : ['logo'=>'','background'=>'','overlay'=>0.45,'accent'=>'','card'=>'light'];
|
||||||
|
$logoUrl = $look['logo'];
|
||||||
|
$bodyClass = 'kiosk-body';
|
||||||
|
$bodyStyle = '';
|
||||||
|
if ($look['background'] !== '') {
|
||||||
|
$bodyClass .= ' has-background';
|
||||||
|
$bodyStyle .= "--kiosk-bg:url('" . htmlspecialchars(Ui::mediaUrl($look['background']), ENT_QUOTES) . "');"
|
||||||
|
. '--kiosk-overlay:' . $look['overlay'] . ';';
|
||||||
|
}
|
||||||
|
if ($look['card'] === 'dark') {
|
||||||
|
$bodyClass .= ' kiosk-dark';
|
||||||
|
}
|
||||||
|
if ($look['accent'] !== '') {
|
||||||
|
// Nur die Akzentfarbe dieser Seite überschreiben – der Rest bleibt Design-System.
|
||||||
|
$bodyStyle .= '--accent:' . $look['accent'] . ';'
|
||||||
|
. '--accent-hover:color-mix(in srgb, ' . $look['accent'] . ' 84%, #000);'
|
||||||
|
. '--accent-soft:color-mix(in srgb, ' . $look['accent'] . ' 14%, #fff);'
|
||||||
|
. '--accent-border:color-mix(in srgb, ' . $look['accent'] . ' 32%, #fff);';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<?= I18n::getLanguage() ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title><?= htmlspecialchars($appTitle) ?></title>
|
||||||
|
<?= Ui::head($db) ?>
|
||||||
|
<?php if (!$notFound): ?>
|
||||||
|
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js') ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</head>
|
||||||
|
<body class="<?= $bodyClass ?>"<?= $bodyStyle !== '' ? ' style="' . $bodyStyle . '"' : '' ?>>
|
||||||
|
|
||||||
|
<?php if ($notFound): ?>
|
||||||
|
<main class="kiosk-stage">
|
||||||
|
<div class="kiosk-card">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon"><i class="fas fa-link-slash" aria-hidden="true"></i></div>
|
||||||
|
<h1><?= __('kiosk_unknown') ?></h1>
|
||||||
|
<p><?= __('kiosk_unknown_hint') ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<?php elseif ($voucher): ?>
|
||||||
|
<main class="kiosk-stage">
|
||||||
|
<div class="kiosk-card kiosk-result">
|
||||||
|
<?php if ($logoUrl): ?>
|
||||||
|
<img class="kiosk-logo kiosk-logo-sm" src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="<?= htmlspecialchars($appTitle) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="kiosk-eyebrow"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('kiosk_ready') ?></p>
|
||||||
|
<div class="kiosk-code" id="voucherCode"><?= htmlspecialchars($voucher['code']) ?></div>
|
||||||
|
<div class="kiosk-meta">
|
||||||
|
<span><i class="fas fa-location-dot" aria-hidden="true"></i> <?= htmlspecialchars($voucher['site_name']) ?></span>
|
||||||
|
<span><i class="fas fa-clock" aria-hidden="true"></i> <?= (int)$voucher['expire_min'] ?> <?= __('minutes_short') ?></span>
|
||||||
|
<span><i class="fas fa-mobile-screen" aria-hidden="true"></i> <?= (int)$voucher['max_uses'] ?> <?= __('label_devices') ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="kiosk-qr">
|
||||||
|
<div id="qrcode"></div>
|
||||||
|
<p class="kiosk-qr-label"><?= __('kiosk_scan_code') ?></p>
|
||||||
|
</div>
|
||||||
|
<p class="kiosk-countdown">
|
||||||
|
<?= str_replace('{seconds}', '<span id="countdown">' . $display . '</span>', __('kiosk_reset_in')) ?>
|
||||||
|
</p>
|
||||||
|
<a class="btn btn-secondary btn-lg" href="?k=<?= htmlspecialchars($kiosk['token']) ?>"><?= __('kiosk_done') ?></a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<?php else: ?>
|
||||||
|
<main class="kiosk-stage">
|
||||||
|
<div class="kiosk-card">
|
||||||
|
<?php if ($logoUrl): ?>
|
||||||
|
<img class="kiosk-logo" src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="<?= htmlspecialchars($appTitle) ?>">
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="brand-mark kiosk-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h1 class="kiosk-headline"><?= htmlspecialchars($headline) ?></h1>
|
||||||
|
<p class="kiosk-subline"><?= htmlspecialchars($subline) ?></p>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="alert alert-error kiosk-alert"><?= htmlspecialchars($error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" id="kioskForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
|
||||||
|
<button type="submit" class="btn btn-primary kiosk-button" id="kioskButton"
|
||||||
|
<?= $waitSecs > 0 ? 'disabled' : '' ?>>
|
||||||
|
<i class="fas fa-wifi" aria-hidden="true"></i>
|
||||||
|
<span><?= __('kiosk_button') ?></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="kiosk-phone">
|
||||||
|
<div id="selfQr" class="kiosk-phone-qr"></div>
|
||||||
|
<p><?= __('kiosk_phone_hint') ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<footer class="kiosk-footer"><?= Ui::credit() ?></footer>
|
||||||
|
|
||||||
|
<?php if (!$notFound): ?>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
<?php if ($voucher): ?>
|
||||||
|
// Code als QR – lokal erzeugt, ohne externen Dienst.
|
||||||
|
new QRCode(document.getElementById('qrcode'), {
|
||||||
|
text: <?= json_encode(str_replace('-', '', $voucher['code'])) ?>,
|
||||||
|
width: 240, height: 240,
|
||||||
|
colorDark: '#101625', colorLight: '#ffffff',
|
||||||
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nach der Anzeigedauer zurück zum Startbildschirm, damit der nächste
|
||||||
|
// Gast nicht den Code seines Vorgängers sieht.
|
||||||
|
var left = <?= $display ?>;
|
||||||
|
var out = document.getElementById('countdown');
|
||||||
|
setInterval(function () {
|
||||||
|
left -= 1;
|
||||||
|
if (out) out.textContent = left > 0 ? left : 0;
|
||||||
|
if (left <= 0) location.href = '?k=<?= htmlspecialchars($kiosk['token'], ENT_QUOTES) ?>';
|
||||||
|
}, 1000);
|
||||||
|
<?php else: ?>
|
||||||
|
// QR auf diese Seite selbst: Gäste öffnen sie am eigenen Handy.
|
||||||
|
new QRCode(document.getElementById('selfQr'), {
|
||||||
|
text: <?= json_encode($selfUrl) ?>,
|
||||||
|
width: 150, height: 150,
|
||||||
|
colorDark: '#101625', colorLight: '#ffffff',
|
||||||
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
|
});
|
||||||
|
|
||||||
|
// Doppelklicks auf dem Touch-Display verhindern.
|
||||||
|
var form = document.getElementById('kioskForm');
|
||||||
|
var button = document.getElementById('kioskButton');
|
||||||
|
if (form && button) {
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
|
button.disabled = true;
|
||||||
|
button.querySelector('span').textContent = <?= json_encode(__('kiosk_working')) ?>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
<?php if ($waitSecs > 0): ?>
|
||||||
|
// Nach der Wartezeit wieder freigeben.
|
||||||
|
setTimeout(function () { location.href = '?k=<?= htmlspecialchars($kiosk['token'], ENT_QUOTES) ?>'; }, <?= $waitSecs * 1000 ?>);
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
338
lang/de.php
|
|
@ -1,6 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
return [
|
return [
|
||||||
// Navigation
|
// Navigation
|
||||||
|
'nav_group_overview'=> 'Übersicht',
|
||||||
|
'nav_group_manage' => 'Verwaltung',
|
||||||
|
'nav_group_system' => 'System',
|
||||||
'nav_dashboard' => 'Dashboard',
|
'nav_dashboard' => 'Dashboard',
|
||||||
'nav_sites' => 'Sites verwalten',
|
'nav_sites' => 'Sites verwalten',
|
||||||
'nav_users' => 'Benutzer verwalten',
|
'nav_users' => 'Benutzer verwalten',
|
||||||
|
|
@ -98,7 +101,9 @@ return [
|
||||||
'voucher_email_hint' => 'gast@example.com',
|
'voucher_email_hint' => 'gast@example.com',
|
||||||
'voucher_create_btn' => 'Voucher erstellen',
|
'voucher_create_btn' => 'Voucher erstellen',
|
||||||
'voucher_creating' => 'Erstelle Voucher...',
|
'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_validity' => 'Gültig für {minutes} Minuten ab Erstellung',
|
||||||
'voucher_qr_label' => 'QR-Code scannen zum Verbinden',
|
'voucher_qr_label' => 'QR-Code scannen zum Verbinden',
|
||||||
'voucher_print_btn' => 'Code ausdrucken',
|
'voucher_print_btn' => 'Code ausdrucken',
|
||||||
|
|
@ -119,6 +124,7 @@ return [
|
||||||
'bulk_success' => '{count} Vouchers erfolgreich erstellt!',
|
'bulk_success' => '{count} Vouchers erfolgreich erstellt!',
|
||||||
'bulk_print_all' => 'Alle ausdrucken',
|
'bulk_print_all' => 'Alle ausdrucken',
|
||||||
'bulk_results' => 'Erstellte Vouchers ({count})',
|
'bulk_results' => 'Erstellte Vouchers ({count})',
|
||||||
|
'bulk_results_hint' => 'Alle Codes sind sofort gültig und können gedruckt oder kopiert werden.',
|
||||||
|
|
||||||
// Templates
|
// Templates
|
||||||
'templates_title' => 'Voucher-Profile',
|
'templates_title' => 'Voucher-Profile',
|
||||||
|
|
@ -214,6 +220,323 @@ return [
|
||||||
// Settings
|
// Settings
|
||||||
'settings_title' => 'Einstellungen',
|
'settings_title' => 'Einstellungen',
|
||||||
'settings_subtitle' => 'System-Konfiguration und Personalisierung',
|
'settings_subtitle' => 'System-Konfiguration und Personalisierung',
|
||||||
|
'settings_tab_branding' => 'Design',
|
||||||
|
'settings_branding_intro' => 'Farben und Formen der gesamten Oberfläche – Frontend wie Administration.',
|
||||||
|
'settings_brand_accent' => 'Akzentfarbe (hell)',
|
||||||
|
'settings_brand_accent_hint' => 'Buttons, aktive Navigation, Links. Abgeleitete Töne werden automatisch berechnet.',
|
||||||
|
'settings_brand_accent_dark' => 'Akzentfarbe (Dark Mode)',
|
||||||
|
'settings_brand_accent_dark_hint' => 'Im Dark Mode meist eine hellere Variante der Grundfarbe.',
|
||||||
|
'settings_brand_gradient_from'=> 'Markenverlauf: Start',
|
||||||
|
'settings_brand_gradient_to' => 'Markenverlauf: Ende',
|
||||||
|
'settings_brand_gradient_hint'=> 'Für Logo-Kachel, Avatare und die Voucher-Karte.',
|
||||||
|
'settings_brand_radius' => 'Eckenradius',
|
||||||
|
'settings_brand_radius_sharp' => 'Kantig',
|
||||||
|
'settings_brand_radius_default'=> 'Standard',
|
||||||
|
'settings_brand_radius_round' => 'Rund',
|
||||||
|
'settings_brand_preview' => 'Vorschau',
|
||||||
|
'settings_brand_link' => 'Beispiel-Link',
|
||||||
|
'settings_image_url_placeholder' => 'https://… oder Datei hochladen',
|
||||||
|
'settings_image_remove' => 'Bild entfernen',
|
||||||
|
'settings_upload_hint' => 'PNG, JPG, WEBP, GIF oder SVG – maximal 3 MB.',
|
||||||
|
'upload_error_generic' => 'Die Datei konnte nicht hochgeladen werden.',
|
||||||
|
'upload_error_size' => 'Die Datei ist zu groß (maximal 3 MB).',
|
||||||
|
'upload_error_type' => 'Dieser Dateityp wird nicht unterstützt.',
|
||||||
|
'upload_error_dir' => 'Der Ordner uploads/ ist nicht beschreibbar.',
|
||||||
|
'settings_tab_login' => 'Login-Seite',
|
||||||
|
'settings_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_general' => 'Allgemein',
|
||||||
'settings_tab_defaults' => 'Voucher-Standards',
|
'settings_tab_defaults' => 'Voucher-Standards',
|
||||||
'settings_tab_cron' => 'Cron-Sync',
|
'settings_tab_cron' => 'Cron-Sync',
|
||||||
|
|
@ -224,8 +547,8 @@ return [
|
||||||
'settings_tab_password' => 'Passwort',
|
'settings_tab_password' => 'Passwort',
|
||||||
'settings_saved' => 'Einstellungen erfolgreich gespeichert!',
|
'settings_saved' => 'Einstellungen erfolgreich gespeichert!',
|
||||||
'settings_app_title' => 'Anwendungs-Titel *',
|
'settings_app_title' => 'Anwendungs-Titel *',
|
||||||
'settings_logo_url' => 'Logo-URL',
|
'settings_logo_url' => 'Logo',
|
||||||
'settings_favicon_url' => 'Favicon-URL',
|
'settings_favicon_url' => 'Favicon',
|
||||||
'settings_favicon_hint' => 'Icon im Browser-Tab (.ico, .png, .svg)',
|
'settings_favicon_hint' => 'Icon im Browser-Tab (.ico, .png, .svg)',
|
||||||
'settings_instr_header' => 'Anleitung - Überschrift',
|
'settings_instr_header' => 'Anleitung - Überschrift',
|
||||||
'settings_instr_text' => 'Anleitung - Text',
|
'settings_instr_text' => 'Anleitung - Text',
|
||||||
|
|
@ -244,13 +567,18 @@ return [
|
||||||
|
|
||||||
// Login / Auth
|
// Login / Auth
|
||||||
'login_title' => 'Anmelden',
|
'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_subtitle' => 'Melden Sie sich an, um fortzufahren',
|
||||||
'login_email' => 'E-Mail',
|
'login_email' => 'E-Mail',
|
||||||
'login_password' => 'Passwort',
|
'login_password' => 'Passwort',
|
||||||
'login_btn' => 'Anmelden',
|
'login_btn' => 'Anmelden',
|
||||||
'login_ms' => 'Mit Microsoft anmelden',
|
'login_ms' => 'Mit Microsoft anmelden',
|
||||||
'login_local' => 'Mit Benutzername und Passwort 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_forgot' => 'Passwort vergessen?',
|
||||||
'login_error_empty' => 'Bitte E-Mail und Passwort eingeben',
|
'login_error_empty' => 'Bitte E-Mail und Passwort eingeben',
|
||||||
'login_error_rate' => 'Zu viele Fehlversuche. Bitte warten Sie 10 Minuten.',
|
'login_error_rate' => 'Zu viele Fehlversuche. Bitte warten Sie 10 Minuten.',
|
||||||
|
|
@ -262,7 +590,7 @@ return [
|
||||||
'reset_email_label' => 'E-Mail-Adresse',
|
'reset_email_label' => 'E-Mail-Adresse',
|
||||||
'reset_send_btn' => 'Reset-Link senden',
|
'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_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' => 'Neues Passwort festlegen',
|
||||||
'reset_new_pw_label'=> 'Neues Passwort',
|
'reset_new_pw_label'=> 'Neues Passwort',
|
||||||
'reset_confirm_label'=> 'Passwort bestätigen',
|
'reset_confirm_label'=> 'Passwort bestätigen',
|
||||||
|
|
|
||||||
338
lang/en.php
|
|
@ -1,6 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
return [
|
return [
|
||||||
// Navigation
|
// Navigation
|
||||||
|
'nav_group_overview'=> 'Overview',
|
||||||
|
'nav_group_manage' => 'Management',
|
||||||
|
'nav_group_system' => 'System',
|
||||||
'nav_dashboard' => 'Dashboard',
|
'nav_dashboard' => 'Dashboard',
|
||||||
'nav_sites' => 'Manage Sites',
|
'nav_sites' => 'Manage Sites',
|
||||||
'nav_users' => 'Manage Users',
|
'nav_users' => 'Manage Users',
|
||||||
|
|
@ -98,7 +101,9 @@ return [
|
||||||
'voucher_email_hint' => 'guest@example.com',
|
'voucher_email_hint' => 'guest@example.com',
|
||||||
'voucher_create_btn' => 'Create Voucher',
|
'voucher_create_btn' => 'Create Voucher',
|
||||||
'voucher_creating' => 'Creating 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_validity' => 'Valid for {minutes} minutes from creation',
|
||||||
'voucher_qr_label' => 'Scan QR code to connect',
|
'voucher_qr_label' => 'Scan QR code to connect',
|
||||||
'voucher_print_btn' => 'Print Code',
|
'voucher_print_btn' => 'Print Code',
|
||||||
|
|
@ -119,6 +124,7 @@ return [
|
||||||
'bulk_success' => '{count} vouchers created successfully!',
|
'bulk_success' => '{count} vouchers created successfully!',
|
||||||
'bulk_print_all' => 'Print All',
|
'bulk_print_all' => 'Print All',
|
||||||
'bulk_results' => 'Created Vouchers ({count})',
|
'bulk_results' => 'Created Vouchers ({count})',
|
||||||
|
'bulk_results_hint' => 'All codes are valid immediately and can be printed or copied.',
|
||||||
|
|
||||||
// Templates
|
// Templates
|
||||||
'templates_title' => 'Voucher Profiles',
|
'templates_title' => 'Voucher Profiles',
|
||||||
|
|
@ -214,6 +220,323 @@ return [
|
||||||
// Settings
|
// Settings
|
||||||
'settings_title' => 'Settings',
|
'settings_title' => 'Settings',
|
||||||
'settings_subtitle' => 'System configuration and customization',
|
'settings_subtitle' => 'System configuration and customization',
|
||||||
|
'settings_tab_branding' => 'Design',
|
||||||
|
'settings_branding_intro' => 'Colours and shapes for the whole interface – front end and administration.',
|
||||||
|
'settings_brand_accent' => 'Accent colour (light)',
|
||||||
|
'settings_brand_accent_hint' => 'Buttons, active navigation, links. Derived shades are calculated automatically.',
|
||||||
|
'settings_brand_accent_dark' => 'Accent colour (dark mode)',
|
||||||
|
'settings_brand_accent_dark_hint' => 'Usually a lighter variant of the base colour for dark mode.',
|
||||||
|
'settings_brand_gradient_from'=> 'Brand gradient: start',
|
||||||
|
'settings_brand_gradient_to' => 'Brand gradient: end',
|
||||||
|
'settings_brand_gradient_hint'=> 'Used for the logo tile, avatars and the voucher card.',
|
||||||
|
'settings_brand_radius' => 'Corner radius',
|
||||||
|
'settings_brand_radius_sharp' => 'Sharp',
|
||||||
|
'settings_brand_radius_default'=> 'Default',
|
||||||
|
'settings_brand_radius_round' => 'Round',
|
||||||
|
'settings_brand_preview' => 'Preview',
|
||||||
|
'settings_brand_link' => 'Example link',
|
||||||
|
'settings_image_url_placeholder' => 'https://… or upload a file',
|
||||||
|
'settings_image_remove' => 'Remove image',
|
||||||
|
'settings_upload_hint' => 'PNG, JPG, WEBP, GIF or SVG – 3 MB maximum.',
|
||||||
|
'upload_error_generic' => 'The file could not be uploaded.',
|
||||||
|
'upload_error_size' => 'The file is too large (3 MB maximum).',
|
||||||
|
'upload_error_type' => 'This file type is not supported.',
|
||||||
|
'upload_error_dir' => 'The uploads/ directory is not writable.',
|
||||||
|
'settings_tab_login' => 'Login page',
|
||||||
|
'settings_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_general' => 'General',
|
||||||
'settings_tab_defaults' => 'Voucher Defaults',
|
'settings_tab_defaults' => 'Voucher Defaults',
|
||||||
'settings_tab_cron' => 'Cron Sync',
|
'settings_tab_cron' => 'Cron Sync',
|
||||||
|
|
@ -224,8 +547,8 @@ return [
|
||||||
'settings_tab_password' => 'Password',
|
'settings_tab_password' => 'Password',
|
||||||
'settings_saved' => 'Settings saved successfully!',
|
'settings_saved' => 'Settings saved successfully!',
|
||||||
'settings_app_title' => 'Application Title *',
|
'settings_app_title' => 'Application Title *',
|
||||||
'settings_logo_url' => 'Logo URL',
|
'settings_logo_url' => 'Logo',
|
||||||
'settings_favicon_url' => 'Favicon URL',
|
'settings_favicon_url' => 'Favicon',
|
||||||
'settings_favicon_hint' => 'Browser tab icon (.ico, .png, .svg)',
|
'settings_favicon_hint' => 'Browser tab icon (.ico, .png, .svg)',
|
||||||
'settings_instr_header' => 'Instructions - Headline',
|
'settings_instr_header' => 'Instructions - Headline',
|
||||||
'settings_instr_text' => 'Instructions - Text',
|
'settings_instr_text' => 'Instructions - Text',
|
||||||
|
|
@ -244,13 +567,18 @@ return [
|
||||||
|
|
||||||
// Login / Auth
|
// Login / Auth
|
||||||
'login_title' => 'Sign In',
|
'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_subtitle' => 'Sign in to continue',
|
||||||
'login_email' => 'Email',
|
'login_email' => 'Email',
|
||||||
'login_password' => 'Password',
|
'login_password' => 'Password',
|
||||||
'login_btn' => 'Sign In',
|
'login_btn' => 'Sign In',
|
||||||
'login_ms' => 'Sign in with Microsoft',
|
'login_ms' => 'Sign in with Microsoft',
|
||||||
'login_local' => 'Sign in with username and password',
|
'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_forgot' => 'Forgot password?',
|
||||||
'login_error_empty' => 'Please enter email and password',
|
'login_error_empty' => 'Please enter email and password',
|
||||||
'login_error_rate' => 'Too many failed attempts. Please wait 10 minutes.',
|
'login_error_rate' => 'Too many failed attempts. Please wait 10 minutes.',
|
||||||
|
|
@ -262,7 +590,7 @@ return [
|
||||||
'reset_email_label' => 'Email Address',
|
'reset_email_label' => 'Email Address',
|
||||||
'reset_send_btn' => 'Send Reset Link',
|
'reset_send_btn' => 'Send Reset Link',
|
||||||
'reset_success' => 'If an account with this email exists, you will receive an email shortly.',
|
'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' => 'Set New Password',
|
||||||
'reset_new_pw_label'=> 'New Password',
|
'reset_new_pw_label'=> 'New Password',
|
||||||
'reset_confirm_label'=> 'Confirm Password',
|
'reset_confirm_label'=> 'Confirm Password',
|
||||||
|
|
|
||||||
152
login.php
|
|
@ -6,11 +6,15 @@ ini_set('log_errors', 1);
|
||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
require_once __DIR__ . '/includes/Database.php';
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
require_once __DIR__ . '/includes/Auth.php';
|
require_once __DIR__ . '/includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/includes/Ui.php';
|
||||||
require_once __DIR__ . '/includes/I18n.php';
|
require_once __DIR__ . '/includes/I18n.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$auth = new Auth();
|
$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) {
|
} catch (Exception $e) {
|
||||||
die('Fehler beim Initialisieren: ' . $e->getMessage());
|
die('Fehler beim Initialisieren: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +121,34 @@ try {
|
||||||
|
|
||||||
$showLocalLogin = isset($_GET['local']) && $_GET['local'] === '1';
|
$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) {
|
} catch (Exception $e) {
|
||||||
die('Datenbankfehler: ' . $e->getMessage());
|
die('Datenbankfehler: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
@ -127,57 +159,60 @@ try {
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('login_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('login_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<link rel="stylesheet" href="assets/global.css">
|
<?= Ui::head($db) ?>
|
||||||
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
|
||||||
.login-container { background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); max-width: 420px; width: 100%; padding: 50px 40px; text-align: center; }
|
|
||||||
.logo { max-width: 200px; height: auto; margin-bottom: 30px; }
|
|
||||||
h1 { color: var(--text-primary); font-size: 28px; margin-bottom: 10px; }
|
|
||||||
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 30px; }
|
|
||||||
.form-group { margin-bottom: 20px; text-align: left; }
|
|
||||||
label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
|
||||||
input[type="email"], input[type="password"] { width: 100%; padding: 14px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; background: var(--bg-input); color: var(--text-primary); transition: all 0.2s; }
|
|
||||||
input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
|
|
||||||
.btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 10px; }
|
|
||||||
.btn:hover { background: var(--accent-hover); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102,126,234,0.4); }
|
|
||||||
.btn-microsoft { background: #2f2f2f; color: white; border: none; margin-top: 0; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 12px; padding: 16px 24px; border-radius: 10px; width: 100%; font-size: 15px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
|
|
||||||
.btn-microsoft:hover { background: #1a1a1a; transform: translateY(-2px); }
|
|
||||||
.btn-microsoft svg { width: 20px; height: 20px; }
|
|
||||||
.divider { margin: 22px 0; text-align: center; position: relative; }
|
|
||||||
.divider::before { content: ''; position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--border-color); }
|
|
||||||
.divider span { background: var(--bg-card); padding: 0 15px; color: var(--text-muted); font-size: 13px; position: relative; z-index: 1; }
|
|
||||||
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; }
|
|
||||||
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
|
|
||||||
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
|
|
||||||
.back-link { display: block; margin-top: 20px; color: var(--accent); text-decoration: none; font-size: 14px; }
|
|
||||||
.back-link:hover { text-decoration: underline; }
|
|
||||||
.local-login-link { display: block; margin-top: 20px; color: var(--text-muted); text-decoration: none; font-size: 13px; }
|
|
||||||
.local-login-link:hover { color: var(--accent); text-decoration: underline; }
|
|
||||||
.forgot-link { display: block; margin-top: 12px; text-align: right; color: var(--text-muted); font-size: 13px; text-decoration: none; }
|
|
||||||
.forgot-link:hover { color: var(--accent); text-decoration: underline; }
|
|
||||||
.header-tools { position: absolute; top: 20px; right: 20px; display: flex; gap: 8px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="auth-body<?= $showPanel ? '' : ' auth-body-single' ?>">
|
||||||
<div style="position:fixed;top:15px;right:20px;display:flex;gap:8px;z-index:10;">
|
|
||||||
<div class="lang-switcher">
|
<?php if ($showPanel): ?>
|
||||||
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
<section class="auth-visual<?= $loginBgImage !== '' ? ' has-image' : '' ?>" style="<?= $visualStyle ?>">
|
||||||
<button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>"
|
<div class="auth-brand">
|
||||||
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
<?php if ($loginLogo): ?>
|
||||||
<?php endforeach; ?>
|
<img src="<?= htmlspecialchars(Ui::mediaUrl($loginLogo)) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="auth-brand-logo">
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||||||
|
<span><?= htmlspecialchars($loginBrand) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="auth-claim">
|
||||||
|
<?php if ($claimTitle !== ''): ?><h2><?= htmlspecialchars($claimTitle) ?></h2><?php endif; ?>
|
||||||
|
<?php if ($claimText !== ''): ?><p><?= htmlspecialchars($claimText) ?></p><?php endif; ?>
|
||||||
|
<?php if (!empty($loginFeatures)): ?>
|
||||||
|
<ul class="auth-features">
|
||||||
|
<?php foreach ($loginFeatures as $feature): ?>
|
||||||
|
<li><span class="tick"><i class="fas fa-check" aria-hidden="true"></i></span> <?= htmlspecialchars($feature) ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="auth-foot"><?= htmlspecialchars($loginFooter) ?></div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="auth-panel">
|
||||||
|
<div class="auth-tools">
|
||||||
|
<div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
|
||||||
|
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
||||||
|
<button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>"
|
||||||
|
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
|
||||||
|
<i class="fas fa-moon" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">🌙</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<?php if ($logoUrl): ?>
|
<?php if (!$showPanel): ?>
|
||||||
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
|
<?php if ($loginLogo): ?>
|
||||||
<?php else: ?>
|
<img src="<?= htmlspecialchars(Ui::mediaUrl($loginLogo)) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="logo">
|
||||||
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
<?php else: ?>
|
||||||
|
<div class="auth-mark">
|
||||||
|
<span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||||||
|
<span><?= htmlspecialchars($loginBrand) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<h1><?= __('login_title') ?></h1>
|
||||||
<p class="subtitle"><?= __('login_subtitle') ?></p>
|
<p class="subtitle"><?= __('login_subtitle') ?></p>
|
||||||
|
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
|
|
@ -189,20 +224,19 @@ try {
|
||||||
|
|
||||||
<?php if ($show2fa): ?>
|
<?php if ($show2fa): ?>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<p style="color:var(--text-secondary,#666);font-size:14px;margin-bottom:18px;">
|
<p class="subtitle">
|
||||||
Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
|
Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
|
||||||
– oder einen Ihrer Recovery-Codes.
|
– oder einen Ihrer Recovery-Codes.
|
||||||
</p>
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="totp_code">Code</label>
|
<label for="totp_code">Code</label>
|
||||||
<input type="text" id="totp_code" name="totp_code" maxlength="9"
|
<input type="text" id="totp_code" name="totp_code" maxlength="9" class="code-input"
|
||||||
autocomplete="one-time-code" required autofocus
|
autocomplete="one-time-code" required autofocus
|
||||||
placeholder="123456 oder XXXX-XXXX"
|
placeholder="123456">
|
||||||
style="letter-spacing:3px;text-align:center;font-size:18px;">
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">Bestätigen</button>
|
<button type="submit" class="btn btn-primary btn-lg">Bestätigen</button>
|
||||||
</form>
|
</form>
|
||||||
<a href="login.php" class="local-login-link">Abbrechen</a>
|
<div class="auth-links"><a href="login.php" class="local-login-link">Abbrechen</a></div>
|
||||||
<?php elseif ($m365Enabled && !$showLocalLogin): ?>
|
<?php elseif ($m365Enabled && !$showLocalLogin): ?>
|
||||||
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn-microsoft">
|
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn-microsoft">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
|
||||||
|
|
@ -213,7 +247,7 @@ try {
|
||||||
</svg>
|
</svg>
|
||||||
<?= __('login_ms') ?>
|
<?= __('login_ms') ?>
|
||||||
</a>
|
</a>
|
||||||
<a href="?local=1" class="local-login-link"><?= __('login_local') ?></a>
|
<div class="auth-links"><a href="?local=1" class="local-login-link"><?= __('login_local') ?></a></div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -227,7 +261,7 @@ try {
|
||||||
<?php if ($smtpEnabled): ?>
|
<?php if ($smtpEnabled): ?>
|
||||||
<a href="forgot_password.php" class="forgot-link"><?= __('login_forgot') ?></a>
|
<a href="forgot_password.php" class="forgot-link"><?= __('login_forgot') ?></a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<button type="submit" class="btn"><?= __('login_btn') ?></button>
|
<button type="submit" class="btn btn-primary btn-lg"><?= __('login_btn') ?></button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php if ($m365Enabled): ?>
|
<?php if ($m365Enabled): ?>
|
||||||
|
|
@ -246,15 +280,17 @@ try {
|
||||||
|
|
||||||
<?php if (!$show2fa && $oidcEnabled): ?>
|
<?php if (!$show2fa && $oidcEnabled): ?>
|
||||||
<div class="divider"><span><?= __('or') ?></span></div>
|
<div class="divider"><span><?= __('or') ?></span></div>
|
||||||
<a href="<?= htmlspecialchars($oidcLoginUrl) ?>" class="btn" style="display:block;text-align:center;text-decoration:none;background:#444;">
|
<a href="<?= htmlspecialchars($oidcLoginUrl) ?>" class="btn btn-secondary btn-lg">
|
||||||
🔑 <?= htmlspecialchars($oidcName) ?>
|
<i class="fas fa-key" aria-hidden="true"></i> <?= htmlspecialchars($oidcName) ?>
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($publicAccess): ?>
|
<?php if ($publicAccess): ?>
|
||||||
<a href="index.php" class="back-link"><?= __('login_back') ?></a>
|
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('login_back') ?></a></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?= Ui::credit() ?>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<script src="assets/global.js"></script>
|
<script src="assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
511
login_simple.php
|
|
@ -1,329 +1,186 @@
|
||||||
<?php
|
<?php
|
||||||
// Umfassendes Error Reporting
|
// Umfassendes Error Reporting
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
ini_set('log_errors', 1);
|
ini_set('log_errors', 1);
|
||||||
ini_set('log_errors', 1);
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
// Versuche Dateien zu laden
|
// Versuche Dateien zu laden
|
||||||
$loadErrors = [];
|
$loadErrors = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!file_exists(__DIR__ . '/config.php')) {
|
if (!file_exists(__DIR__ . '/config.php')) {
|
||||||
throw new Exception('config.php nicht gefunden');
|
throw new Exception('config.php nicht gefunden');
|
||||||
}
|
}
|
||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$loadErrors[] = "Config: " . $e->getMessage();
|
$loadErrors[] = "Config: " . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!file_exists(__DIR__ . '/includes/Database.php')) {
|
if (!file_exists(__DIR__ . '/includes/Database.php')) {
|
||||||
throw new Exception('includes/Database.php nicht gefunden');
|
throw new Exception('includes/Database.php nicht gefunden');
|
||||||
}
|
}
|
||||||
require_once __DIR__ . '/includes/Database.php';
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$loadErrors[] = "Database: " . $e->getMessage();
|
$loadErrors[] = "Database: " . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!file_exists(__DIR__ . '/includes/Auth.php')) {
|
if (!file_exists(__DIR__ . '/includes/Auth.php')) {
|
||||||
throw new Exception('includes/Auth.php nicht gefunden');
|
throw new Exception('includes/Auth.php nicht gefunden');
|
||||||
}
|
}
|
||||||
require_once __DIR__ . '/includes/Auth.php';
|
require_once __DIR__ . '/includes/Auth.php';
|
||||||
} catch (Exception $e) {
|
require_once __DIR__ . '/includes/Ui.php';
|
||||||
$loadErrors[] = "Auth: " . $e->getMessage();
|
} catch (Exception $e) {
|
||||||
}
|
$loadErrors[] = "Auth: " . $e->getMessage();
|
||||||
|
}
|
||||||
// Wenn Ladefehler aufgetreten sind, zeige sie an
|
|
||||||
if (!empty($loadErrors)) {
|
// Wenn Ladefehler aufgetreten sind, zeige sie an
|
||||||
die('<h1>Fehler beim Laden der Dateien</h1><ul><li>' . implode('</li><li>', $loadErrors) . '</li></ul>');
|
if (!empty($loadErrors)) {
|
||||||
}
|
die('<h1>Fehler beim Laden der Dateien</h1><ul><li>' . implode('</li><li>', $loadErrors) . '</li></ul>');
|
||||||
|
}
|
||||||
// Ab hier normal weiter
|
|
||||||
try {
|
// Ab hier normal weiter
|
||||||
$auth = new Auth();
|
try {
|
||||||
} catch (Exception $e) {
|
$auth = new Auth();
|
||||||
die('<h1>Fehler bei Auth-Initialisierung</h1><p>' . $e->getMessage() . '</p>');
|
} catch (Exception $e) {
|
||||||
}
|
die('<h1>Fehler bei Auth-Initialisierung</h1><p>' . $e->getMessage() . '</p>');
|
||||||
|
}
|
||||||
// Wenn bereits eingeloggt, weiterleiten
|
|
||||||
if ($auth->isLoggedIn()) {
|
// Wenn bereits eingeloggt, weiterleiten
|
||||||
header('Location: index.php');
|
if ($auth->isLoggedIn()) {
|
||||||
exit;
|
header('Location: index.php');
|
||||||
}
|
exit;
|
||||||
|
}
|
||||||
$error = '';
|
|
||||||
$success = '';
|
$error = '';
|
||||||
|
$success = '';
|
||||||
// Login-Verarbeitung
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
// Login-Verarbeitung
|
||||||
try {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$email = $_POST['email'] ?? '';
|
try {
|
||||||
$password = $_POST['password'] ?? '';
|
$email = $_POST['email'] ?? '';
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
if (empty($email) || empty($password)) {
|
|
||||||
$error = 'Bitte E-Mail und Passwort eingeben';
|
if (empty($email) || empty($password)) {
|
||||||
} elseif ($auth->login($email, $password)) {
|
$error = 'Bitte E-Mail und Passwort eingeben';
|
||||||
header('Location: index.php');
|
} elseif ($auth->login($email, $password)) {
|
||||||
exit;
|
header('Location: index.php');
|
||||||
} else {
|
exit;
|
||||||
$error = 'Ungültige E-Mail oder Passwort';
|
} else {
|
||||||
}
|
$error = 'Ungültige E-Mail oder Passwort';
|
||||||
} catch (Exception $e) {
|
}
|
||||||
$error = 'Login-Fehler: ' . $e->getMessage();
|
} catch (Exception $e) {
|
||||||
}
|
$error = 'Login-Fehler: ' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
try {
|
|
||||||
$db = Database::getInstance();
|
try {
|
||||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
$db = Database::getInstance();
|
||||||
$logoUrl = $db->getSetting('logo_url', '');
|
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
$m365Enabled = !empty($db->getSetting('m365_client_id')) &&
|
$logoUrl = $db->getSetting('logo_url', '');
|
||||||
!empty($db->getSetting('m365_client_secret')) &&
|
$m365Enabled = !empty($db->getSetting('m365_client_id')) &&
|
||||||
!empty($db->getSetting('m365_tenant_id'));
|
!empty($db->getSetting('m365_client_secret')) &&
|
||||||
$publicAccess = $db->getSetting('public_access', 0);
|
!empty($db->getSetting('m365_tenant_id'));
|
||||||
|
$publicAccess = $db->getSetting('public_access', 0);
|
||||||
// M365 OAuth URL generieren falls aktiviert
|
|
||||||
$m365LoginUrl = '';
|
// M365 OAuth URL generieren falls aktiviert
|
||||||
if ($m365Enabled) {
|
$m365LoginUrl = '';
|
||||||
$clientId = $db->getSetting('m365_client_id');
|
if ($m365Enabled) {
|
||||||
$tenantId = $db->getSetting('m365_tenant_id');
|
$clientId = $db->getSetting('m365_client_id');
|
||||||
|
$tenantId = $db->getSetting('m365_tenant_id');
|
||||||
// Dynamische Redirect URI basierend auf aktuellem Pfad
|
|
||||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
// Dynamische Redirect URI basierend auf aktuellem Pfad
|
||||||
$host = $_SERVER['HTTP_HOST'];
|
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
$host = $_SERVER['HTTP_HOST'];
|
||||||
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
$redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
|
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||||
|
$redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
|
||||||
$params = [
|
|
||||||
'client_id' => $clientId,
|
$params = [
|
||||||
'response_type' => 'code',
|
'client_id' => $clientId,
|
||||||
'redirect_uri' => $redirectUri,
|
'response_type' => 'code',
|
||||||
'response_mode' => 'query',
|
'redirect_uri' => $redirectUri,
|
||||||
'scope' => 'openid profile email User.Read',
|
'response_mode' => 'query',
|
||||||
'state' => bin2hex(random_bytes(16))
|
'scope' => 'openid profile email User.Read',
|
||||||
];
|
'state' => bin2hex(random_bytes(16))
|
||||||
|
];
|
||||||
$_SESSION['m365_state'] = $params['state'];
|
|
||||||
|
$_SESSION['m365_state'] = $params['state'];
|
||||||
$m365LoginUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?" . http_build_query($params);
|
|
||||||
}
|
$m365LoginUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?" . http_build_query($params);
|
||||||
} catch (Exception $e) {
|
}
|
||||||
die('<h1>Datenbankfehler</h1><p>' . $e->getMessage() . '</p>');
|
} catch (Exception $e) {
|
||||||
}
|
die('<h1>Datenbankfehler</h1><p>' . $e->getMessage() . '</p>');
|
||||||
?>
|
}
|
||||||
<!DOCTYPE html>
|
?>
|
||||||
<html lang="de">
|
<!DOCTYPE html>
|
||||||
<head>
|
<html lang="de">
|
||||||
<meta charset="UTF-8">
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta charset="UTF-8">
|
||||||
<title>Login - <?= htmlspecialchars($appTitle) ?></title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<style>
|
<title>Login - <?= htmlspecialchars($appTitle) ?></title>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
<?= Ui::head($db) ?>
|
||||||
body {
|
<style>
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
/* Diagnose-Ausgabe am Seitenende */
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
.debug-info {
|
||||||
min-height: 100vh;
|
margin-top: 22px; padding: 14px;
|
||||||
display: flex;
|
background: var(--bg-subtle); border: 1px solid var(--border-color); border-radius: var(--r-md);
|
||||||
align-items: center;
|
font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); text-align: left;
|
||||||
justify-content: center;
|
}
|
||||||
padding: 20px;
|
</style>
|
||||||
}
|
</head>
|
||||||
.login-container {
|
<body class="app-body focus-page">
|
||||||
background: white;
|
<div class="focus-card card login-container">
|
||||||
border-radius: 20px;
|
<?php if ($logoUrl): ?>
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
|
||||||
max-width: 420px;
|
<?php else: ?>
|
||||||
width: 100%;
|
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
||||||
padding: 50px 40px;
|
<?php endif; ?>
|
||||||
text-align: center;
|
|
||||||
}
|
<p class="subtitle">Melden Sie sich an, um fortzufahren</p>
|
||||||
.logo {
|
|
||||||
max-width: 200px;
|
<?php if ($error): ?>
|
||||||
height: auto;
|
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
|
||||||
margin-bottom: 30px;
|
<?php endif; ?>
|
||||||
}
|
|
||||||
h1 {
|
<?php if ($success): ?>
|
||||||
color: #333;
|
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
|
||||||
font-size: 28px;
|
<?php endif; ?>
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
<form method="post" action="">
|
||||||
.subtitle {
|
<div class="form-group">
|
||||||
color: #666;
|
<label for="email">E-Mail</label>
|
||||||
font-size: 14px;
|
<input type="email" id="email" name="email" required autofocus>
|
||||||
margin-bottom: 30px;
|
</div>
|
||||||
}
|
|
||||||
.form-group {
|
<div class="form-group">
|
||||||
margin-bottom: 20px;
|
<label for="password">Passwort</label>
|
||||||
text-align: left;
|
<input type="password" id="password" name="password" required>
|
||||||
}
|
</div>
|
||||||
label {
|
|
||||||
display: block;
|
<button type="submit" class="btn btn-primary btn-lg btn-block">Anmelden</button>
|
||||||
margin-bottom: 8px;
|
</form>
|
||||||
color: #555;
|
|
||||||
font-weight: 500;
|
<?php if ($m365Enabled): ?>
|
||||||
font-size: 14px;
|
<div class="divider"><span>oder</span></div>
|
||||||
}
|
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft">
|
||||||
input[type="email"],
|
<i class="fab fa-microsoft" aria-hidden="true"></i> Mit Microsoft 365 anmelden
|
||||||
input[type="password"] {
|
</a>
|
||||||
width: 100%;
|
<?php endif; ?>
|
||||||
padding: 14px;
|
|
||||||
border: 2px solid #e0e0e0;
|
<?php if ($publicAccess): ?>
|
||||||
border-radius: 10px;
|
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> Zurück zur Code-Erstellung</a></div>
|
||||||
font-size: 15px;
|
<?php endif; ?>
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
<!-- Debug Info (kann nach erfolgreicher Einrichtung entfernt werden) -->
|
||||||
input:focus {
|
<div class="debug-info">
|
||||||
outline: none;
|
<strong>System-Status:</strong><br>
|
||||||
border-color: #667eea;
|
PHP Version: <?= phpversion() ?><br>
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
Session Status: <?= session_status() === PHP_SESSION_ACTIVE ? 'Aktiv' : 'Inaktiv' ?><br>
|
||||||
}
|
Eingeloggt: <?= $auth->isLoggedIn() ? 'Ja' : 'Nein' ?>
|
||||||
.btn {
|
</div>
|
||||||
width: 100%;
|
</div>
|
||||||
padding: 14px;
|
</body>
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.btn:hover {
|
|
||||||
background: #5568d3;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
.btn-microsoft {
|
|
||||||
background: white;
|
|
||||||
color: #333;
|
|
||||||
border: 2px solid #e0e0e0;
|
|
||||||
margin-top: 15px;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.btn-microsoft:hover {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-color: #667eea;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.divider {
|
|
||||||
margin: 25px 0;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.divider::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 1px;
|
|
||||||
background: #e0e0e0;
|
|
||||||
}
|
|
||||||
.divider span {
|
|
||||||
background: white;
|
|
||||||
padding: 0 15px;
|
|
||||||
color: #999;
|
|
||||||
font-size: 13px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.alert {
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.alert-error {
|
|
||||||
background: #fee;
|
|
||||||
border: 1px solid #fcc;
|
|
||||||
color: #c33;
|
|
||||||
}
|
|
||||||
.alert-success {
|
|
||||||
background: #efe;
|
|
||||||
border: 1px solid #cfc;
|
|
||||||
color: #3c3;
|
|
||||||
}
|
|
||||||
.back-link {
|
|
||||||
display: block;
|
|
||||||
margin-top: 20px;
|
|
||||||
color: #667eea;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.back-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.debug-info {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
padding: 15px;
|
|
||||||
margin-top: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="login-container">
|
|
||||||
<?php if ($logoUrl): ?>
|
|
||||||
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
|
|
||||||
<?php else: ?>
|
|
||||||
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<p class="subtitle">Melden Sie sich an, um fortzufahren</p>
|
|
||||||
|
|
||||||
<?php if ($error): ?>
|
|
||||||
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($success): ?>
|
|
||||||
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<form method="post" action="">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="email">E-Mail</label>
|
|
||||||
<input type="email" id="email" name="email" required autofocus>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Passwort</label>
|
|
||||||
<input type="password" id="password" name="password" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn">Anmelden</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php if ($m365Enabled): ?>
|
|
||||||
<div class="divider"><span>oder</span></div>
|
|
||||||
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft">
|
|
||||||
🔷 Mit Microsoft 365 anmelden
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($publicAccess): ?>
|
|
||||||
<a href="index.php" class="back-link">← Zurück zur Code-Erstellung</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Debug Info (kann nach erfolgreicher Einrichtung entfernt werden) -->
|
|
||||||
<div class="debug-info">
|
|
||||||
<strong>System-Status:</strong><br>
|
|
||||||
PHP Version: <?= phpversion() ?><br>
|
|
||||||
Session Status: <?= session_status() === PHP_SESSION_ACTIVE ? 'Aktiv' : 'Inaktiv' ?><br>
|
|
||||||
Eingeloggt: <?= $auth->isLoggedIn() ? 'Ja' : 'Nein' ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||