Compare commits
4 commits
main
...
claude/too
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5496f49fd5 | ||
|
|
1a2f86ed3d | ||
|
|
968afbb212 | ||
|
|
6e19958a37 |
15
.gitattributes
vendored
|
|
@ -1,15 +0,0 @@
|
||||||
# Zeilenenden nicht anfassen – einige Dateien liegen bewusst mit CRLF vor.
|
|
||||||
* -text
|
|
||||||
|
|
||||||
# Dateien, die nicht ins Release-ZIP gehoeren.
|
|
||||||
# `git archive` (siehe .github/workflows/release.yml) wertet export-ignore aus.
|
|
||||||
/.gitattributes export-ignore
|
|
||||||
/.gitignore export-ignore
|
|
||||||
/.dockerignore export-ignore
|
|
||||||
/.github export-ignore
|
|
||||||
/docs export-ignore
|
|
||||||
/tests export-ignore
|
|
||||||
/tools export-ignore
|
|
||||||
/phpunit.xml.dist export-ignore
|
|
||||||
/phpstan.neon export-ignore
|
|
||||||
/composer.lock export-ignore
|
|
||||||
35
.github/workflows/ci.yml
vendored
|
|
@ -29,40 +29,9 @@ jobs:
|
||||||
php -l "$f"
|
php -l "$f"
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Validate language files
|
- name: Validate JSON language/migration assets
|
||||||
run: |
|
run: |
|
||||||
php -r '
|
php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";'
|
||||||
$de = require "lang/de.php"; $en = require "lang/en.php";
|
|
||||||
if (!is_array($de) || !is_array($en)) { fwrite(STDERR, "Bad lang file\n"); exit(1); }
|
|
||||||
$missingEn = array_diff(array_keys($de), array_keys($en));
|
|
||||||
$missingDe = array_diff(array_keys($en), array_keys($de));
|
|
||||||
if ($missingEn || $missingDe) {
|
|
||||||
fwrite(STDERR, "Fehlend in en: " . implode(", ", $missingEn) . "\n");
|
|
||||||
fwrite(STDERR, "Fehlend in de: " . implode(", ", $missingDe) . "\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
echo "lang OK (" . count($de) . " Schluessel)\n";'
|
|
||||||
|
|
||||||
- name: Check that every used translation key exists
|
|
||||||
run: |
|
|
||||||
php -r '
|
|
||||||
$de = require "lang/de.php";
|
|
||||||
$missing = [];
|
|
||||||
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(".", FilesystemIterator::SKIP_DOTS));
|
|
||||||
foreach ($it as $file) {
|
|
||||||
$path = $file->getPathname();
|
|
||||||
if (substr($path, -4) !== ".php") continue;
|
|
||||||
if (strpos($path, "/vendor/") !== false || strpos($path, "/tools/") !== false) continue;
|
|
||||||
preg_match_all("/__\(\s*\x27([a-z0-9_]+)\x27/", file_get_contents($path), $m);
|
|
||||||
foreach ($m[1] as $key) {
|
|
||||||
if (!isset($de[$key]) && substr($key, -1) !== "_") { $missing[$key] = $path; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($missing) {
|
|
||||||
foreach ($missing as $key => $path) { fwrite(STDERR, "Unbekannter Schluessel $key in $path\n"); }
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
echo "Alle verwendeten Schluessel vorhanden\n";'
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Unit Tests & Static Analysis
|
name: Unit Tests & Static Analysis
|
||||||
|
|
|
||||||
30
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
php-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.2'
|
||||||
|
- name: PHP Syntax-Check (alle Dateien)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
fail=0
|
||||||
|
while IFS= read -r f; do
|
||||||
|
php -l "$f" > /dev/null || fail=1
|
||||||
|
done < <(git ls-files '*.php')
|
||||||
|
exit $fail
|
||||||
|
- name: Sprachdateien-Paritaet (de/en)
|
||||||
|
run: |
|
||||||
|
php -r '
|
||||||
|
$de = require "lang/de.php"; $en = require "lang/en.php";
|
||||||
|
$missing = array_merge(array_diff(array_keys($de), array_keys($en)), array_diff(array_keys($en), array_keys($de)));
|
||||||
|
if ($missing) { fwrite(STDERR, "Fehlende Keys: " . implode(", ", $missing) . "\n"); exit(1); }
|
||||||
|
echo "OK: " . count($de) . " Keys synchron\n";
|
||||||
|
'
|
||||||
152
.github/workflows/release.yml
vendored
|
|
@ -1,152 +0,0 @@
|
||||||
name: Release-Paket
|
|
||||||
|
|
||||||
# Bei jedem Merge nach main entsteht ein installierbares ZIP und landet als
|
|
||||||
# Vorab-Release "latest-main" im Repository. Wird ein Tag v* gepusht, wird
|
|
||||||
# daraus ein regulaeres Release mit derselben Mechanik.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
tags: [ 'v*' ]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
package:
|
|
||||||
name: ZIP bauen und veröffentlichen
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Version und Dateinamen bestimmen
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
VERSION="$(tr -d ' \r\n' < VERSION)"
|
|
||||||
SHORT_SHA="$(git rev-parse --short HEAD)"
|
|
||||||
BUILD_DATE="$(date -u +%Y-%m-%d)"
|
|
||||||
|
|
||||||
if [ "${GITHUB_REF_TYPE:-branch}" = "tag" ]; then
|
|
||||||
TAG="${GITHUB_REF_NAME}"
|
|
||||||
NAME="unifi-voucher-tool-${TAG}"
|
|
||||||
TITLE="Version ${TAG}"
|
|
||||||
PRERELEASE="false"
|
|
||||||
else
|
|
||||||
TAG="latest-main"
|
|
||||||
NAME="unifi-voucher-tool-${VERSION}+${BUILD_DATE}.${SHORT_SHA}"
|
|
||||||
TITLE="Aktueller Stand von main – ${VERSION} (${SHORT_SHA})"
|
|
||||||
PRERELEASE="true"
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "version=${VERSION}"
|
|
||||||
echo "short_sha=${SHORT_SHA}"
|
|
||||||
echo "build_date=${BUILD_DATE}"
|
|
||||||
echo "tag=${TAG}"
|
|
||||||
echo "name=${NAME}"
|
|
||||||
echo "title=${TITLE}"
|
|
||||||
echo "prerelease=${PRERELEASE}"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: ZIP erzeugen
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
mkdir -p dist
|
|
||||||
# git archive wertet die export-ignore-Regeln aus .gitattributes aus,
|
|
||||||
# docs/, tests/, tools/ und CI-Dateien bleiben also draußen.
|
|
||||||
git archive --format=zip -9 \
|
|
||||||
--prefix="unifi-voucher-tool/" \
|
|
||||||
-o "dist/${{ steps.meta.outputs.name }}.zip" HEAD
|
|
||||||
cd dist
|
|
||||||
sha256sum "${{ steps.meta.outputs.name }}.zip" > "${{ steps.meta.outputs.name }}.zip.sha256"
|
|
||||||
ls -lh
|
|
||||||
|
|
||||||
- name: Inhalt kurz prüfen
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# Ein paar Dateien muessen enthalten sein, sonst ist das Paket kaputt.
|
|
||||||
for required in \
|
|
||||||
unifi-voucher-tool/index.php \
|
|
||||||
unifi-voucher-tool/install.php \
|
|
||||||
unifi-voucher-tool/database.sql \
|
|
||||||
unifi-voucher-tool/assets/global.css \
|
|
||||||
unifi-voucher-tool/assets/vendor/inter/inter.css \
|
|
||||||
unifi-voucher-tool/includes/Ui.php
|
|
||||||
do
|
|
||||||
if ! unzip -l "dist/${{ steps.meta.outputs.name }}.zip" | grep -q "$required"; then
|
|
||||||
echo "Fehlt im Paket: $required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "Paket vollständig."
|
|
||||||
|
|
||||||
- name: Als Build-Artefakt sichern
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
continue-on-error: true # Artefakt-Speicher ist optional
|
|
||||||
with:
|
|
||||||
name: ${{ steps.meta.outputs.name }}
|
|
||||||
path: dist/*
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Release anlegen bzw. auffrischen
|
|
||||||
env:
|
|
||||||
# Forgejo stellt den Token automatisch bereit; FORGEJO_TOKEN
|
|
||||||
# (persönlicher Token) dient als Ausweichweg.
|
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.FORGEJO_TOKEN }}
|
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
TAG: ${{ steps.meta.outputs.tag }}
|
|
||||||
NAME: ${{ steps.meta.outputs.name }}
|
|
||||||
TITLE: ${{ steps.meta.outputs.title }}
|
|
||||||
PRERELEASE: ${{ steps.meta.outputs.prerelease }}
|
|
||||||
VERSION: ${{ steps.meta.outputs.version }}
|
|
||||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
|
||||||
BUILD_DATE: ${{ steps.meta.outputs.build_date }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
if [ -z "${TOKEN:-}" ]; then
|
|
||||||
echo "Kein Token vorhanden – Release wird übersprungen." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
AUTH="Authorization: token ${TOKEN}"
|
|
||||||
|
|
||||||
# Rollendes Vorab-Release durch ein frisches ersetzen, damit der
|
|
||||||
# Download-Link stabil bleibt und auf den aktuellen Stand zeigt.
|
|
||||||
if [ "$TAG" = "latest-main" ]; then
|
|
||||||
OLD_ID="$(curl -sf -H "$AUTH" "${API}/releases/tags/${TAG}" \
|
|
||||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)"
|
|
||||||
if [ -n "${OLD_ID:-}" ]; then
|
|
||||||
curl -sf -X DELETE -H "$AUTH" "${API}/releases/${OLD_ID}" || true
|
|
||||||
curl -sf -X DELETE -H "$AUTH" "${API}/tags/${TAG}" || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Release-Text bewusst ohne Anfuehrungszeichen und Backslashes,
|
|
||||||
# damit er ohne jq direkt in den JSON-Body passt (\n bleibt literal).
|
|
||||||
BODY="Automatisch gebaut aus Commit ${SHORT_SHA}.\n\n"
|
|
||||||
BODY="${BODY}| | |\n|---|---|\n"
|
|
||||||
BODY="${BODY}| Version | ${VERSION} |\n"
|
|
||||||
BODY="${BODY}| Commit | ${SHORT_SHA} |\n"
|
|
||||||
BODY="${BODY}| Gebaut am | ${BUILD_DATE} |\n\n"
|
|
||||||
BODY="${BODY}**Neuinstallation:** ZIP entpacken, Dateien auf den Webserver legen, install.php aufrufen.\n\n"
|
|
||||||
BODY="${BODY}**Update einer bestehenden Installation:** config.php, uploads/ und updater/storage/ nicht ueberschreiben "
|
|
||||||
BODY="${BODY}- oder gleich den eingebauten Updater unter Administration, System-Update verwenden.\n\n"
|
|
||||||
BODY="${BODY}Pruefsumme: siehe beigelegte .sha256-Datei."
|
|
||||||
|
|
||||||
RELEASE_ID="$(curl -sf -X POST -H "$AUTH" -H 'Content-Type: application/json' \
|
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"${TITLE}\",\"body\":\"${BODY}\",\"draft\":false,\"prerelease\":${PRERELEASE}}" \
|
|
||||||
"${API}/releases" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)"
|
|
||||||
|
|
||||||
if [ -z "${RELEASE_ID:-}" ]; then
|
|
||||||
echo "Release konnte nicht angelegt werden." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for file in "dist/${NAME}.zip" "dist/${NAME}.zip.sha256"; do
|
|
||||||
curl -sf -X POST -H "$AUTH" \
|
|
||||||
-F "attachment=@${file}" \
|
|
||||||
"${API}/releases/${RELEASE_ID}/assets?name=$(basename "$file")" > /dev/null
|
|
||||||
echo "Angehängt: $(basename "$file")"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Release ${TITLE} steht bereit: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
|
||||||
26
.htaccess
|
|
@ -1,26 +0,0 @@
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sicherheits-Header und Zugriffsschutz (Apache)
|
|
||||||
# Nginx-Entsprechung siehe Readme.md, Abschnitt "Sicherheit".
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
<IfModule mod_headers.c>
|
|
||||||
Header always set X-Content-Type-Options "nosniff"
|
|
||||||
Header always set X-Frame-Options "SAMEORIGIN"
|
|
||||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
|
||||||
|
|
||||||
# Alle Frontend-Assets liegen lokal; externe Quellen nur fuer hCaptcha,
|
|
||||||
# falls es in den Einstellungen aktiviert wurde.
|
|
||||||
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline' https://*.hcaptcha.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.hcaptcha.com; frame-src https://*.hcaptcha.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
# Kein Verzeichnislisting
|
|
||||||
Options -Indexes
|
|
||||||
|
|
||||||
# Dateien, die nie direkt ausgeliefert werden sollen
|
|
||||||
<FilesMatch "^(config\.php|composer\.(json|lock)|phpunit\.xml\.dist|phpstan\.neon|database\.sql)$">
|
|
||||||
Require all denied
|
|
||||||
</FilesMatch>
|
|
||||||
|
|
||||||
# Interne Ordner schuetzen sich ueber eigene .htaccess-Dateien
|
|
||||||
# (funktioniert auch bei Installation in einem Unterverzeichnis).
|
|
||||||
|
|
@ -16,14 +16,11 @@ RUN { \
|
||||||
echo 'post_max_size=8M'; \
|
echo 'post_max_size=8M'; \
|
||||||
} > /usr/local/etc/php/conf.d/zz-voucher.ini
|
} > /usr/local/etc/php/conf.d/zz-voucher.ini
|
||||||
|
|
||||||
# .htaccess auswerten (Sicherheits-Header, Schutz des uploads-Ordners)
|
|
||||||
RUN sed -ri 's!<Directory /var/www/>!<Directory /var/www/>\n\tAllowOverride All!g' /etc/apache2/apache2.conf
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
COPY . /var/www/html
|
COPY . /var/www/html
|
||||||
|
|
||||||
# Laufzeit-Verzeichnisse beschreibbar machen
|
# Laufzeit-Verzeichnis des Updaters beschreibbar machen
|
||||||
RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \
|
RUN mkdir -p /var/www/html/updater/storage \
|
||||||
&& chown -R www-data:www-data /var/www/html
|
&& chown -R www-data:www-data /var/www/html
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
|
||||||
344
Readme.md
|
|
@ -4,15 +4,12 @@
|
||||||
|
|
||||||
**Webbasiertes WLAN-Voucher-Management für UniFi OS** – mit Multi-Site-Support, Benutzerverwaltung, Microsoft-365-Login und integriertem Auto-Updater.
|
**Webbasiertes WLAN-Voucher-Management für UniFi OS** – mit Multi-Site-Support, Benutzerverwaltung, Microsoft-365-Login und integriertem Auto-Updater.
|
||||||
|
|
||||||
Entwickelt von **[Loheide.eu](https://loheide.eu)**
|
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
[](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -28,8 +25,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand
|
- 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand
|
||||||
- 🖥️ **Display-Seiten (Kiosk)** – öffentliche Seite je Site, an der Gäste sich mit einem Klick selbst einen Zugang holen
|
|
||||||
- 🎨 **Jede Display-Seite eigenständig gestaltbar** – Logo, Hintergrundbild, Akzentfarbe, helle oder dunkle Karte
|
|
||||||
- 📦 **Bulk-Erstellung** – bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout
|
- 📦 **Bulk-Erstellung** – bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout
|
||||||
- 🧩 **Voucher-Profile/Templates** – vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl
|
- 🧩 **Voucher-Profile/Templates** – vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl
|
||||||
- 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten
|
- 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten
|
||||||
|
|
@ -50,13 +45,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
|
||||||
- 💾 **Config-Backup & -Restore** (JSON Export/Import)
|
- 💾 **Config-Backup & -Restore** (JSON Export/Import)
|
||||||
- 🐳 **Docker** – Dockerfile + docker-compose (MariaDB)
|
- 🐳 **Docker** – Dockerfile + docker-compose (MariaDB)
|
||||||
- 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
|
- 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
|
||||||
- 🎨 **Einheitliches Design-System** – ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark)
|
|
||||||
- 🏷️ **Login-Seite individualisierbar** – Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf
|
|
||||||
- 🖌️ **Eigene Markenfarben** – Akzentfarbe, Verlauf und Eckenradius wirken auf die gesamte Oberfläche
|
|
||||||
- ⬆️ **Bild-Upload** für Logo, Favicon und Login-Hintergrund (kein externes Hosting nötig)
|
|
||||||
- 🔒 **Keine externen CDNs** – Schrift, Icons, Diagramme und Editor werden lokal ausgeliefert (DSGVO, Offline-Netze)
|
|
||||||
- ♿ **Barrierearm** – Kontraste nach WCAG AA, Sprungmarke, aria-Beschriftungen, `prefers-reduced-motion`
|
|
||||||
- 📱 **Mobil nutzbar** – Tabellen werden auf schmalen Geräten zu Karten
|
|
||||||
- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert
|
- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert
|
||||||
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
|
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
|
||||||
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
|
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
|
||||||
|
|
@ -71,52 +59,31 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
|
||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
> Alle Screenshots stammen aus der Oberfläche in Version 2.5.0 (neues Design-System).
|
|
||||||
|
|
||||||
### Anmeldung & Voucher-Erstellung
|
### Anmeldung & Voucher-Erstellung
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/login.png" alt="Anmeldung" width="48%">
|
<img src="docs/screenshots/login.png" alt="Login mit Microsoft 365" width="32%">
|
||||||
<img src="docs/screenshots/login-branding.png" alt="Anmeldung mit eigenem Branding" width="48%">
|
<img src="docs/screenshots/voucher-form.png" alt="Voucher erstellen" width="32%">
|
||||||
|
<img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis" width="32%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
### Bulk-Erstellung & Dark Mode
|
||||||
<img src="docs/screenshots/voucher-form.png" alt="Voucher erstellen" width="48%">
|
|
||||||
<img src="docs/screenshots/settings-login.png" alt="Einstellungen der Login-Seite" width="48%">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Display-Seite für Gäste
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/kiosk-display.png" alt="Display-Seite im Ruhezustand" width="48%">
|
|
||||||
<img src="docs/screenshots/kiosk-branded.png" alt="Display-Seite mit eigenem Bild und Farben" width="48%">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="docs/screenshots/kiosk-code.png" alt="Ausgegebener Zugangscode auf dem Display" width="48%">
|
|
||||||
<img src="docs/screenshots/kiosks-form.png" alt="Display-Seite einrichten" width="48%">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis mit QR-Code" width="48%">
|
|
||||||
<img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%">
|
<img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%">
|
||||||
</div>
|
|
||||||
|
|
||||||
### Administration
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="docs/screenshots/admin-dashboard.png" alt="Dashboard" width="48%">
|
|
||||||
<img src="docs/screenshots/admin-dashboard-dark.png" alt="Dashboard im Dark Mode" width="48%">
|
<img src="docs/screenshots/admin-dashboard-dark.png" alt="Dashboard im Dark Mode" width="48%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
### Administration & Updater
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/vouchers.png" alt="Live-Voucher-Verwaltung" width="48%">
|
<img src="docs/screenshots/admin-dashboard.png" alt="Dashboard" width="48%">
|
||||||
<img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%">
|
<img src="docs/screenshots/updater-available.png" alt="Auto-Updater" width="48%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/settings-branding.png" alt="Markenfarben einstellen" width="48%">
|
<img src="docs/screenshots/updater.png" alt="Updater – Ausgangszustand" width="48%">
|
||||||
<img src="docs/screenshots/mobile-vouchers.png" alt="Ansicht auf dem Smartphone" width="22%">
|
<img src="docs/screenshots/maintenance.png" alt="Wartungsmodus" width="48%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### REST-API, 2FA & Integrationen
|
### REST-API, 2FA & Integrationen
|
||||||
|
|
@ -130,17 +97,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
|
||||||
<img src="docs/screenshots/two-factor.png" alt="Zwei-Faktor-Authentifizierung" width="60%">
|
<img src="docs/screenshots/two-factor.png" alt="Zwei-Faktor-Authentifizierung" width="60%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Updater & Wartungsmodus
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="docs/screenshots/updater.png" alt="Updater – Ausgangszustand" width="48%">
|
|
||||||
<img src="docs/screenshots/updater-available.png" alt="Auto-Updater mit verfügbarem Update" width="48%">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="docs/screenshots/maintenance.png" alt="Wartungsmodus" width="60%">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Anforderungen
|
## 📋 Anforderungen
|
||||||
|
|
@ -157,8 +113,8 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
|
||||||
## 🚀 Installation
|
## 🚀 Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git
|
git clone https://github.com/friloo/unifi-voucher-tool.git
|
||||||
cd Unifi-Voucher-Tool
|
cd unifi-voucher-tool
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Dateien auf den Webserver hochladen
|
1. Dateien auf den Webserver hochladen
|
||||||
|
|
@ -228,64 +184,6 @@ Während eines Updates wird die Anwendung kurz in den **Wartungsmodus** versetzt
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🖥️ Display-Seiten für Gäste
|
|
||||||
|
|
||||||
Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine **öffentliche Seite**
|
|
||||||
anlegen, die auf einem Bildschirm oder Tablet läuft. Gäste tippen auf einen
|
|
||||||
Knopf und bekommen sofort einen eigenen Zugangscode – ohne Anmeldung, ohne
|
|
||||||
Personal am Tresen.
|
|
||||||
|
|
||||||
**Anlegen:** Administration → **Display-Seiten** → *Display-Seite anlegen*
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="docs/screenshots/kiosks-admin.png" alt="Verwaltung der Display-Seiten" width="80%">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
| Einstellung | Wirkung |
|
|
||||||
|---|---|
|
|
||||||
| Site | für welchen Standort die Codes erzeugt werden |
|
|
||||||
| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) |
|
|
||||||
| Überschrift / Text | was auf dem Bildschirm steht |
|
|
||||||
| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) |
|
|
||||||
| Wartezeit | Abstand zwischen zwei Codes an diesem Display |
|
|
||||||
| Anzeigedauer | danach springt der Bildschirm automatisch zurück |
|
|
||||||
|
|
||||||
Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich
|
|
||||||
kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und
|
|
||||||
jederzeit erneuern – der alte Link ist dann sofort ungültig. Den Link nicht
|
|
||||||
öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen.
|
|
||||||
|
|
||||||
Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite
|
|
||||||
zeigt. Gäste können sie damit **am eigenen Handy** öffnen – praktisch bei
|
|
||||||
Bildschirmen ohne Touch.
|
|
||||||
|
|
||||||
### Jede Seite eigenständig gestalten
|
|
||||||
|
|
||||||
Jede Display-Seite bringt ihr eigenes Erscheinungsbild mit – das Hotel am
|
|
||||||
Empfang sieht anders aus als der Tagungsraum nebenan:
|
|
||||||
|
|
||||||
| Einstellung | Wirkung |
|
|
||||||
|---|---|
|
|
||||||
| Logo | eigenes Logo auf der Karte (leer = Logo aus den Einstellungen) |
|
|
||||||
| Hintergrundbild | formatfüllend hinter der Karte, z. B. ein Foto des Hauses |
|
|
||||||
| Abdunklung | 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`
|
||||||
|
|
@ -322,80 +220,6 @@ Den Token finden Sie unter **Administration → Einstellungen → Cron**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎨 Design-System
|
|
||||||
|
|
||||||
Frontend, Login-Seiten, Installer, Updater und der gesamte Admin-Bereich nutzen ein
|
|
||||||
gemeinsames Stylesheet: **`assets/global.css`**.
|
|
||||||
|
|
||||||
- **Design-Tokens** (`:root` bzw. `[data-theme="dark"]`) für Flächen, Text, Linien,
|
|
||||||
Markenfarbe, Statusfarben, Radien, Schatten und Layout-Maße
|
|
||||||
- **Komponenten** darauf aufgebaut: Buttons, Formularfelder, Cards, Tabellen, Badges,
|
|
||||||
Alerts, Tabs, Pagination, Modals, Toasts, Statistik-Kacheln, Sidebar/Topbar
|
|
||||||
- **Dark Mode** ausschließlich über Tokens – keine `!important`-Overrides mehr
|
|
||||||
- **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback
|
|
||||||
|
|
||||||
### Markenfarben ohne Code
|
|
||||||
|
|
||||||
Unter **Administration → Einstellungen → Design** lassen sich Akzentfarbe
|
|
||||||
(hell und dunkel), Markenverlauf und Eckenradius setzen. Abgeleitete Töne –
|
|
||||||
Hover, weiche Flächen, Rahmen, Fokusring – berechnet das System per `color-mix`
|
|
||||||
aus der Grundfarbe; eine Farbe genügt also. Eine Live-Vorschau zeigt Button,
|
|
||||||
Badge, Chip und Logo-Kachel sofort im neuen Ton.
|
|
||||||
|
|
||||||
Die Werte landen als schlanker `:root`-Override im Seitenkopf und gelten überall,
|
|
||||||
auch auf Login-Seite, Installer und Updater. Wer lieber in CSS arbeitet, kann
|
|
||||||
dieselben Variablen weiterhin in `assets/global.css` überschreiben:
|
|
||||||
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--accent: #0f766e; /* Primärfarbe (Buttons, aktive Navigation) */
|
|
||||||
--brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%);
|
|
||||||
--r-lg: 14px; /* Eckenradius für Cards */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bilder hochladen
|
|
||||||
|
|
||||||
Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich direkt hochladen –
|
|
||||||
alternativ bleibt das URL-Feld bestehen. Die Dateien landen unter `uploads/`
|
|
||||||
(Docker: eigenes Volume, siehe unten). Erlaubt sind PNG, JPG, WEBP, GIF und SVG
|
|
||||||
bis 3 MB; SVGs werden vor dem Speichern von Skripten und externen Verweisen
|
|
||||||
befreit, und im Upload-Ordner sperrt eine `.htaccess` die PHP-Ausführung.
|
|
||||||
|
|
||||||
### Assets ohne Drittanbieter
|
|
||||||
|
|
||||||
Schrift (Inter), Icons (Font Awesome), Diagramme (Chart.js), QR-Codes und der
|
|
||||||
WYSIWYG-Editor (TinyMCE) liegen unter `assets/vendor/` und kommen vom eigenen
|
|
||||||
Server. Das hält Besucher-IPs bei Ihnen – und die Oberfläche funktioniert auch
|
|
||||||
dort, wo das Netz keinen Weg nach außen hat. Details und Aktualisierungs-Hinweise:
|
|
||||||
[`assets/vendor/README.md`](assets/vendor/README.md).
|
|
||||||
|
|
||||||
Alle Asset-URLs tragen einen Versionsstempel (`?v=…`), damit Browser nach einem
|
|
||||||
Update nicht die alten Dateien aus dem Cache verwenden.
|
|
||||||
|
|
||||||
### Login-Seite individualisieren
|
|
||||||
|
|
||||||
Unter **Administration → Einstellungen → Login-Seite** lässt sich die Anmeldeseite
|
|
||||||
ohne Code-Änderung an das eigene Haus anpassen. Leere Felder verwenden jeweils den
|
|
||||||
Standardwert – eine frische Installation sieht also unverändert aus.
|
|
||||||
|
|
||||||
| Einstellung | Wirkung |
|
|
||||||
|---|---|
|
|
||||||
| Linke Bildspalte anzeigen | Split-Screen an/aus. Aus = zentrierte Anmeldekarte |
|
|
||||||
| Firmenname | Name neben dem Logo bzw. in der Fußzeile (leer = Anwendungstitel) |
|
|
||||||
| Logo (URL) | Eigenes Logo in der Bildspalte (leer = allgemeines Logo) |
|
|
||||||
| Überschrift / Beschreibungstext | Claim in der Bildspalte |
|
|
||||||
| Stichpunkte | Liste mit Haken – ein Stichpunkt pro Zeile, leer = keine Liste |
|
|
||||||
| Fußzeile | z. B. `© 2026 Muster GmbH · Datenschutz · Impressum` |
|
|
||||||
| Hintergrundbild (URL) | Formatfüllendes Bild der Bildspalte |
|
|
||||||
| Verlauf Start-/Endfarbe | Farbverlauf, wenn kein Bild gesetzt ist |
|
|
||||||
| Abdunklung (%) | Dunkle Ebene über dem Bild, damit der Text lesbar bleibt |
|
|
||||||
|
|
||||||
Über **„Vorschau öffnen"** lässt sich die Login-Seite als angemeldeter Administrator
|
|
||||||
ansehen (`login.php?preview=1`), ohne sich abzumelden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ Sicherheit
|
## 🛡️ Sicherheit
|
||||||
|
|
||||||
Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
|
Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
|
||||||
|
|
@ -411,30 +235,13 @@ Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
|
||||||
| **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout |
|
| **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout |
|
||||||
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
|
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
|
||||||
|
|
||||||
Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern
|
Empfohlene zusätzliche Härtung am Server:
|
||||||
(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
|
|
||||||
`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal
|
|
||||||
liegen, erlaubt die CSP nur noch die eigene Herkunft – externe Verbindungen
|
|
||||||
bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie
|
|
||||||
`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene
|
|
||||||
`.htaccess`-Dateien.
|
|
||||||
|
|
||||||
> **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst
|
```apache
|
||||||
> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image
|
# .htaccess – sensible Dateien sperren (wird vom Installer erzeugt)
|
||||||
> erledigt das bereits.
|
<FilesMatch "^(config\.php|database\.sql|install\.php|test\.php|m365_debug\.php|.*\.md)$">
|
||||||
|
Require all denied
|
||||||
Für **Nginx** entspricht das:
|
</FilesMatch>
|
||||||
|
|
||||||
```nginx
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always;
|
|
||||||
|
|
||||||
location ~ ^/(includes|tools|tests)/ { deny all; }
|
|
||||||
location ~ ^/updater/(storage|migrations)/ { deny all; }
|
|
||||||
location ~ ^/(config\.php|database\.sql)$ { deny all; }
|
|
||||||
location ^~ /uploads/ { location ~ \.php$ { deny all; } }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
|
@ -557,108 +364,6 @@ Das Schema wird beim ersten Start automatisch in MariaDB geladen; danach den
|
||||||
Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV
|
Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV
|
||||||
setzen (`DB_*`, `APP_KEY`).
|
setzen (`DB_*`, `APP_KEY`).
|
||||||
|
|
||||||
Hochgeladene Logos und Hintergründe liegen im Volume `uploads` und überstehen
|
|
||||||
damit ein Image-Update. Bei eigener Apache-/Nginx-Installation muss `uploads/`
|
|
||||||
für den Webserver beschreibbar sein:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chown -R www-data:www-data uploads updater/storage
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Entwicklung
|
|
||||||
|
|
||||||
Für Screenshots und einen schnellen Durchlauf aller Seiten gibt es eine
|
|
||||||
Demo-Instanz **ohne Datenbank** – `Database` und `Auth` werden durch Stubs mit
|
|
||||||
festen Beispieldaten ersetzt:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/demo/build.py /tmp/uvt-demo
|
|
||||||
php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
|
|
||||||
|
|
||||||
# Bilder in docs/screenshots neu erzeugen (benötigt headless Chromium)
|
|
||||||
CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Details und die verfügbaren Demo-Zustände: [`tools/README.md`](tools/README.md).
|
|
||||||
|
|
||||||
Tests und statische Analyse:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
composer install
|
|
||||||
vendor/bin/phpunit # 29 Tests (Crypto, TOTP, API-Keys, Upload, Ui)
|
|
||||||
vendor/bin/phpstan analyse # Level 5
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Versionsnummer steht in der Datei **`VERSION`** im Projektstamm. Sie wird
|
|
||||||
im Admin-Bereich unten in der Seitenleiste angezeigt und benennt das
|
|
||||||
Release-Paket – für eine neue Version also dort (und im Badge oben) anheben.
|
|
||||||
|
|
||||||
Die Pipeline (`.github/workflows/ci.yml`) führt zusätzlich einen
|
|
||||||
Syntax-Check über alle PHP-Dateien aus und prüft, ob `lang/de.php` und
|
|
||||||
`lang/en.php` dieselben Schlüssel enthalten und jeder im Code verwendete
|
|
||||||
Schlüssel existiert. Dieselben Schritte lassen sich lokal ausführen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Repository & Mitwirken
|
|
||||||
|
|
||||||
Der Quellcode liegt auf der eigenen Forgejo-Instanz – **nicht** auf GitHub:
|
|
||||||
|
|
||||||
**<https://git.loheide.cloud/friloo/Unifi-Voucher-Tool>**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# HTTPS
|
|
||||||
git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git
|
|
||||||
|
|
||||||
# SSH (Port 2222)
|
|
||||||
git clone ssh://git@git.loheide.cloud:2222/friloo/Unifi-Voucher-Tool.git
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fertige Pakete
|
|
||||||
|
|
||||||
Jeder Merge nach `main` erzeugt automatisch ein installierbares ZIP
|
|
||||||
(`.github/workflows/release.yml`) und hängt es an das rollende Vorab-Release
|
|
||||||
**`latest-main`**:
|
|
||||||
|
|
||||||
**<https://git.loheide.cloud/friloo/Unifi-Voucher-Tool/releases>**
|
|
||||||
|
|
||||||
Das Paket enthält nur die Laufzeit-Dateien – `docs/`, `tests/`, `tools/` und die
|
|
||||||
CI-Konfiguration bleiben draußen (rund 1,4 MB). Wird ein Tag `v*` gepusht,
|
|
||||||
entsteht daraus ein reguläres Release mit derselben Mechanik.
|
|
||||||
|
|
||||||
> Beim **Update einer bestehenden Installation** `config.php`, `uploads/` und
|
|
||||||
> `updater/storage/` nicht überschreiben – oder gleich den eingebauten Updater
|
|
||||||
> verwenden, der genau diese Pfade schützt.
|
|
||||||
|
|
||||||
Voraussetzung ist ein registrierter **Forgejo-Actions-Runner**; ohne Runner
|
|
||||||
bleiben die Workflows in der Warteschlange stehen. Compose-Datei und Anleitung
|
|
||||||
dafür liegen in [`tools/runner/`](tools/runner/README.md).
|
|
||||||
|
|
||||||
### Mitwirken
|
|
||||||
|
|
||||||
Fehlerberichte und Änderungsvorschläge laufen über die **Issues** und **Pull
|
|
||||||
Requests** dort. Für die Kommandozeile eignet sich [`tea`](https://gitea.com/gitea/tea),
|
|
||||||
die Gitea-/Forgejo-CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tea pr create # Pull Request öffnen
|
|
||||||
tea issues ls # offene Tickets ansehen
|
|
||||||
```
|
|
||||||
|
|
||||||
> Die Workflows unter `.github/workflows/` werden von Forgejo Actions
|
|
||||||
> mitgelesen; das Badge oben ist bewusst statisch, solange kein Runner
|
|
||||||
> registriert ist. `docker-publish.yml` veröffentlicht nach `ghcr.io` und
|
|
||||||
> stammt noch aus der GitHub-Zeit – für den Forgejo-Betrieb entweder auf die
|
|
||||||
> eigene Registry umstellen oder entfernen.
|
|
||||||
|
|
||||||
Der **Auto-Updater** ist davon unabhängig: er zieht seine Pakete über
|
|
||||||
`update.loheide.eu` (Channels `stable` und `development`) und nicht direkt aus
|
|
||||||
dem Git-Hoster.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
- [x] Voucher-Templates (vordefinierte Laufzeiten)
|
- [x] Voucher-Templates (vordefinierte Laufzeiten)
|
||||||
|
|
@ -673,18 +378,11 @@ dem Git-Hoster.
|
||||||
- [x] Docker-Container
|
- [x] Docker-Container
|
||||||
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
|
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
|
||||||
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
|
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
|
||||||
- [x] Gemeinsames Design-System für Frontend, Login und Backend
|
|
||||||
- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite)
|
|
||||||
- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
|
|
||||||
- [x] Vollständige englische Übersetzung des Admin-Bereichs
|
|
||||||
- [x] Display-Seiten: Selbstbedienung für Gäste am Bildschirm
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**Version 2.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
**Version 2.4.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
||||||
|
|
||||||
Entwickelt von **[Loheide.eu](https://loheide.eu)**
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
1
VERSION
|
|
@ -1 +0,0 @@
|
||||||
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_created_once');
|
$success = 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -46,14 +46,14 @@ if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken(
|
||||||
$row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]);
|
$row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]);
|
||||||
if ($row) {
|
if ($row) {
|
||||||
$db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]);
|
$db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]);
|
||||||
$success = __('api_status_updated');
|
$success = 'Status aktualisiert.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
||||||
$db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]);
|
$db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]);
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht');
|
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht');
|
||||||
$success = __('api_deleted');
|
$success = 'API-Schlüssel gelöscht.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC");
|
$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC");
|
||||||
|
|
@ -66,80 +66,94 @@ $adminBase = '';
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('api_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title>API-Schlüssel – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<div class="page-header">
|
<style>
|
||||||
<div>
|
.card { background: var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); }
|
||||||
<h1 class="page-title"><?= __('api_title') ?></h1>
|
.card h2 { font-size:16px; margin-bottom:16px; color:var(--text-primary); }
|
||||||
<p class="page-subtitle"><?= __('api_subtitle') ?></p>
|
table { width:100%; border-collapse:collapse; }
|
||||||
</div>
|
th,td { text-align:left; padding:11px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
|
||||||
</div>
|
th { color:var(--text-muted); font-weight:600; }
|
||||||
|
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
|
||||||
|
.btn { padding:10px 16px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
||||||
|
.btn-primary { background:var(--accent,#667eea); color:#fff; }
|
||||||
|
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
|
||||||
|
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
|
||||||
|
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
||||||
|
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||||
|
.keybox { background:#0f1117; color:#7CFFB2; font-family:monospace; padding:14px; border-radius:8px; word-break:break-all; font-size:15px; margin-top:10px; }
|
||||||
|
.badge { padding:3px 9px; border-radius:6px; font-size:12px; font-weight:600; }
|
||||||
|
.b-on { background:#e3f6ea; color:#2a7; } .b-off { background:#fdeaea; color:#c33; }
|
||||||
|
.a-link { color:var(--accent,#667eea); text-decoration:none; margin-right:10px; font-size:13px; }
|
||||||
|
.muted { color:var(--text-muted); font-size:13px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔑 API-Schlüssel</h1>
|
||||||
|
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
<?php if ($newKey): ?>
|
<?php if ($newKey): ?>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('api_new_key') ?></h2>
|
<h2>Neuer Schlüssel</h2>
|
||||||
<p class="muted"><?= __('api_new_key_hint') ?></p>
|
<p class="muted">Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.</p>
|
||||||
<div class="keybox"><?= htmlspecialchars($newKey) ?></div>
|
<div class="keybox"><?= htmlspecialchars($newKey) ?></div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('api_create_title') ?></h2>
|
<h2>Neuen API-Schlüssel erstellen</h2>
|
||||||
<form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
<form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<div style="flex:2;min-width:200px;">
|
<div style="flex:2;min-width:200px;">
|
||||||
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_name') ?></label>
|
<label class="muted" style="display:block;margin-bottom:6px;">Bezeichnung</label>
|
||||||
<input class="input" type="text" name="name" placeholder="<?= __('api_name_placeholder') ?>" required>
|
<input class="input" type="text" name="name" placeholder="z.B. Buchungssystem, Terminal Foyer" required>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:130px;">
|
<div style="flex:1;min-width:130px;">
|
||||||
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_scope') ?></label>
|
<label class="muted" style="display:block;margin-bottom:6px;">Berechtigung</label>
|
||||||
<select class="input" name="scope">
|
<select class="input" name="scope">
|
||||||
<option value="write"><?= __('api_scope_write') ?></option>
|
<option value="write">Lesen + Erstellen</option>
|
||||||
<option value="read"><?= __('api_scope_read') ?></option>
|
<option value="read">Nur Lesen</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:120px;">
|
<div style="flex:1;min-width:120px;">
|
||||||
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_limit') ?></label>
|
<label class="muted" style="display:block;margin-bottom:6px;">Limit (Anfr./min)</label>
|
||||||
<input class="input" type="number" name="rate_limit" min="0" value="0" title="<?= __('api_limit_title') ?>">
|
<input class="input" type="number" name="rate_limit" min="0" value="0" title="0 = unbegrenzt">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" type="submit" name="create_key"><?= __('btn_create') ?></button>
|
<button class="btn btn-primary" type="submit" name="create_key">Erstellen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('api_existing') ?></h2>
|
<h2>Vorhandene Schlüssel</h2>
|
||||||
<?php if (empty($keys)): ?>
|
<?php if (empty($keys)): ?>
|
||||||
<p class="muted"><?= __('api_none') ?></p>
|
<p class="muted">Noch keine API-Schlüssel angelegt.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="table-container">
|
<table>
|
||||||
<table class="table-stack">
|
<tr><th>Name</th><th>Präfix</th><th>Scope</th><th>Limit</th><th>Status</th><th>Zuletzt genutzt</th><th>Erstellt von</th><th></th></tr>
|
||||||
<tr><th><?= __('label_name') ?></th><th><?= __('api_col_prefix') ?></th><th><?= __('api_col_scope') ?></th><th><?= __('api_col_limit') ?></th><th><?= __('label_status') ?></th><th><?= __('api_col_last_used') ?></th><th><?= __('api_col_created_by') ?></th><th></th></tr>
|
|
||||||
<?php foreach ($keys as $k): ?>
|
<?php foreach ($keys as $k): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="<?= __('label_name') ?>"><?= htmlspecialchars($k['name']) ?></td>
|
<td><?= htmlspecialchars($k['name']) ?></td>
|
||||||
<td data-label="<?= __('api_col_prefix') ?>"><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
|
<td><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
|
||||||
<td data-label="<?= __('api_col_scope') ?>"><?= ($k['scope'] ?? 'write') === 'read' ? __('api_scope_read_short') : __('api_scope_write_short') ?></td>
|
<td><?= ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?></td>
|
||||||
<td data-label="<?= __('api_col_limit') ?>"><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
|
<td><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
|
||||||
<td data-label="<?= __('label_status') ?>"><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? __('api_state_active') : __('api_state_blocked') ?></span></td>
|
<td><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? 'aktiv' : 'gesperrt' ?></span></td>
|
||||||
<td class="muted" data-label="<?= __('api_col_last_used') ?>"><?= $k['last_used_at'] ? date('d.m.Y H:i', strtotime($k['last_used_at'])) : '–' ?></td>
|
<td class="muted"><?= $k['last_used_at'] ? htmlspecialchars($k['last_used_at']) : '–' ?></td>
|
||||||
<td class="muted" data-label="<?= __('api_col_created_by') ?>"><?= htmlspecialchars($k['creator'] ?? '–') ?></td>
|
<td class="muted"><?= htmlspecialchars($k['creator'] ?? '–') ?></td>
|
||||||
<td style="text-align:right;white-space:nowrap;">
|
<td style="text-align:right;white-space:nowrap;">
|
||||||
<a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? __('api_action_block') : __('api_action_unblock') ?></a>
|
<a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? 'Sperren' : 'Aktivieren' ?></a>
|
||||||
<a class="a-link" style="color:var(--danger);" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('<?= __('api_delete_confirm') ?>');"><?= __('btn_delete') ?></a>
|
<a class="a-link" style="color:#e25555;" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('Schlüssel löschen?');">Löschen</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('api_usage') ?></h2>
|
<h2>Verwendung</h2>
|
||||||
<p class="muted" style="margin-bottom:10px;"><?= __('api_usage_hint') ?> <code>Authorization: Bearer <key></code> oder <code>X-API-Key: <key></code>.</p>
|
<p class="muted" style="margin-bottom:10px;">Authentifizierung per Header <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_…" \
|
||||||
|
|
@ -148,10 +162,10 @@ curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
|
||||||
|
|
||||||
# Sites auflisten
|
# Sites auflisten
|
||||||
curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"</pre>
|
curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"</pre>
|
||||||
<p class="muted" style="margin-top:12px;"><?= __('api_openapi') ?> <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p>
|
<p class="muted" style="margin-top:12px;">OpenAPI-Spezifikation (Import in Postman/Swagger): <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</div><!-- /main-content -->
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -45,15 +45,24 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY
|
||||||
$currentPage = 'audit_log';
|
$currentPage = 'audit_log';
|
||||||
$adminBase = '';
|
$adminBase = '';
|
||||||
|
|
||||||
// Aktionsnamen uebersetzt anzeigen; unbekannte Aktionen bleiben technisch.
|
// WICHTIG: Die Keys muessen den tatsaechlich via writeAuditLog() geschriebenen
|
||||||
$actionLabels = [];
|
// Action-Namen entsprechen (user_create, site_edit, ...), sonst erscheinen
|
||||||
foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created',
|
// die Eintraege als rohe Keys.
|
||||||
'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted',
|
$actionLabels = [
|
||||||
'settings_saved', 'password_reset', 'template_created', 'template_updated',
|
'voucher_create' => '🎫 Voucher erstellt',
|
||||||
'template_deleted', 'voucher_kiosk', 'kiosk_created', 'kiosk_updated',
|
'voucher_bulk' => '🎫 Bulk Voucher',
|
||||||
'kiosk_deleted'] as $action) {
|
'user_login' => '🔐 Login',
|
||||||
$actionLabels[$action] = __('audit_action_' . $action);
|
'user_create' => '👤 Benutzer erstellt',
|
||||||
}
|
'user_edit' => '👤 Benutzer geändert',
|
||||||
|
'user_delete' => '👤 Benutzer gelöscht',
|
||||||
|
'site_create' => '🌐 Site hinzugefügt',
|
||||||
|
'site_edit' => '🌐 Site geändert',
|
||||||
|
'site_delete' => '🌐 Site gelöscht',
|
||||||
|
'password_reset' => '🔑 Passwort-Reset',
|
||||||
|
'update_installed' => '🔄 Update installiert',
|
||||||
|
'update_failed' => '🔄 Update fehlgeschlagen',
|
||||||
|
'migrations_run' => '🗄️ Migrationen ausgeführt',
|
||||||
|
];
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="<?= I18n::getLanguage() ?>">
|
<html lang="<?= I18n::getLanguage() ?>">
|
||||||
|
|
@ -62,17 +71,46 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.page-header { margin-bottom: 25px; }
|
||||||
|
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
|
||||||
|
.card { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 20px; }
|
||||||
|
.card-header { padding: 18px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.table { width: 100%; border-collapse: collapse; }
|
||||||
|
.table th { text-align: left; padding: 11px 15px; background: var(--bg-table-head); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.table td { padding: 12px 15px; border-bottom: 1px solid var(--border-color); font-size: 13px; color: var(--text-primary); }
|
||||||
|
.table tr:last-child td { border-bottom: none; }
|
||||||
|
.table tr:hover td { background: var(--bg-hover); }
|
||||||
|
.filter-bar { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
|
||||||
|
.filter-bar select { padding: 9px 12px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 13px; background: var(--bg-input); color: var(--text-primary); }
|
||||||
|
.filter-bar select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-small { padding: 6px 12px; font-size: 12px; }
|
||||||
|
.ip-cell { font-family: monospace; font-size: 12px; color: var(--text-muted); }
|
||||||
|
.details-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); }
|
||||||
|
.pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 25px; border-top: 1px solid var(--border-color); flex-wrap: wrap; gap: 10px; }
|
||||||
|
.page-info { font-size: 13px; color: var(--text-muted); }
|
||||||
|
.page-btns { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||||
|
.page-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-decoration: none; transition: all 0.2s; }
|
||||||
|
.page-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.page-btn.active { background: var(--accent); color: white; border-color: var(--accent); }
|
||||||
|
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||||
|
.empty-state i { font-size: 42px; margin-bottom: 15px; display: block; opacity: 0.3; }
|
||||||
|
.action-chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; background: var(--bg-hover); color: var(--text-secondary); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<h1 class="page-title"><?= __('audit_title') ?></h1>
|
||||||
<h1 class="page-title"><?= __('audit_title') ?></h1>
|
<p style="color: var(--text-muted); font-size: 14px;"><?= __('audit_subtitle') ?></p>
|
||||||
<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" aria-hidden="true"></i> <?= __('audit_filter') ?></span></div>
|
<div class="card-header"><span class="card-title"><i class="fas fa-filter"></i> <?= __('audit_filter') ?></span></div>
|
||||||
<div style="padding: 20px 25px;">
|
<div style="padding: 20px 25px;">
|
||||||
<form method="get" class="filter-bar">
|
<form method="get" class="filter-bar">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -89,7 +127,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
|
||||||
<div>
|
<div>
|
||||||
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:5px;"><?= __('audit_user') ?></label>
|
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:5px;"><?= __('audit_user') ?></label>
|
||||||
<select name="user_id">
|
<select name="user_id">
|
||||||
<option value=""><?= __('audit_all_users') ?></option>
|
<option value="">Alle Benutzer</option>
|
||||||
<?php foreach ($users as $u): ?>
|
<?php foreach ($users as $u): ?>
|
||||||
<option value="<?= $u['id'] ?>" <?= (string)$filterUser === (string)$u['id'] ? 'selected' : '' ?>>
|
<option value="<?= $u['id'] ?>" <?= (string)$filterUser === (string)$u['id'] ? 'selected' : '' ?>>
|
||||||
<?= htmlspecialchars($u['name']) ?>
|
<?= htmlspecialchars($u['name']) ?>
|
||||||
|
|
@ -97,8 +135,8 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search" aria-hidden="true"></i> Filtern</button>
|
<button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search"></i> Filtern</button>
|
||||||
<a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times" aria-hidden="true"></i> Zurücksetzen</a>
|
<a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times"></i> Zurücksetzen</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,15 +144,14 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
|
||||||
<!-- Log-Tabelle -->
|
<!-- Log-Tabelle -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title"><i class="fas fa-history" aria-hidden="true"></i> <?= __('audit_title') ?></span>
|
<span class="card-title"><i class="fas fa-history"></i> <?= __('audit_title') ?></span>
|
||||||
<span style="font-size:13px;color:var(--text-muted);"><?= number_format($total) ?> Einträge</span>
|
<span style="font-size:13px;color:var(--text-muted);"><?= number_format($total) ?> Einträge</span>
|
||||||
</div>
|
</div>
|
||||||
<?php if (empty($logs)): ?>
|
<?php if (empty($logs)): ?>
|
||||||
<div class="empty-state"><i class="fas fa-history" aria-hidden="true"></i><p><?= __('audit_none') ?></p></div>
|
<div class="empty-state"><i class="fas fa-history"></i><p><?= __('audit_none') ?></p></div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<div class="table-container">
|
<table class="table">
|
||||||
<table class="table table-stack">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?= __('audit_time') ?></th>
|
<th><?= __('audit_time') ?></th>
|
||||||
|
|
@ -128,55 +165,54 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($logs as $log): ?>
|
<?php foreach ($logs as $log): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="<?= __('audit_time') ?>" style="white-space:nowrap;color:var(--text-muted);">
|
<td style="white-space:nowrap;color:var(--text-muted);">
|
||||||
<?= date('d.m.Y', strtotime($log['created_at'])) ?><br>
|
<?= date('d.m.Y', strtotime($log['created_at'])) ?><br>
|
||||||
<small><?= date('H:i:s', strtotime($log['created_at'])) ?></small>
|
<small><?= date('H:i:s', strtotime($log['created_at'])) ?></small>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="<?= __('audit_action') ?>">
|
<td>
|
||||||
<span class="action-chip">
|
<span class="action-chip">
|
||||||
<?= htmlspecialchars($actionLabels[$log['action']] ?? $log['action']) ?>
|
<?= htmlspecialchars($actionLabels[$log['action']] ?? $log['action']) ?>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="<?= __('audit_user') ?>">
|
<td>
|
||||||
<?php if ($log['user_name']): ?>
|
<?php if ($log['user_name']): ?>
|
||||||
<strong style="font-size:13px;"><?= htmlspecialchars($log['user_name']) ?></strong><br>
|
<strong style="font-size:13px;"><?= htmlspecialchars($log['user_name']) ?></strong><br>
|
||||||
<small style="color:var(--text-muted);"><?= htmlspecialchars($log['user_email'] ?? '') ?></small>
|
<small style="color:var(--text-muted);"><?= htmlspecialchars($log['user_email'] ?? '') ?></small>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<em style="color:var(--text-muted);"><?= __('audit_system_anon') ?></em>
|
<em style="color:var(--text-muted);">System/Anonym</em>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="<?= __('audit_entity') ?>" style="color:var(--text-secondary);">
|
<td style="color:var(--text-secondary);">
|
||||||
<?php if ($log['entity_type']): ?>
|
<?php if ($log['entity_type']): ?>
|
||||||
<code style="font-size:11px;"><?= htmlspecialchars($log['entity_type']) ?>:<?= htmlspecialchars($log['entity_id'] ?? '') ?></code>
|
<code style="font-size:11px;"><?= htmlspecialchars($log['entity_type']) ?>:<?= htmlspecialchars($log['entity_id'] ?? '') ?></code>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span style="color:var(--text-muted);">-</span>
|
<span style="color:var(--text-muted);">-</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="details-cell" data-label="<?= __('audit_details') ?>" title="<?= htmlspecialchars($log['details'] ?? '') ?>">
|
<td class="details-cell" title="<?= htmlspecialchars($log['details'] ?? '') ?>">
|
||||||
<?= htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, '…')) ?>
|
<?= htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, '…')) ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="ip-cell" data-label="<?= __('audit_ip') ?>"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
<td class="ip-cell"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($pages > 1): ?>
|
<?php if ($pages > 1): ?>
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<span class="page-info"><?= str_replace(['{page}', '{pages}', '{total}'], [(string)$page, (string)$pages, number_format((int)$total, 0, ',', '.')], __('audit_page_info')) ?></span>
|
<span class="page-info">Seite <?= $page ?> von <?= $pages ?> (<?= $total ?> Einträge)</span>
|
||||||
<div class="page-btns">
|
<div class="page-btns">
|
||||||
<?php
|
<?php
|
||||||
$baseUrl = '?' . http_build_query(array_filter(['action' => $filterAction, 'user_id' => $filterUser]));
|
$baseUrl = '?' . http_build_query(array_filter(['action' => $filterAction, 'user_id' => $filterUser]));
|
||||||
if ($page > 1): ?>
|
if ($page > 1): ?>
|
||||||
<a href="<?= $baseUrl ?>&page=<?= $page - 1 ?>" class="page-btn"><i class="fas fa-chevron-left" aria-hidden="true"></i></a>
|
<a href="<?= $baseUrl ?>&page=<?= $page - 1 ?>" class="page-btn"><i class="fas fa-chevron-left"></i></a>
|
||||||
<?php endif;
|
<?php endif;
|
||||||
for ($p = max(1, $page - 2); $p <= min($pages, $page + 2); $p++): ?>
|
for ($p = max(1, $page - 2); $p <= min($pages, $page + 2); $p++): ?>
|
||||||
<a href="<?= $baseUrl ?>&page=<?= $p ?>" class="page-btn <?= $p === $page ? 'active' : '' ?>"><?= $p ?></a>
|
<a href="<?= $baseUrl ?>&page=<?= $p ?>" class="page-btn <?= $p === $page ? 'active' : '' ?>"><?= $p ?></a>
|
||||||
<?php endfor;
|
<?php endfor;
|
||||||
if ($page < $pages): ?>
|
if ($page < $pages): ?>
|
||||||
<a href="<?= $baseUrl ?>&page=<?= $page + 1 ?>" class="page-btn"><i class="fas fa-chevron-right" aria-hidden="true"></i></a>
|
<a href="<?= $baseUrl ?>&page=<?= $page + 1 ?>" class="page-btn"><i class="fas fa-chevron-right"></i></a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -185,7 +221,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</div><!-- main-content -->
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$error = __('error_csrf');
|
$error = __('error_csrf');
|
||||||
} elseif (empty($_FILES['backup']['tmp_name'])) {
|
} elseif (empty($_FILES['backup']['tmp_name'])) {
|
||||||
$error = __('backup_choose_file');
|
$error = 'Bitte eine Backup-Datei auswählen.';
|
||||||
} else {
|
} else {
|
||||||
$raw = file_get_contents($_FILES['backup']['tmp_name']);
|
$raw = file_get_contents($_FILES['backup']['tmp_name']);
|
||||||
$data = json_decode($raw, true);
|
$data = json_decode($raw, true);
|
||||||
if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') {
|
if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') {
|
||||||
$error = __('backup_invalid_file');
|
$error = 'Ungültige oder fremde Backup-Datei.';
|
||||||
} else {
|
} else {
|
||||||
$importSites = isset($_POST['import_sites']);
|
$importSites = isset($_POST['import_sites']);
|
||||||
$importTemplates = isset($_POST['import_templates']);
|
$importTemplates = isset($_POST['import_templates']);
|
||||||
|
|
@ -95,9 +95,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert');
|
$auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert');
|
||||||
$success = str_replace(['{settings}', '{sites}', '{templates}'],
|
$success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile.";
|
||||||
[(string)$counts['settings'], (string)$counts['sites'], (string)$counts['templates']],
|
|
||||||
__('backup_imported'));
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = 'Import-Fehler: ' . $e->getMessage();
|
$error = 'Import-Fehler: ' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
@ -114,38 +112,48 @@ $adminBase = '';
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('backup_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title>Backup & Restore – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<div class="page-header">
|
<style>
|
||||||
<div>
|
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
|
||||||
<h1 class="page-title"><?= __('backup_title') ?></h1>
|
.card h2 { font-size:16px; margin-bottom:12px; color:var(--text-primary); }
|
||||||
<p class="page-subtitle"><?= __('backup_subtitle') ?></p>
|
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
|
||||||
</div>
|
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
||||||
</div>
|
.btn-primary { background:var(--accent,#667eea); color:#fff; }
|
||||||
|
.btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
||||||
|
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
|
||||||
|
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
||||||
|
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||||
|
label.chk { display:flex; align-items:center; gap:9px; margin:8px 0; color:var(--text-primary); font-size:14px; }
|
||||||
|
input[type=file] { margin:10px 0; color:var(--text-primary); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">💾 Backup & Restore</h1>
|
||||||
|
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('backup_export') ?></h2>
|
<h2>Export</h2>
|
||||||
<p class="muted"><?= __('backup_export_hint') ?> <code>APP_KEY</code> <?= __('backup_export_hint2') ?></p>
|
<p class="muted">Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem <code>APP_KEY</code> dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.</p>
|
||||||
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>"><?= __('backup_export_btn') ?></a>
|
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>">Konfiguration exportieren</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('backup_import') ?></h2>
|
<h2>Import / Restore</h2>
|
||||||
<p class="muted"><?= __('backup_import_hint') ?></p>
|
<p class="muted">Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.</p>
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<input type="file" name="backup" accept="application/json,.json" required><br>
|
<input type="file" name="backup" accept="application/json,.json" required><br>
|
||||||
<label class="chk"><input type="checkbox" name="import_settings" checked> <?= __('backup_opt_settings') ?></label>
|
<label class="chk"><input type="checkbox" name="import_settings" checked> Einstellungen</label>
|
||||||
<label class="chk"><input type="checkbox" name="import_sites" checked> <?= __('backup_opt_sites') ?></label>
|
<label class="chk"><input type="checkbox" name="import_sites" checked> Sites</label>
|
||||||
<label class="chk"><input type="checkbox" name="import_templates" checked> <?= __('backup_opt_templates') ?></label>
|
<label class="chk"><input type="checkbox" name="import_templates" checked> Voucher-Profile</label>
|
||||||
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('<?= __('backup_import_confirm') ?>');"><?= __('import_submit') ?></button>
|
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('Import jetzt durchführen?');">Importieren</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</div><!-- /main-content -->
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['do_import'])) {
|
||||||
Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null);
|
Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null);
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert");
|
$auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert");
|
||||||
}
|
}
|
||||||
$success = str_replace('{count}', (string)$created, __('import_created'));
|
$success = "$created Voucher erstellt.";
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = $e->getMessage();
|
$error = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
@ -93,38 +93,48 @@ $adminBase = '';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>CSV-Import – <?= htmlspecialchars($appTitle) ?></title>
|
<title>CSV-Import – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<div class="page-header">
|
<style>
|
||||||
<div>
|
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:20px; box-shadow:0 4px 14px var(--shadow); max-width:760px; }
|
||||||
<h1 class="page-title"><?= __('import_title') ?></h1>
|
.card h2 { font-size:15px; margin-bottom:12px; color:var(--text-primary); }
|
||||||
<p class="page-subtitle"><?= __('import_subtitle') ?></p>
|
.muted { color:var(--text-muted); font-size:13px; margin-bottom:12px; }
|
||||||
</div>
|
label { display:block; font-size:13px; color:var(--text-secondary); margin:12px 0 6px; }
|
||||||
</div>
|
.input,textarea,select { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; font-family:inherit; }
|
||||||
|
textarea { min-height:140px; font-family:monospace; }
|
||||||
|
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; font-size:14px; cursor:pointer; background:var(--accent,#667eea); color:#fff; }
|
||||||
|
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:760px; }
|
||||||
|
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||||
|
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:8px; font-size:13px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
|
||||||
|
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="font-size:24px;margin-bottom:18px;color:var(--text-primary);">📥 Voucher-Import (CSV)</h1>
|
||||||
|
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('import_card_title') ?></h2>
|
<h2>Mehrere Voucher erstellen</h2>
|
||||||
<p class="muted"><?= __('import_format_hint') ?> <code>Name,MaxGeräte,Minuten</code> <?= __('import_format_hint2') ?><br>
|
<p class="muted">Eine Zeile pro Voucher: <code>Name,MaxGeräte,Minuten</code> – MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:<br>
|
||||||
<code>Gast Müller,1,480</code> · <code>Konferenzraum A,5,240</code> · <code>Tagespass</code></p>
|
<code>Gast Müller,1,480</code> · <code>Konferenzraum A,5,240</code> · <code>Tagespass</code></p>
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<label><?= __('import_site') ?></label>
|
<label>Standort</label>
|
||||||
<select class="input" name="site_id" required>
|
<select class="input" name="site_id" required>
|
||||||
<?php foreach ($sites as $s): ?><option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?>
|
<?php foreach ($sites as $s): ?><option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<label><?= __('import_file') ?></label>
|
<label>CSV-Datei (optional)</label>
|
||||||
<input class="input" type="file" name="csv" accept=".csv,text/csv">
|
<input class="input" type="file" name="csv" accept=".csv,text/csv">
|
||||||
<label><?= __('import_paste') ?></label>
|
<label>… oder direkt einfügen</label>
|
||||||
<textarea name="csv_text" placeholder="Gast Müller,1,480 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_confirm') ?>');"><?= __('import_submit') ?></button>
|
<button class="btn" type="submit" name="do_import" style="margin-top:14px;" onclick="return confirm('Import jetzt starten?');">Importieren</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!empty($results)): ?>
|
<?php if (!empty($results)): ?>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('import_result') ?></h2>
|
<h2>Ergebnis</h2>
|
||||||
<table><tr><th><?= __('label_name') ?></th><th><?= __('import_col_code') ?></th><th><?= __('label_status') ?></th></tr>
|
<table><tr><th>Name</th><th>Code / Fehler</th><th>Status</th></tr>
|
||||||
<?php foreach ($results as $r): ?>
|
<?php foreach ($results as $r): ?>
|
||||||
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><code><?= htmlspecialchars($r['code']) ?></code></td><td><?= $r['ok'] ? '✅' : '❌' ?></td></tr>
|
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><code><?= htmlspecialchars($r['code']) ?></code></td><td><?= $r['ok'] ? '✅' : '❌' ?></td></tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
@ -132,7 +142,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</main>
|
</div><!-- /main-content -->
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
178
admin/index.php
|
|
@ -6,7 +6,6 @@ ini_set('log_errors', 1);
|
||||||
require_once __DIR__ . '/../config.php';
|
require_once __DIR__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
require_once __DIR__ . '/../includes/Ui.php';
|
|
||||||
require_once __DIR__ . '/../includes/UniFiController.php';
|
require_once __DIR__ . '/../includes/UniFiController.php';
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
|
||||||
|
|
@ -27,9 +26,12 @@ if (isset($_GET['ajax_stats'])) {
|
||||||
$syncErrors = [];
|
$syncErrors = [];
|
||||||
|
|
||||||
if ($syncFirst) {
|
if ($syncFirst) {
|
||||||
|
// Mehrere Sites werden sequentiell synchronisiert (je bis zu ~15s
|
||||||
|
// bei Timeout) – PHP-Default von 30s reicht dann nicht.
|
||||||
|
@set_time_limit(30 + count($sites) * 20);
|
||||||
foreach ($sites as $site) {
|
foreach ($sites as $site) {
|
||||||
try {
|
try {
|
||||||
$ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id']);
|
$ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id'], $site['ssl_verify'] ?? 0);
|
||||||
$ctrl->syncVouchersToDatabase($db, $site['id']);
|
$ctrl->syncVouchersToDatabase($db, $site['id']);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$syncErrors[$site['id']] = $e->getMessage();
|
$syncErrors[$site['id']] = $e->getMessage();
|
||||||
|
|
@ -91,8 +93,78 @@ $currentPage = 'dashboard';
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('dashboard_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('dashboard_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.page-header { margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
|
||||||
|
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
|
||||||
|
.live-badge { display: inline-flex; align-items: center; gap: 8px; background: #d4edda; color: #155724; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 500; }
|
||||||
|
.live-badge .dot { width: 8px; height: 8px; background: #28a745; border-radius: 50%; animation: pulse 2s infinite; }
|
||||||
|
.live-badge.loading { background: #fff3cd; color: #856404; }
|
||||||
|
.live-badge.loading .dot { background: #ffc107; }
|
||||||
|
.live-badge.error { background: #f8d7da; color: #721c24; }
|
||||||
|
.live-badge.error .dot { background: var(--danger); animation: none; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.5} }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; margin-bottom: 30px; }
|
||||||
|
.stat-card { background: var(--bg-card); padding: 22px; border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); }
|
||||||
|
.stat-card.live { border-color: var(--success); border-width: 2px; }
|
||||||
|
.stat-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||||
|
.stat-card-title { color: var(--text-muted); font-size: 13px; font-weight: 500; }
|
||||||
|
.stat-card-icon { width: 38px; height: 38px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 17px; }
|
||||||
|
.stat-card-value { font-size: 30px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.stat-card-value.valid { color: var(--success); }
|
||||||
|
.stat-card-value.used { color: var(--warning); }
|
||||||
|
.stat-card-value.expired { color: var(--danger); }
|
||||||
|
.stat-card-sub { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); margin-bottom: 28px; overflow: hidden; }
|
||||||
|
.card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.card-title { font-size: 17px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.card-body { padding: 22px; }
|
||||||
|
.table { width: 100%; border-collapse: collapse; }
|
||||||
|
.table th { text-align: left; padding: 11px 14px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.table td { padding: 13px 14px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
|
||||||
|
.table tr:last-child td { border-bottom: none; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-success { background: var(--success); color: white; }
|
||||||
|
.btn-success:hover { opacity: .9; }
|
||||||
|
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||||
|
.chart-container { position: relative; height: 280px; }
|
||||||
|
.site-status { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 14px; }
|
||||||
|
.site-status-card { background: var(--bg-card); border: 2px solid var(--border-color); border-radius: 12px; padding: 18px; transition: border-color .2s; }
|
||||||
|
.site-status-card:hover { border-color: var(--accent); }
|
||||||
|
.site-status-card.error { border-color: var(--danger); }
|
||||||
|
.site-status-name { font-weight: 600; color: var(--text-primary); font-size: 15px; }
|
||||||
|
.site-voucher-count { background: var(--accent); color: white; padding: 3px 11px; border-radius: 20px; font-size: 13px; font-weight: 600; }
|
||||||
|
.site-voucher-count.zero { background: var(--text-muted); }
|
||||||
|
.site-status-stats { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 12px; }
|
||||||
|
.site-stat { text-align: center; padding: 9px; background: var(--bg-hover); border-radius: 8px; }
|
||||||
|
.site-stat-value { font-size: 18px; font-weight: 700; }
|
||||||
|
.site-stat-value.valid { color: var(--success); }
|
||||||
|
.site-stat-value.used { color: var(--warning); }
|
||||||
|
.site-stat-value.expired { color: var(--danger); }
|
||||||
|
.site-stat-label { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
.site-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||||
|
.top-users-list { list-style: none; }
|
||||||
|
.top-users-list li { display: flex; justify-content: space-between; align-items: center; padding: 11px 0; border-bottom: 1px solid var(--border-color); }
|
||||||
|
.top-users-list li:last-child { border-bottom: none; }
|
||||||
|
.user-info-row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.user-avatar-sm { width: 30px; height: 30px; border-radius: 50%; background: linear-gradient(135deg,#667eea,#764ba2); display: flex; align-items: center; justify-content: center; color: white; font-weight: 700; font-size: 13px; }
|
||||||
|
.user-count { background: var(--bg-hover); padding: 3px 10px; border-radius: 12px; font-weight: 600; color: var(--text-primary); font-size: 13px; }
|
||||||
|
.refresh-indicator { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.last-update { color: var(--text-muted); font-size: 12px; }
|
||||||
|
.empty-state { text-align: center; padding: 50px 20px; color: var(--text-muted); }
|
||||||
|
.empty-state i { font-size: 40px; margin-bottom: 15px; opacity: .3; display: block; }
|
||||||
|
@media(max-width:768px){ .main-content{ margin-left:0!important; } .stats-grid{ grid-template-columns:1fr 1fr; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<?php if (!Crypto::hasKey()): ?>
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span><?= __('crypto_warning') ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -104,8 +176,8 @@ $currentPage = 'dashboard';
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span id="liveStatus">DB</span>
|
<span id="liveStatus">DB</span>
|
||||||
</span>
|
</span>
|
||||||
<button onclick="refreshData('live')" class="btn btn-secondary btn-sm" id="refreshBtn">
|
<button onclick="refreshData('live')" class="btn btn-success btn-sm" id="refreshBtn">
|
||||||
<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>
|
<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -114,43 +186,43 @@ $currentPage = 'dashboard';
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title"><?= __('dashboard_active_sites') ?></div>
|
<div class="stat-card-title"><?= __('dashboard_active_sites') ?></div>
|
||||||
<div class="stat-card-icon info"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
|
<div class="stat-card-icon" style="background:#e3f2fd;color:#1976d2;"><i class="fas fa-map-marker-alt"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value"><?= $stats['total_sites'] ?></div>
|
<div class="stat-card-value"><?= $stats['total_sites'] ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title"><?= __('dashboard_users') ?></div>
|
<div class="stat-card-title"><?= __('dashboard_users') ?></div>
|
||||||
<div class="stat-card-icon accent"><i class="fas fa-users" aria-hidden="true"></i></div>
|
<div class="stat-card-icon" style="background:#f3e5f5;color:#7b1fa2;"><i class="fas fa-users"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value"><?= $stats['total_users'] ?></div>
|
<div class="stat-card-value"><?= $stats['total_users'] ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card live">
|
<div class="stat-card live">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title"><?= __('dashboard_valid') ?></div>
|
<div class="stat-card-title">🟢 <?= __('dashboard_valid') ?></div>
|
||||||
<div class="stat-card-icon success"><i class="fas fa-circle-check" aria-hidden="true"></i></div>
|
<div class="stat-card-icon" style="background:#e8f5e9;color:#388e3c;"><i class="fas fa-check-circle"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value valid" id="liveValid"><?= (int)($voucherStats['valid']??0) ?></div>
|
<div class="stat-card-value valid" id="liveValid"><?= (int)($voucherStats['valid']??0) ?></div>
|
||||||
<div class="stat-card-sub" id="subValid"><?= $lastCronSync ? date('H:i', strtotime($lastCronSync)) : __('never') ?></div>
|
<div class="stat-card-sub" id="subValid"><?= $lastCronSync ? date('H:i', strtotime($lastCronSync)) : __('never') ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card live">
|
<div class="stat-card live">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title"><?= __('dashboard_used') ?></div>
|
<div class="stat-card-title">🟡 <?= __('dashboard_used') ?></div>
|
||||||
<div class="stat-card-icon warning"><i class="fas fa-user-check" aria-hidden="true"></i></div>
|
<div class="stat-card-icon" style="background:#fff3e0;color:#f57c00;"><i class="fas fa-user-check"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value used" id="liveUsed"><?= (int)($voucherStats['used']??0) ?></div>
|
<div class="stat-card-value used" id="liveUsed"><?= (int)($voucherStats['used']??0) ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card live">
|
<div class="stat-card live">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title"><?= __('dashboard_expired') ?></div>
|
<div class="stat-card-title">🔴 <?= __('dashboard_expired') ?></div>
|
||||||
<div class="stat-card-icon danger"><i class="fas fa-circle-xmark" aria-hidden="true"></i></div>
|
<div class="stat-card-icon" style="background:#ffebee;color:#d32f2f;"><i class="fas fa-times-circle"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value expired" id="liveExpired"><?= (int)($voucherStats['expired']??0) ?></div>
|
<div class="stat-card-value expired" id="liveExpired"><?= (int)($voucherStats['expired']??0) ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card live">
|
<div class="stat-card live">
|
||||||
<div class="stat-card-header">
|
<div class="stat-card-header">
|
||||||
<div class="stat-card-title"><?= __('dashboard_total') ?></div>
|
<div class="stat-card-title">📊 <?= __('dashboard_total') ?></div>
|
||||||
<div class="stat-card-icon"><i class="fas fa-ticket" aria-hidden="true"></i></div>
|
<div class="stat-card-icon" style="background:var(--bg-hover);color:var(--text-muted);"><i class="fas fa-ticket-alt"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card-value" id="liveTotal"><?= (int)($voucherStats['total']??0) ?></div>
|
<div class="stat-card-value" id="liveTotal"><?= (int)($voucherStats['total']??0) ?></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -158,16 +230,16 @@ $currentPage = 'dashboard';
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title"><?= __('dashboard_vouchers_per_site') ?></h2>
|
<h2 class="card-title">🔴 <?= __('dashboard_vouchers_per_site') ?></h2>
|
||||||
<span class="last-update" id="lastUpdate"><?= $lastCronSync ? date('d.m.Y H:i', strtotime($lastCronSync)) : __('never') ?></span>
|
<span class="last-update" id="lastUpdate"><?= $lastCronSync ? date('d.m.Y H:i', strtotime($lastCronSync)) : __('never') ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body" style="padding:18px;">
|
||||||
<div class="site-status" id="siteStatusContainer">
|
<div class="site-status" id="siteStatusContainer">
|
||||||
<?php foreach ($sites as $site):
|
<?php foreach ($sites as $site):
|
||||||
$ss = $siteStats[$site['id']] ?? ['total'=>0,'valid'=>0,'used'=>0,'expired'=>0];
|
$ss = $siteStats[$site['id']] ?? ['total'=>0,'valid'=>0,'used'=>0,'expired'=>0];
|
||||||
?>
|
?>
|
||||||
<div class="site-status-card" id="site-<?= $site['id'] ?>">
|
<div class="site-status-card" id="site-<?= $site['id'] ?>">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||||
<div class="site-status-name"><?= htmlspecialchars($site['name']) ?></div>
|
<div class="site-status-name"><?= htmlspecialchars($site['name']) ?></div>
|
||||||
<span class="site-voucher-count <?= (int)$ss['total']===0 ? 'zero' : '' ?>" id="site-total-<?= $site['id'] ?>">
|
<span class="site-voucher-count <?= (int)$ss['total']===0 ? 'zero' : '' ?>" id="site-total-<?= $site['id'] ?>">
|
||||||
<?= (int)$ss['total'] ?>
|
<?= (int)$ss['total'] ?>
|
||||||
|
|
@ -182,10 +254,10 @@ $currentPage = 'dashboard';
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($sites)): ?>
|
<?php if (empty($sites)): ?>
|
||||||
<div class="empty-state" style="grid-column:1/-1;">
|
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted);">
|
||||||
<div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
|
<i class="fas fa-map-marker-alt" style="font-size:32px;margin-bottom:15px;opacity:.3;display:block;"></i>
|
||||||
<p><?= __('dashboard_no_data') ?></p>
|
<p><?= __('dashboard_no_data') ?></p>
|
||||||
<a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?></a>
|
<a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus"></i> <?= __('sites_add') ?></a>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -193,18 +265,18 @@ $currentPage = 'dashboard';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h2 class="card-title"><?= __('dashboard_trend') ?></h2></div>
|
<div class="card-header"><h2 class="card-title">📊 <?= __('dashboard_trend') ?></h2></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="chart-container"><canvas id="voucherChart"></canvas></div>
|
<div class="chart-container"><canvas id="voucherChart"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="two-col-grid">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;" class="two-col-grid">
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:0;">
|
||||||
<div class="card-header"><h2 class="card-title"><?= __('dashboard_top_users') ?></h2></div>
|
<div class="card-header"><h2 class="card-title">🏆 <?= __('dashboard_top_users') ?></h2></div>
|
||||||
<div class="card-body">
|
<div class="card-body" style="padding:18px 22px;">
|
||||||
<?php if (empty($topUsers)): ?>
|
<?php if (empty($topUsers)): ?>
|
||||||
<div class="empty-state"><i class="fas fa-users" aria-hidden="true"></i><p><?= __('dashboard_no_data') ?></p></div>
|
<div class="empty-state"><i class="fas fa-users"></i><p><?= __('dashboard_no_data') ?></p></div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<ul class="top-users-list">
|
<ul class="top-users-list">
|
||||||
<?php foreach ($topUsers as $u): ?>
|
<?php foreach ($topUsers as $u): ?>
|
||||||
|
|
@ -224,22 +296,21 @@ $currentPage = 'dashboard';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:0;">
|
||||||
<div class="card-header"><h2 class="card-title"><?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> Live</a></div>
|
<div class="card-header"><h2 class="card-title">📋 <?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-external-link-alt"></i> Live</a></div>
|
||||||
<div class="card-body" style="padding:0;">
|
<div class="card-body" style="padding:0;">
|
||||||
<?php if (empty($recentVouchers)): ?>
|
<?php if (empty($recentVouchers)): ?>
|
||||||
<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= __('dashboard_no_vouchers') ?></p></div>
|
<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('dashboard_no_vouchers') ?></p></div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="table-container">
|
<table class="table">
|
||||||
<table class="table table-stack">
|
|
||||||
<thead><tr><th><?= __('label_created') ?></th><th><?= __('label_code') ?></th><th><?= __('label_site') ?></th><th><?= __('label_status') ?></th></tr></thead>
|
<thead><tr><th><?= __('label_created') ?></th><th><?= __('label_code') ?></th><th><?= __('label_site') ?></th><th><?= __('label_status') ?></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($recentVouchers as $v): ?>
|
<?php foreach ($recentVouchers as $v): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="<?= __('label_created') ?>" style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
|
<td style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
|
||||||
<td data-label="<?= __('label_code') ?>"><code><?= htmlspecialchars($v['voucher_code']) ?></code></td>
|
<td><code style="font-size:12px;background:var(--bg-hover);padding:3px 6px;border-radius:4px;"><?= htmlspecialchars($v['voucher_code']) ?></code></td>
|
||||||
<td data-label="<?= __('label_site') ?>"><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
|
<td><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
|
||||||
<td data-label="<?= __('label_status') ?>">
|
<td>
|
||||||
<?php $st=$v['status']??'valid'; ?>
|
<?php $st=$v['status']??'valid'; ?>
|
||||||
<span class="badge badge-<?= $st==='valid'?'success':($st==='used'?'warning':'danger') ?>"><?= __('status_'.$st) ?></span>
|
<span class="badge badge-<?= $st==='valid'?'success':($st==='used'?'warning':'danger') ?>"><?= __('status_'.$st) ?></span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -247,26 +318,17 @@ $currentPage = 'dashboard';
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</div><!-- /main-content -->
|
||||||
|
|
||||||
<div id="toast-container" role="status" aria-live="polite"></div>
|
<div id="toast-container"></div>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const rootStyles = getComputedStyle(document.documentElement);
|
|
||||||
const accentColor = rootStyles.getPropertyValue('--accent').trim();
|
|
||||||
const surfaceColor = rootStyles.getPropertyValue('--bg-card').trim();
|
|
||||||
const gridColor = rootStyles.getPropertyValue('--border-color').trim();
|
|
||||||
const mutedColor = rootStyles.getPropertyValue('--text-muted').trim();
|
|
||||||
const ctx = document.getElementById('voucherChart').getContext('2d');
|
const ctx = document.getElementById('voucherChart').getContext('2d');
|
||||||
const accentFill = ctx.createLinearGradient(0, 0, 0, 280);
|
|
||||||
accentFill.addColorStop(0, accentColor + '33');
|
|
||||||
accentFill.addColorStop(1, accentColor + '00');
|
|
||||||
const chartData = <?= json_encode($chartData) ?>;
|
const chartData = <?= json_encode($chartData) ?>;
|
||||||
new Chart(ctx, {
|
new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
|
|
@ -275,20 +337,17 @@ new Chart(ctx, {
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Vouchers',
|
label: 'Vouchers',
|
||||||
data: chartData.map(d => d.count),
|
data: chartData.map(d => d.count),
|
||||||
borderColor: accentColor,
|
borderColor: '#667eea',
|
||||||
backgroundColor: accentFill,
|
backgroundColor: 'rgba(102,126,234,0.1)',
|
||||||
tension: 0.4, fill: true,
|
tension: 0.4, fill: true,
|
||||||
pointBackgroundColor: accentColor, pointBorderColor: surfaceColor,
|
pointBackgroundColor: '#667eea', pointBorderColor: '#fff',
|
||||||
pointBorderWidth: 2, pointRadius: 4, pointHoverRadius: 6
|
pointBorderWidth: 2, pointRadius: 5, pointHoverRadius: 7
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true, maintainAspectRatio: false,
|
responsive: true, maintainAspectRatio: false,
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: {
|
scales: { y: { beginAtZero: true, ticks: { stepSize: 1, color: getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim() } }, x: { ticks: { color: getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim() } } }
|
||||||
y: { beginAtZero: true, border: { display: false }, grid: { color: gridColor, drawTicks: false }, ticks: { stepSize: 1, color: mutedColor, padding: 10, font: { size: 11 } } },
|
|
||||||
x: { border: { display: false }, grid: { display: false }, ticks: { color: mutedColor, padding: 8, font: { size: 11 } } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -302,7 +361,7 @@ async function refreshData(mode='db') {
|
||||||
liveBadge.className = 'live-badge loading';
|
liveBadge.className = 'live-badge loading';
|
||||||
liveStatus.textContent = '...';
|
liveStatus.textContent = '...';
|
||||||
refreshBtn.disabled = true;
|
refreshBtn.disabled = true;
|
||||||
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>';
|
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||||
try {
|
try {
|
||||||
const url = `index.php?ajax_stats=1${mode==='live'?'&sync=1':''}`;
|
const url = `index.php?ajax_stats=1${mode==='live'?'&sync=1':''}`;
|
||||||
const result = await fetch(url).then(r=>r.json());
|
const result = await fetch(url).then(r=>r.json());
|
||||||
|
|
@ -340,8 +399,13 @@ async function refreshData(mode='db') {
|
||||||
}
|
}
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
refreshBtn.disabled = false;
|
refreshBtn.disabled = false;
|
||||||
refreshBtn.innerHTML = '<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>';
|
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Responsive two-column grid
|
||||||
|
const twoCol = document.querySelector('.two-col-grid');
|
||||||
|
function checkGrid() { if (twoCol) twoCol.style.gridTemplateColumns = window.innerWidth < 900 ? '1fr' : '1fr 1fr'; }
|
||||||
|
checkGrid(); window.addEventListener('resize', checkGrid);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -49,13 +49,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
|
||||||
$db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0)));
|
$db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0)));
|
||||||
$db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30)));
|
$db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30)));
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert');
|
$auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert');
|
||||||
$success = __('settings_saved');
|
$success = 'Einstellungen gespeichert.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
||||||
Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']);
|
Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']);
|
||||||
$success = __('int_webhook_test_sent');
|
$success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).';
|
||||||
}
|
}
|
||||||
|
|
||||||
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
|
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
|
||||||
|
|
@ -92,14 +92,24 @@ $adminBase = '';
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('int_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title>Integration & Wartung – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<div class="page-header">
|
<style>
|
||||||
<div>
|
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
|
||||||
<h1 class="page-title"><?= __('int_title') ?></h1>
|
.card h2 { font-size:16px; margin-bottom:6px; color:var(--text-primary); }
|
||||||
<p class="page-subtitle"><?= __('int_subtitle') ?></p>
|
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
|
||||||
</div>
|
label { display:block; font-size:14px; color:var(--text-secondary); margin:14px 0 6px; }
|
||||||
</div>
|
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
|
||||||
|
.row { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
||||||
|
.chk { display:flex; align-items:center; gap:9px; margin-top:14px; color:var(--text-primary); font-size:14px; }
|
||||||
|
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
||||||
|
.btn-primary { background:var(--accent,#667eea); color:#fff; } .btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
||||||
|
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
|
||||||
|
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔧 Integration & Wartung</h1>
|
||||||
|
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
@ -108,64 +118,64 @@ $adminBase = '';
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('int_security') ?></h2>
|
<h2>Sicherheitsrichtlinie</h2>
|
||||||
<p class="muted"><?= __('int_security_hint') ?></p>
|
<p class="muted">Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.</p>
|
||||||
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> <?= __('int_enforce_2fa') ?></label>
|
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label>
|
||||||
<label><?= __('int_daily_limit') ?></label>
|
<label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</label>
|
||||||
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
|
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
|
||||||
<label><?= __('int_session_driver') ?></label>
|
<label>Session-Speicher</label>
|
||||||
<select class="input" name="session_driver" style="max-width:340px;">
|
<select class="input" name="session_driver" style="max-width:240px;">
|
||||||
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>><?= __('int_session_php') ?></option>
|
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>>PHP-Standard (Dateien)</option>
|
||||||
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>><?= __('int_session_db') ?></option>
|
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>>Datenbank (ermöglicht „überall abmelden")</option>
|
||||||
</select>
|
</select>
|
||||||
<label><?= __('int_captcha') ?></label>
|
<label>Captcha im öffentlichen Modus</label>
|
||||||
<select class="input" name="captcha_mode" style="max-width:340px;">
|
<select class="input" name="captcha_mode" style="max-width:240px;">
|
||||||
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>><?= __('int_captcha_off') ?></option>
|
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option>
|
||||||
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>><?= __('int_captcha_math') ?></option>
|
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>>Rechenaufgabe (ohne externen Dienst)</option>
|
||||||
<option value="hcaptcha" <?= $captchaMode==='hcaptcha'?'selected':'' ?>>hCaptcha</option>
|
<option value="hcaptcha" <?= $captchaMode==='hcaptcha'?'selected':'' ?>>hCaptcha</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="row3" style="margin-top:10px;">
|
<div class="row3" style="margin-top:10px;">
|
||||||
<div><label>hCaptcha Site-Key</label><input class="input" type="text" name="captcha_site_key" value="<?= htmlspecialchars($captchaSiteKey) ?>"></div>
|
<div><label>hCaptcha Site-Key</label><input class="input" type="text" name="captcha_site_key" value="<?= htmlspecialchars($captchaSiteKey) ?>"></div>
|
||||||
<div><label>hCaptcha Secret<?= $captchaSecretSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? __('int_secret_placeholder') : '' ?>"></div>
|
<div><label>hCaptcha Secret<?= $captchaSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('int_proxy') ?></h2>
|
<h2>Reverse-Proxy</h2>
|
||||||
<p class="muted"><?= __('int_proxy_hint') ?> <code>X-Forwarded-For</code> <?= __('int_proxy_hint2') ?></p>
|
<p class="muted">IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus <code>X-Forwarded-For</code> für Rate-Limit & Audit verwendet.</p>
|
||||||
<input class="input" type="text" name="trusted_proxy" value="<?= htmlspecialchars($trustedProxy) ?>" placeholder="z.B. 10.0.0.1, 172.18.0.1">
|
<input class="input" type="text" name="trusted_proxy" value="<?= htmlspecialchars($trustedProxy) ?>" placeholder="z.B. 10.0.0.1, 172.18.0.1">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('int_webhook') ?></h2>
|
<h2>Webhook-Benachrichtigungen</h2>
|
||||||
<p class="muted"><?= __('int_webhook_hint') ?></p>
|
<p class="muted">Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.</p>
|
||||||
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> <?= __('int_webhook_active') ?></label>
|
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> Webhook aktiv</label>
|
||||||
<label><?= __('int_webhook_url') ?></label>
|
<label>Webhook-URL</label>
|
||||||
<input class="input" type="url" name="webhook_url" value="<?= htmlspecialchars($webhookUrl) ?>" placeholder="https://hooks.slack.com/services/…">
|
<input class="input" type="url" name="webhook_url" value="<?= htmlspecialchars($webhookUrl) ?>" placeholder="https://hooks.slack.com/services/…">
|
||||||
<div style="margin-top:12px;">
|
<div style="margin-top:12px;">
|
||||||
<a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>"><?= __('int_webhook_test') ?></a>
|
<a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>">Test senden</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('int_sms') ?></h2>
|
<h2>SMS-Versand (Twilio)</h2>
|
||||||
<p class="muted"><?= __('int_sms_hint') ?></p>
|
<p class="muted">Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.</p>
|
||||||
<label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> <?= __('int_sms_active') ?></label>
|
<label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> SMS-Versand aktiv</label>
|
||||||
<div class="row3" style="margin-top:10px;">
|
<div class="row3" style="margin-top:10px;">
|
||||||
<div><label>Account SID</label><input class="input" type="text" name="twilio_sid" value="<?= htmlspecialchars($twilioSid) ?>"></div>
|
<div><label>Account SID</label><input class="input" type="text" name="twilio_sid" value="<?= htmlspecialchars($twilioSid) ?>"></div>
|
||||||
<div><label>Auth Token<?= $twilioTokenSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? __('int_secret_placeholder') : '' ?>"></div>
|
<div><label>Auth Token<?= $twilioTokenSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
||||||
<div><label><?= __('int_sms_from') ?></label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div>
|
<div><label>Absender (From)</label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('int_sso') ?></h2>
|
<h2>Single Sign-On (OpenID Connect)</h2>
|
||||||
<p class="muted"><?= __('int_sso_hint') ?> <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p>
|
<p class="muted">Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI: <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p>
|
||||||
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> <?= __('int_sso_active') ?></label>
|
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> OIDC-Login aktiv</label>
|
||||||
<div class="row3" style="margin-top:10px;">
|
<div class="row3" style="margin-top:10px;">
|
||||||
<div><label><?= __('int_sso_button') ?></label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div>
|
<div><label>Button-Text</label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div>
|
||||||
<div><label>Client ID</label><input class="input" type="text" name="oidc_client_id" value="<?= htmlspecialchars($oidcClientId) ?>"></div>
|
<div><label>Client ID</label><input class="input" type="text" name="oidc_client_id" value="<?= htmlspecialchars($oidcClientId) ?>"></div>
|
||||||
<div><label>Client Secret<?= $oidcSecretSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? __('int_secret_placeholder') : '' ?>"></div>
|
<div><label>Client Secret<?= $oidcSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
<label>Authorization Endpoint</label><input class="input" type="url" name="oidc_auth_url" value="<?= htmlspecialchars($oidcAuthUrl) ?>" placeholder="https://idp/authorize">
|
<label>Authorization Endpoint</label><input class="input" type="url" name="oidc_auth_url" value="<?= htmlspecialchars($oidcAuthUrl) ?>" placeholder="https://idp/authorize">
|
||||||
<label>Token Endpoint</label><input class="input" type="url" name="oidc_token_url" value="<?= htmlspecialchars($oidcTokenUrl) ?>" placeholder="https://idp/token">
|
<label>Token Endpoint</label><input class="input" type="url" name="oidc_token_url" value="<?= htmlspecialchars($oidcTokenUrl) ?>" placeholder="https://idp/token">
|
||||||
|
|
@ -174,21 +184,21 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('int_cleanup') ?></h2>
|
<h2>Datenhaltung & Cleanup (DSGVO)</h2>
|
||||||
<p class="muted"><?= __('int_cleanup_hint') ?> <code>cron_cleanup.php</code> <?= __('int_cleanup_hint2') ?>
|
<p class="muted">Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per <code>cron_cleanup.php</code> (täglich empfohlen).
|
||||||
<?php if ($lastCleanup): ?><br><?= __('int_cleanup_last') ?> <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
|
<?php if ($lastCleanup): ?><br>Letzter Lauf: <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div><label><?= __('int_cleanup_expired') ?></label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
|
<div><label>Abgelaufene Voucher</label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
|
||||||
<div><label><?= __('int_cleanup_audit') ?></label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
|
<div><label>Audit-Log</label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
|
||||||
<div><label><?= __('int_cleanup_logins') ?></label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div>
|
<div><label>Login-Versuche</label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary" type="submit" name="save"><?= __('btn_save') ?></button>
|
<button class="btn btn-primary" type="submit" name="save">Speichern</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</main>
|
</div><!-- /main-content -->
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
500
admin/kiosks.php
|
|
@ -1,500 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Verwaltung der öffentlichen Display-Seiten ("Kiosk").
|
|
||||||
*
|
|
||||||
* Jeder Kiosk gehört zu einer Site, hat einen geheimen Link und gibt über
|
|
||||||
* kiosk.php Zugangscodes aus – ohne Anmeldung, aber mit Tageslimit und
|
|
||||||
* Wartezeit zwischen zwei Codes.
|
|
||||||
*/
|
|
||||||
error_reporting(E_ALL);
|
|
||||||
ini_set('display_errors', 0);
|
|
||||||
ini_set('log_errors', 1);
|
|
||||||
|
|
||||||
require_once __DIR__ . '/../config.php';
|
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
|
||||||
require_once __DIR__ . '/../includes/Ui.php';
|
|
||||||
require_once __DIR__ . '/../includes/Kiosk.php';
|
|
||||||
require_once __DIR__ . '/../includes/Upload.php';
|
|
||||||
|
|
||||||
$auth = new Auth();
|
|
||||||
$auth->requireAdmin();
|
|
||||||
I18n::init();
|
|
||||||
|
|
||||||
$db = Database::getInstance();
|
|
||||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
|
||||||
|
|
||||||
$error = '';
|
|
||||||
$success = '';
|
|
||||||
|
|
||||||
/** Formularwerte einsammeln – für Anlegen und Bearbeiten identisch. */
|
|
||||||
function kioskInput(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'site_id' => (int)($_POST['site_id'] ?? 0),
|
|
||||||
'template_id' => (int)($_POST['template_id'] ?? 0) ?: null,
|
|
||||||
'name' => trim((string)($_POST['name'] ?? '')),
|
|
||||||
'headline' => trim((string)($_POST['headline'] ?? '')),
|
|
||||||
'subline' => trim((string)($_POST['subline'] ?? '')),
|
|
||||||
'daily_limit' => max(0, (int)($_POST['daily_limit'] ?? Kiosk::DEFAULT_DAILY_LIMIT)),
|
|
||||||
'cooldown_seconds' => max(0, min(3600, (int)($_POST['cooldown_seconds'] ?? Kiosk::DEFAULT_COOLDOWN))),
|
|
||||||
'display_seconds' => max(10, min(600, (int)($_POST['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS))),
|
|
||||||
'is_active' => isset($_POST['is_active']) ? 1 : 0,
|
|
||||||
'bg_overlay' => max(0, min(90, (int)($_POST['bg_overlay'] ?? 45))),
|
|
||||||
'accent_color' => self_accent($_POST['accent_color'] ?? ''),
|
|
||||||
'card_style' => ($_POST['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nur echte Hex-Farben durchlassen – der Wert landet in einem style-Attribut. */
|
|
||||||
function self_accent($value): ?string
|
|
||||||
{
|
|
||||||
$value = strtolower(trim((string)$value));
|
|
||||||
|
|
||||||
return preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_kiosk'])) {
|
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
|
||||||
$error = __('error_csrf');
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
$in = kioskInput();
|
|
||||||
if ($in['name'] === '') throw new Exception(__('error_name_req'));
|
|
||||||
if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
|
|
||||||
|
|
||||||
$logo = Upload::resolveField('logo_url', '', 'image');
|
|
||||||
$bg = Upload::resolveField('background_url', '', 'image');
|
|
||||||
|
|
||||||
$db->execute(
|
|
||||||
"INSERT INTO kiosks (site_id, template_id, name, token, headline, subline,
|
|
||||||
logo_url, background_url, bg_overlay, accent_color, card_style,
|
|
||||||
daily_limit, cooldown_seconds, display_seconds, is_active, created_by)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
|
|
||||||
[$in['site_id'], $in['template_id'], $in['name'], Kiosk::newToken(),
|
|
||||||
$in['headline'], $in['subline'], $logo, $bg, $in['bg_overlay'],
|
|
||||||
$in['accent_color'], $in['card_style'], $in['daily_limit'],
|
|
||||||
$in['cooldown_seconds'], $in['display_seconds'], $_SESSION['user_id']]
|
|
||||||
);
|
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_created', 'kiosk', null, $in['name']);
|
|
||||||
$success = __('kiosks_added');
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$error = $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_kiosk'])) {
|
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
|
||||||
$error = __('error_csrf');
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
$id = (int)($_POST['kiosk_id'] ?? 0);
|
|
||||||
$in = kioskInput();
|
|
||||||
if ($in['name'] === '') throw new Exception(__('error_name_req'));
|
|
||||||
if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
|
|
||||||
|
|
||||||
$current = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [$id]) ?: [];
|
|
||||||
$logo = Upload::resolveField('logo_url', (string)($current['logo_url'] ?? ''), 'image');
|
|
||||||
$bg = Upload::resolveField('background_url', (string)($current['background_url'] ?? ''), 'image');
|
|
||||||
|
|
||||||
$db->execute(
|
|
||||||
"UPDATE kiosks SET site_id=?, template_id=?, name=?, headline=?, subline=?,
|
|
||||||
logo_url=?, background_url=?, bg_overlay=?, accent_color=?, card_style=?,
|
|
||||||
daily_limit=?, cooldown_seconds=?, display_seconds=?, is_active=?
|
|
||||||
WHERE id=?",
|
|
||||||
[$in['site_id'], $in['template_id'], $in['name'], $in['headline'], $in['subline'],
|
|
||||||
$logo, $bg, $in['bg_overlay'], $in['accent_color'], $in['card_style'],
|
|
||||||
$in['daily_limit'], $in['cooldown_seconds'], $in['display_seconds'], $in['is_active'], $id]
|
|
||||||
);
|
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, $in['name']);
|
|
||||||
$success = __('kiosks_updated');
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$error = $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neuen Link erzeugen – der alte gilt damit sofort nicht mehr.
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['renew_token'])) {
|
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
|
||||||
$error = __('error_csrf');
|
|
||||||
} else {
|
|
||||||
$id = (int)($_POST['kiosk_id'] ?? 0);
|
|
||||||
$db->execute("UPDATE kiosks SET token = ? WHERE id = ?", [Kiosk::newToken(), $id]);
|
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, 'Link erneuert');
|
|
||||||
$success = __('kiosks_token_renewed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($_GET['delete'], $_GET['token'])) {
|
|
||||||
if ($auth->validateCsrfToken($_GET['token'])) {
|
|
||||||
$old = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
|
|
||||||
if ($old) {
|
|
||||||
Upload::delete((string)($old['logo_url'] ?? ''));
|
|
||||||
Upload::delete((string)($old['background_url'] ?? ''));
|
|
||||||
}
|
|
||||||
$db->execute("DELETE FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
|
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_deleted', 'kiosk', (int)$_GET['delete'], '');
|
|
||||||
$success = __('kiosks_deleted');
|
|
||||||
} else {
|
|
||||||
$error = __('error_csrf');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$sites = $db->fetchAll("SELECT id, name FROM sites WHERE is_active = 1 ORDER BY name");
|
|
||||||
$templates = $db->fetchAll("SELECT id, name, max_uses, expire_minutes FROM voucher_templates WHERE is_active = 1 ORDER BY name");
|
|
||||||
$kiosks = $db->fetchAll(
|
|
||||||
"SELECT k.*, s.name AS site_name, t.name AS template_name,
|
|
||||||
(SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id) AS total_vouchers,
|
|
||||||
(SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id AND DATE(v.created_at) = CURDATE()) AS today_vouchers
|
|
||||||
FROM kiosks k
|
|
||||||
INNER JOIN sites s ON s.id = k.site_id
|
|
||||||
LEFT JOIN voucher_templates t ON t.id = k.template_id
|
|
||||||
ORDER BY k.is_active DESC, k.name"
|
|
||||||
);
|
|
||||||
|
|
||||||
$csrf = $auth->getCsrfToken();
|
|
||||||
$currentPage = 'kiosks';
|
|
||||||
$adminBase = '';
|
|
||||||
?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="<?= I18n::getLanguage() ?>">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title><?= __('kiosks_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
|
||||||
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
|
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
|
||||||
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<h1 class="page-title"><?= __('kiosks_title') ?></h1>
|
|
||||||
<p class="page-subtitle"><?= __('kiosks_subtitle') ?></p>
|
|
||||||
</div>
|
|
||||||
<button onclick="openAddModal()" class="btn btn-primary">
|
|
||||||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('kiosks_add') ?>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (empty($sites)): ?>
|
|
||||||
<div class="empty-card">
|
|
||||||
<div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
|
|
||||||
<p><?= __('kiosks_no_sites') ?></p>
|
|
||||||
<a href="sites.php" class="btn btn-primary" style="margin-top:16px;">
|
|
||||||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php elseif (empty($kiosks)): ?>
|
|
||||||
<div class="empty-card">
|
|
||||||
<div class="empty-icon"><i class="fas fa-display" aria-hidden="true"></i></div>
|
|
||||||
<p><?= __('kiosks_empty') ?></p>
|
|
||||||
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top:16px;">
|
|
||||||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('kiosks_add') ?>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="sites-grid">
|
|
||||||
<?php foreach ($kiosks as $k): ?>
|
|
||||||
<?php $url = Kiosk::publicUrl($k['token']); ?>
|
|
||||||
<div class="site-card">
|
|
||||||
<div class="site-card-header">
|
|
||||||
<div>
|
|
||||||
<div class="site-name"><?= htmlspecialchars($k['name']) ?></div>
|
|
||||||
<div class="site-id-label"><?= htmlspecialchars($k['site_name']) ?></div>
|
|
||||||
</div>
|
|
||||||
<span class="badge <?= $k['is_active'] ? 'badge-success' : 'badge-neutral' ?>">
|
|
||||||
<?= $k['is_active'] ? __('status_active') : __('status_inactive') ?>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="site-info">
|
|
||||||
<div class="site-info-item">
|
|
||||||
<i class="fas fa-layer-group" aria-hidden="true"></i>
|
|
||||||
<?= $k['template_name'] ? htmlspecialchars($k['template_name']) : __('kiosks_no_template') ?>
|
|
||||||
</div>
|
|
||||||
<div class="site-info-item">
|
|
||||||
<i class="fas fa-gauge-high" aria-hidden="true"></i>
|
|
||||||
<?= (int)$k['today_vouchers'] ?><?= (int)$k['daily_limit'] > 0 ? ' / ' . (int)$k['daily_limit'] : '' ?>
|
|
||||||
<?= __('kiosks_today') ?>
|
|
||||||
</div>
|
|
||||||
<div class="site-info-item">
|
|
||||||
<i class="fas fa-ticket" aria-hidden="true"></i>
|
|
||||||
<?= (int)$k['total_vouchers'] ?> <?= __('kiosks_total') ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('kiosks_link') ?></label>
|
|
||||||
<div class="kiosk-link-row">
|
|
||||||
<input type="text" class="input" readonly value="<?= htmlspecialchars($url) ?>"
|
|
||||||
id="link-<?= (int)$k['id'] ?>" onclick="this.select()">
|
|
||||||
<button class="btn btn-secondary" type="button"
|
|
||||||
onclick="copyToClipboard('<?= htmlspecialchars($url, ENT_QUOTES) ?>', '<?= __('js_copied') ?>')"
|
|
||||||
title="<?= __('js_copy') ?>" aria-label="<?= __('js_copy') ?>">
|
|
||||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="site-actions">
|
|
||||||
<a class="btn btn-secondary btn-sm" href="<?= htmlspecialchars($url) ?>" target="_blank" rel="noopener">
|
|
||||||
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> <?= __('kiosks_open') ?>
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-secondary btn-sm" type="button"
|
|
||||||
onclick="showQr('<?= htmlspecialchars($url, ENT_QUOTES) ?>', '<?= htmlspecialchars($k['name'], ENT_QUOTES) ?>')">
|
|
||||||
<i class="fas fa-qrcode" aria-hidden="true"></i> <?= __('kiosks_qr') ?>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-sm" type="button"
|
|
||||||
onclick='openEditModal(<?= json_encode([
|
|
||||||
"id" => (int)$k["id"], "site_id" => (int)$k["site_id"],
|
|
||||||
"template_id" => (int)$k["template_id"], "name" => $k["name"],
|
|
||||||
"headline" => $k["headline"], "subline" => $k["subline"],
|
|
||||||
"logo_url" => $k["logo_url"], "background_url" => $k["background_url"],
|
|
||||||
"bg_overlay" => (int)$k["bg_overlay"], "accent_color" => $k["accent_color"],
|
|
||||||
"card_style" => $k["card_style"],
|
|
||||||
"daily_limit" => (int)$k["daily_limit"],
|
|
||||||
"cooldown_seconds" => (int)$k["cooldown_seconds"],
|
|
||||||
"display_seconds" => (int)$k["display_seconds"],
|
|
||||||
"is_active" => (int)$k["is_active"],
|
|
||||||
], JSON_HEX_APOS | JSON_HEX_QUOT) ?>)'>
|
|
||||||
<i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?>
|
|
||||||
</button>
|
|
||||||
<form method="post" style="display:inline;"
|
|
||||||
onsubmit="return confirm('<?= __('kiosks_renew_confirm') ?>');">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
||||||
<input type="hidden" name="kiosk_id" value="<?= (int)$k['id'] ?>">
|
|
||||||
<button class="btn btn-secondary btn-sm" type="submit" name="renew_token">
|
|
||||||
<i class="fas fa-rotate" aria-hidden="true"></i> <?= __('kiosks_renew') ?>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<a class="btn btn-danger-soft btn-sm"
|
|
||||||
href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"
|
|
||||||
onclick="return confirm('<?= __('kiosks_delete_confirm') ?>');"
|
|
||||||
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
|
|
||||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Anlegen / Bearbeiten -->
|
|
||||||
<div class="modal" id="kioskModal">
|
|
||||||
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="kioskModalTitle">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-title" id="kioskModalTitle"><?= __('kiosks_add') ?></h2>
|
|
||||||
<button class="modal-close" type="button" onclick="closeModal()" aria-label="<?= __('btn_cancel') ?>">×</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,7 +6,6 @@ ini_set('log_errors', 1);
|
||||||
require_once __DIR__ . '/../config.php';
|
require_once __DIR__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
require_once __DIR__ . '/../includes/Ui.php';
|
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
|
|
@ -96,46 +95,56 @@ $adminBase = '';
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('rep_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title>Reporting – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
<div class="page-header">
|
<style>
|
||||||
<div>
|
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:22px; margin-bottom:20px; box-shadow:0 4px 14px var(--shadow); }
|
||||||
<h1 class="page-title"><?= __('rep_title') ?></h1>
|
.card h2 { font-size:15px; margin-bottom:14px; color:var(--text-primary); }
|
||||||
<p class="page-subtitle"><?= __('rep_subtitle') ?></p>
|
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px; }
|
||||||
</div>
|
.stat { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:20px; }
|
||||||
</div>
|
.stat .n { font-size:28px; font-weight:700; color:var(--text-primary); } .stat .l { color:var(--text-muted); font-size:12.5px; margin-top:3px; }
|
||||||
|
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:10px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); } th { color:var(--text-muted); }
|
||||||
|
.btn { padding:9px 15px; border:none; border-radius:8px; font-weight:600; font-size:13px; text-decoration:none; display:inline-block; cursor:pointer; }
|
||||||
|
.btn-s { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
||||||
|
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:18px; }
|
||||||
|
select.input { padding:9px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); }
|
||||||
|
@media print { .sidebar,.header,.toolbar,.no-print { display:none !important; } .main-content { margin:0 !important; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="font-size:24px;margin-bottom:18px;color:var(--text-primary);">📊 Reporting</h1>
|
||||||
|
|
||||||
<div class="toolbar no-print">
|
<div class="toolbar no-print">
|
||||||
<form method="get" style="display:flex;gap:8px;align-items:center;">
|
<form method="get" style="display:flex;gap:8px;align-items:center;">
|
||||||
<label style="margin:0;"><?= __('rep_period') ?></label>
|
<label style="color:var(--text-muted);font-size:13px;">Zeitraum:</label>
|
||||||
<select class="input" name="days" onchange="this.form.submit()">
|
<select class="input" name="days" onchange="this.form.submit()">
|
||||||
<?php foreach ([7,30,90,365] as $d): ?>
|
<?php foreach ([7,30,90,365] as $d): ?>
|
||||||
<option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> <?= __('rep_days') ?></option>
|
<option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> Tage</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn btn-secondary" href="?export=daily&days=<?= $days ?>"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_daily') ?></a>
|
<a class="btn btn-s" href="?export=daily&days=<?= $days ?>">⬇️ CSV (täglich)</a>
|
||||||
<a class="btn btn-secondary" href="?export=per_site"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_site') ?></a>
|
<a class="btn btn-s" href="?export=per_site">⬇️ CSV (pro Site)</a>
|
||||||
<a class="btn btn-secondary" href="?export=per_user"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_user') ?></a>
|
<a class="btn btn-s" href="?export=per_user">⬇️ CSV (pro Nutzer)</a>
|
||||||
<button class="btn btn-secondary" onclick="window.print()"><i class="fas fa-print" aria-hidden="true"></i> <?= __('rep_print') ?></button>
|
<button class="btn btn-s" onclick="window.print()">🖨️ Drucken/PDF</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid4">
|
<div class="grid4">
|
||||||
<div class="stat"><div class="n"><?= (int)$totals['total'] ?></div><div class="l"><?= __('rep_total') ?></div></div>
|
<div class="stat"><div class="n"><?= (int)$totals['total'] ?></div><div class="l">Vouchers gesamt</div></div>
|
||||||
<div class="stat"><div class="n"><?= (int)$totals['valid'] ?></div><div class="l"><?= __('status_valid') ?></div></div>
|
<div class="stat"><div class="n"><?= (int)$totals['valid'] ?></div><div class="l">Gültig</div></div>
|
||||||
<div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l"><?= __('status_used') ?></div></div>
|
<div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l">Verwendet</div></div>
|
||||||
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l"><?= str_replace('{days}', (string)$days, __('rep_in_period')) ?></div></div>
|
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l">In <?= $days ?> Tagen erstellt</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= str_replace('{days}', (string)$days, __('rep_chart_title')) ?></h2>
|
<h2>Erstellte Voucher (<?= $days ?> Tage)</h2>
|
||||||
<canvas id="chart" height="90"></canvas>
|
<canvas id="chart" height="90"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('rep_per_site') ?></h2>
|
<h2>Pro Site</h2>
|
||||||
<table><tr><th><?= __('label_site') ?></th><th><?= __('label_total') ?></th><th><?= __('status_valid') ?></th><th><?= __('status_used') ?></th><th><?= __('status_expired') ?></th></tr>
|
<table><tr><th>Site</th><th>Gesamt</th><th>Gültig</th><th>Verwendet</th><th>Abgelaufen</th></tr>
|
||||||
<?php foreach ($perSite as $r): ?>
|
<?php foreach ($perSite as $r): ?>
|
||||||
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><?= (int)$r['total'] ?></td><td><?= (int)$r['valid'] ?></td><td><?= (int)$r['used'] ?></td><td><?= (int)$r['expired'] ?></td></tr>
|
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><?= (int)$r['total'] ?></td><td><?= (int)$r['valid'] ?></td><td><?= (int)$r['used'] ?></td><td><?= (int)$r['expired'] ?></td></tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
@ -143,32 +152,22 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><?= __('rep_top_users') ?></h2>
|
<h2>Top-Nutzer</h2>
|
||||||
<table><tr><th><?= __('label_user') ?></th><th><?= __('rep_col_created') ?></th></tr>
|
<table><tr><th>Benutzer</th><th>Voucher erstellt</th></tr>
|
||||||
<?php foreach ($perUser as $r): ?>
|
<?php foreach ($perUser as $r): ?>
|
||||||
<tr><td><?= htmlspecialchars($r['name'] ?? '–') ?></td><td><?= (int)$r['c'] ?></td></tr>
|
<tr><td><?= htmlspecialchars($r['name'] ?? '–') ?></td><td><?= (int)$r['c'] ?></td></tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);"><?= __('rep_no_data') ?></td></tr><?php endif; ?>
|
<?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);">Keine Daten</td></tr><?php endif; ?>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</div><!-- /main-content -->
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const styles = getComputedStyle(document.documentElement);
|
|
||||||
const accent = styles.getPropertyValue('--accent').trim();
|
|
||||||
const grid = styles.getPropertyValue('--border-color').trim();
|
|
||||||
const muted = styles.getPropertyValue('--text-muted').trim();
|
|
||||||
new Chart(document.getElementById('chart'), {
|
new Chart(document.getElementById('chart'), {
|
||||||
type:'line',
|
type:'line',
|
||||||
data:{ labels: <?= json_encode($chartLabels) ?>, datasets:[{ label:'Voucher', data: <?= json_encode($chartData) ?>, borderColor: accent, backgroundColor: accent + '22', fill:true, tension:.35, pointRadius:0, pointHoverRadius:4, borderWidth:2 }] },
|
data:{ labels: <?= json_encode($chartLabels) ?>, datasets:[{ label:'Voucher', data: <?= json_encode($chartData) ?>, borderColor:'#667eea', backgroundColor:'rgba(102,126,234,.15)', fill:true, tension:.3 }] },
|
||||||
options:{
|
options:{ plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true,ticks:{precision:0}}} }
|
||||||
plugins:{legend:{display:false}},
|
|
||||||
scales:{
|
|
||||||
y:{ beginAtZero:true, border:{display:false}, grid:{color:grid, drawTicks:false}, ticks:{precision:0, color:muted, padding:10, font:{size:11}} },
|
|
||||||
x:{ border:{display:false}, grid:{display:false}, ticks:{color:muted, padding:8, font:{size:11}} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,6 @@ ini_set('log_errors', 1);
|
||||||
require_once __DIR__ . '/../config.php';
|
require_once __DIR__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
|
||||||
require_once __DIR__ . '/../includes/Ui.php';
|
|
||||||
|
|
||||||
I18n::init();
|
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
$auth->requireLogin();
|
$auth->requireLogin();
|
||||||
|
|
@ -28,20 +24,20 @@ $setupRequired = isset($_GET['setup_required']);
|
||||||
// 2FA aktivieren (Code bestaetigen)
|
// 2FA aktivieren (Code bestaetigen)
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$error = __('sec_token_invalid');
|
$error = 'Ungültiges Sicherheits-Token';
|
||||||
} else {
|
} else {
|
||||||
$secret = $_SESSION['totp_setup_secret'] ?? '';
|
$secret = $_SESSION['totp_setup_secret'] ?? '';
|
||||||
$code = trim($_POST['code'] ?? '');
|
$code = trim($_POST['code'] ?? '');
|
||||||
if ($secret === '') {
|
if ($secret === '') {
|
||||||
$error = __('sec_setup_expired');
|
$error = 'Setup abgelaufen, bitte erneut starten.';
|
||||||
} elseif (!Totp::verify($secret, $code)) {
|
} elseif (!Totp::verify($secret, $code)) {
|
||||||
$error = __('sec_code_invalid');
|
$error = 'Code ungültig. Bitte erneut versuchen.';
|
||||||
} else {
|
} else {
|
||||||
$backupCodes = $auth->enableTotp($user['id'], $secret);
|
$backupCodes = $auth->enableTotp($user['id'], $secret);
|
||||||
unset($_SESSION['totp_setup_secret']);
|
unset($_SESSION['totp_setup_secret']);
|
||||||
$totpEnabled = true;
|
$totpEnabled = true;
|
||||||
$user = $auth->getCurrentUser();
|
$user = $auth->getCurrentUser();
|
||||||
$success = __('sec_enabled');
|
$success = 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -49,32 +45,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
|
||||||
// Überall abmelden (andere Sessions beenden)
|
// Überall abmelden (andere Sessions beenden)
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$error = __('sec_token_invalid');
|
$error = 'Ungültiges Sicherheits-Token';
|
||||||
} else {
|
} else {
|
||||||
$auth->logoutOtherSessions();
|
$auth->logoutOtherSessions();
|
||||||
$success = __('sec_sessions_closed');
|
$success = 'Alle anderen Sitzungen wurden beendet.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recovery-Codes neu erzeugen
|
// Recovery-Codes neu erzeugen
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$error = __('sec_token_invalid');
|
$error = 'Ungültiges Sicherheits-Token';
|
||||||
} elseif (!empty($user['totp_enabled'])) {
|
} elseif (!empty($user['totp_enabled'])) {
|
||||||
$backupCodes = $auth->regenerateBackupCodes($user['id']);
|
$backupCodes = $auth->regenerateBackupCodes($user['id']);
|
||||||
$user = $auth->getCurrentUser();
|
$user = $auth->getCurrentUser();
|
||||||
$success = __('sec_codes_new');
|
$success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2FA deaktivieren
|
// 2FA deaktivieren
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$error = __('sec_token_invalid');
|
$error = 'Ungültiges Sicherheits-Token';
|
||||||
} else {
|
} else {
|
||||||
$auth->disableTotp($user['id']);
|
$auth->disableTotp($user['id']);
|
||||||
$totpEnabled = false;
|
$totpEnabled = false;
|
||||||
$success = __('sec_disabled');
|
$success = 'Zwei-Faktor-Authentifizierung wurde deaktiviert.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,36 +87,54 @@ $dbSessions = $db->getSetting('session_driver', 'php') === 'db';
|
||||||
$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
|
$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="<?= I18n::getLanguage() ?>">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('sec_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title>Zwei-Faktor-Authentifizierung – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php if (!$totpEnabled && $hasPassword): ?>
|
<?php if (!$totpEnabled && $hasPassword): ?>
|
||||||
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?= Ui::head($db, '../') ?>
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
|
||||||
|
body { background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px; }
|
||||||
|
.card { background:#fff; border-radius:18px; box-shadow:0 20px 60px rgba(0,0,0,.3); max-width:480px; width:100%; padding:36px; }
|
||||||
|
h1 { font-size:22px; color:#333; margin-bottom:6px; }
|
||||||
|
.sub { color:#777; font-size:14px; margin-bottom:24px; }
|
||||||
|
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
|
||||||
|
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
||||||
|
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||||
|
.status { display:inline-flex; align-items:center; gap:8px; padding:6px 12px; border-radius:8px; font-size:13px; font-weight:600; margin-bottom:20px; }
|
||||||
|
.on { background:#e3f6ea; color:#2a7; } .off { background:#fdeaea; color:#c33; }
|
||||||
|
.qr { display:flex; justify-content:center; margin:18px 0; }
|
||||||
|
.secret { font-family:monospace; background:#f5f6fa; padding:10px; border-radius:8px; text-align:center; letter-spacing:2px; word-break:break-all; font-size:14px; margin-bottom:18px; }
|
||||||
|
ol { margin:0 0 18px 18px; color:#555; font-size:14px; line-height:1.7; }
|
||||||
|
label { display:block; font-size:14px; color:#555; margin-bottom:8px; font-weight:500; }
|
||||||
|
input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-radius:10px; font-size:18px; letter-spacing:6px; text-align:center; }
|
||||||
|
.btn { width:100%; padding:14px; border:none; border-radius:10px; font-size:15px; font-weight:600; cursor:pointer; margin-top:14px; }
|
||||||
|
.btn-primary { background:#667eea; color:#fff; } .btn-danger { background:#e25555; color:#fff; }
|
||||||
|
.back { display:block; text-align:center; margin-top:20px; color:#667eea; text-decoration:none; font-size:14px; }
|
||||||
|
.codes-box { background:#fff8e6; border:1px solid #ffe3a3; border-radius:10px; padding:16px; margin-bottom:18px; }
|
||||||
|
.codes-box strong { font-size:15px; } .codes-box p { color:#8a6d2f; font-size:13px; margin:6px 0 12px; }
|
||||||
|
.codes { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.codes span { font-family:monospace; background:#fff; border:1px solid #ffe3a3; border-radius:6px; padding:8px; text-align:center; letter-spacing:2px; font-size:14px; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app-body focus-page">
|
<body>
|
||||||
<div class="focus-card card">
|
<div class="card">
|
||||||
<div class="focus-head">
|
<h1>🔐 Zwei-Faktor-Authentifizierung</h1>
|
||||||
<span class="focus-icon"><i class="fas fa-shield-halved" aria-hidden="true"></i></span>
|
<p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p>
|
||||||
<div>
|
|
||||||
<h1><?= __('sec_title') ?></h1>
|
|
||||||
<p class="sub"><?= __('sec_account') ?> <?= htmlspecialchars($user['email']) ?></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if ($setupRequired && !$totpEnabled): ?>
|
<?php if ($setupRequired && !$totpEnabled): ?>
|
||||||
<div class="alert alert-error"><?= __('sec_required_hint') ?></div>
|
<div class="alert alert-error">Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
<?php if (!empty($backupCodes)): ?>
|
<?php if (!empty($backupCodes)): ?>
|
||||||
<div class="codes-box">
|
<div class="codes-box">
|
||||||
<strong><i class="fas fa-key" aria-hidden="true"></i> <?= __('sec_recovery_codes') ?></strong>
|
<strong>🔑 Recovery-Codes</strong>
|
||||||
<p><?= __('sec_recovery_hint') ?></p>
|
<p>Bewahren Sie diese sicher auf. Jeder Code funktioniert <em>einmal</em>, falls Sie keinen Zugriff auf Ihre App haben.</p>
|
||||||
<div class="codes">
|
<div class="codes">
|
||||||
<?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?>
|
<?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -128,54 +142,53 @@ $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (!$hasPassword): ?>
|
<?php if (!$hasPassword): ?>
|
||||||
<div class="status off"><i class="fas fa-circle-minus" aria-hidden="true"></i> <?= __('sec_unavailable') ?></div>
|
<div class="status off">● Nicht verfügbar</div>
|
||||||
<p class="sub"><?= __('sec_m365_hint') ?></p>
|
<p class="sub">Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.</p>
|
||||||
<?php elseif ($totpEnabled): ?>
|
<?php elseif ($totpEnabled): ?>
|
||||||
<div class="status on"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('sec_active') ?></div>
|
<div class="status on">● Aktiv</div>
|
||||||
<p class="sub"><?= __('sec_active_hint') ?><br>
|
<p class="sub">Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.<br>
|
||||||
<?= __('sec_codes_left') ?> <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
|
Verbleibende Recovery-Codes: <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
|
||||||
<form method="post" style="margin-bottom:10px;">
|
<form method="post" style="margin-bottom:10px;">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<button type="submit" name="regen_codes" class="btn btn-secondary btn-lg btn-block"><?= __('sec_regen_codes') ?></button>
|
<button type="submit" name="regen_codes" class="btn btn-secondary" style="background:#eef0ff;color:#5a63d6;width:100%;">Recovery-Codes neu erzeugen</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" onsubmit="return confirm('<?= __('sec_disable_confirm') ?>');">
|
<form method="post" onsubmit="return confirm('2FA wirklich deaktivieren?');">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<button type="submit" name="disable_totp" class="btn btn-danger btn-lg btn-block"><?= __('sec_disable') ?></button>
|
<button type="submit" name="disable_totp" class="btn btn-danger">2FA deaktivieren</button>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="status off"><i class="fas fa-circle-minus" aria-hidden="true"></i> <?= __('sec_inactive') ?></div>
|
<div class="status off">● Inaktiv</div>
|
||||||
<ol>
|
<ol>
|
||||||
<li><?= __('sec_step_1') ?></li>
|
<li>Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)</li>
|
||||||
<li><?= __('sec_step_2') ?></li>
|
<li>QR-Code scannen <em>oder</em> Secret manuell eingeben</li>
|
||||||
<li><?= __('sec_step_3') ?></li>
|
<li>Den angezeigten 6-stelligen Code unten eingeben</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="qr"><div id="qrcode"></div></div>
|
<div class="qr"><div id="qrcode"></div></div>
|
||||||
<div class="secret"><?= htmlspecialchars($setupSecret) ?></div>
|
<div class="secret"><?= htmlspecialchars($setupSecret) ?></div>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<label for="code"><?= __('sec_code_label') ?></label>
|
<label for="code">6-stelliger Code</label>
|
||||||
<input type="text" id="code" name="code" class="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required placeholder="123456">
|
<input type="text" id="code" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required>
|
||||||
<button type="submit" name="enable_totp" class="btn btn-primary btn-lg btn-block" style="margin-top:14px;"><?= __('sec_enable') ?></button>
|
<button type="submit" name="enable_totp" class="btn btn-primary">2FA aktivieren</button>
|
||||||
</form>
|
</form>
|
||||||
<script>
|
<script>
|
||||||
new QRCode(document.getElementById('qrcode'), {
|
new QRCode(document.getElementById('qrcode'), {
|
||||||
text: <?= json_encode($otpUri) ?>, width: 168, height: 168,
|
text: <?= json_encode($otpUri) ?>, width: 180, height: 180,
|
||||||
colorDark: '#101625', colorLight: '#ffffff',
|
|
||||||
correctLevel: QRCode.CorrectLevel.M
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($dbSessions): ?>
|
<?php if ($dbSessions): ?>
|
||||||
<hr>
|
<hr style="margin:20px 0;border:none;border-top:1px solid #eee;">
|
||||||
<p class="sub"><?= __('sec_sessions') ?> <strong><?= (int)$activeSessions ?></strong></p>
|
<p class="sub">Aktive Sitzungen: <strong><?= (int)$activeSessions ?></strong></p>
|
||||||
<form method="post" onsubmit="return confirm('<?= __('sec_logout_others_confirm') ?>');">
|
<form method="post" onsubmit="return confirm('Alle anderen Sitzungen abmelden?');">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
<button type="submit" name="logout_others" class="btn btn-secondary btn-lg btn-block"><?= __('sec_logout_others') ?></button>
|
<button type="submit" name="logout_others" class="btn" style="background:#eef0ff;color:#5a63d6;width:100%;">Auf allen anderen Geräten abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="auth-links"><a class="back-link" href="../index.php"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('nav_back') ?></a></div>
|
<a class="back" href="../index.php">← Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
require_once __DIR__ . '/../includes/Mailer.php';
|
require_once __DIR__ . '/../includes/Mailer.php';
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
require_once __DIR__ . '/../includes/Ui.php';
|
require_once __DIR__ . '/../includes/Helpers.php';
|
||||||
require_once __DIR__ . '/../includes/Upload.php';
|
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
$auth->requireAdmin();
|
$auth->requireAdmin();
|
||||||
|
|
@ -53,37 +52,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
||||||
|
|
||||||
if ($formType === 'general') {
|
if ($formType === 'general') {
|
||||||
$settings['app_title'] = trim($_POST['app_title'] ?? '');
|
$settings['app_title'] = trim($_POST['app_title'] ?? '');
|
||||||
$settings['logo_url'] = Upload::resolveField('logo_url', (string)$db->getSetting('logo_url', ''), 'image');
|
$settings['logo_url'] = trim($_POST['logo_url'] ?? '');
|
||||||
$settings['favicon_url'] = Upload::resolveField('favicon_url', (string)$db->getSetting('favicon_url', ''), 'favicon');
|
$settings['favicon_url'] = trim($_POST['favicon_url'] ?? '');
|
||||||
$settings['instruction_header'] = trim($_POST['instruction_header'] ?? '');
|
$settings['instruction_header'] = trim($_POST['instruction_header'] ?? '');
|
||||||
$settings['instruction_text'] = $_POST['instruction_text'] ?? '';
|
$settings['instruction_text'] = $_POST['instruction_text'] ?? '';
|
||||||
$settings['public_access'] = isset($_POST['public_access']) ? '1' : '0';
|
$settings['public_access'] = isset($_POST['public_access']) ? '1' : '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($formType === 'branding') {
|
|
||||||
foreach (['brand_accent', 'brand_accent_dark', 'brand_gradient_from', 'brand_gradient_to'] as $key) {
|
|
||||||
$value = strtolower(trim($_POST[$key] ?? ''));
|
|
||||||
$settings[$key] = preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : '';
|
|
||||||
}
|
|
||||||
$radius = (int)($_POST['brand_radius'] ?? Ui::DEFAULT_RADIUS);
|
|
||||||
$settings['brand_radius'] = (string)max(0, min(28, $radius));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($formType === 'login') {
|
|
||||||
$settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0';
|
|
||||||
$settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? '');
|
|
||||||
$settings['login_logo_url'] = Upload::resolveField('login_logo_url', (string)$db->getSetting('login_logo_url', ''), 'image');
|
|
||||||
$settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? '');
|
|
||||||
$settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? '');
|
|
||||||
$settings['login_features'] = trim($_POST['login_features'] ?? '');
|
|
||||||
$settings['login_footer'] = trim($_POST['login_footer'] ?? '');
|
|
||||||
$settings['login_bg_image'] = Upload::resolveField('login_bg_image', (string)$db->getSetting('login_bg_image', ''), 'image');
|
|
||||||
$settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? '');
|
|
||||||
$settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? '');
|
|
||||||
$overlay = (int)($_POST['login_bg_overlay'] ?? 40);
|
|
||||||
$settings['login_bg_overlay'] = (string)max(0, min(90, $overlay));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($formType === 'defaults') {
|
if ($formType === 'defaults') {
|
||||||
$expMin = (int)($_POST['default_expire_minutes'] ?? 480);
|
$expMin = (int)($_POST['default_expire_minutes'] ?? 480);
|
||||||
$defDev = (int)($_POST['default_max_uses'] ?? 1);
|
$defDev = (int)($_POST['default_max_uses'] ?? 1);
|
||||||
|
|
@ -98,7 +73,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
||||||
|
|
||||||
if ($formType === 'm365') {
|
if ($formType === 'm365') {
|
||||||
$settings['m365_client_id'] = trim($_POST['m365_client_id'] ?? '');
|
$settings['m365_client_id'] = trim($_POST['m365_client_id'] ?? '');
|
||||||
$settings['m365_client_secret'] = trim($_POST['m365_client_secret'] ?? '');
|
// Secret nur aktualisieren, wenn eines eingegeben wurde – es wird
|
||||||
|
// (wie das SMTP-Passwort) nicht mehr ins Formular zurueckgegeben.
|
||||||
|
if (!empty($_POST['m365_client_secret'])) {
|
||||||
|
$settings['m365_client_secret'] = trim($_POST['m365_client_secret']);
|
||||||
|
}
|
||||||
$settings['m365_tenant_id'] = trim($_POST['m365_tenant_id'] ?? '');
|
$settings['m365_tenant_id'] = trim($_POST['m365_tenant_id'] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,6 +90,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
||||||
$settings['smtp_password'] = trim($_POST['smtp_password']);
|
$settings['smtp_password'] = trim($_POST['smtp_password']);
|
||||||
}
|
}
|
||||||
$settings['smtp_encryption'] = trim($_POST['smtp_encryption'] ?? 'tls');
|
$settings['smtp_encryption'] = trim($_POST['smtp_encryption'] ?? 'tls');
|
||||||
|
$settings['smtp_verify_ssl'] = isset($_POST['smtp_verify_ssl']) ? '1' : '0';
|
||||||
$settings['smtp_from_email'] = trim($_POST['smtp_from_email'] ?? '');
|
$settings['smtp_from_email'] = trim($_POST['smtp_from_email'] ?? '');
|
||||||
$settings['smtp_from_name'] = trim($_POST['smtp_from_name'] ?? '');
|
$settings['smtp_from_name'] = trim($_POST['smtp_from_name'] ?? '');
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +104,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($formType === 'system') {
|
if ($formType === 'system') {
|
||||||
|
$settings['tinymce_api_key'] = trim($_POST['tinymce_api_key'] ?? '');
|
||||||
$settings['print_template'] = $_POST['print_template'] ?? '';
|
$settings['print_template'] = $_POST['print_template'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,9 +112,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
||||||
$db->setSetting($key, $value);
|
$db->setSetting($key, $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
$success = __('settings_saved');
|
// PRG + Tab-Anker: F5 speichert nicht erneut, und der Nutzer landet
|
||||||
} catch (RuntimeException $e) {
|
// wieder auf dem Tab, in dem er gespeichert hat.
|
||||||
$error = $e->getMessage();
|
$tabAnchors = [
|
||||||
|
'general' => 'general', 'defaults' => 'defaults', 'm365' => 'm365',
|
||||||
|
'smtp' => 'smtp', 'templates' => 'templates_email', 'system' => 'system',
|
||||||
|
];
|
||||||
|
flashSet(__('settings_saved'));
|
||||||
|
header('Location: settings.php#' . ($tabAnchors[$formType] ?? 'general'));
|
||||||
|
exit;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = 'Fehler: ' . $e->getMessage();
|
$error = 'Fehler: ' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
@ -146,7 +133,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['generate_cron_token']
|
||||||
$error = __('error_csrf');
|
$error = __('error_csrf');
|
||||||
} else {
|
} else {
|
||||||
$db->setSetting('cron_token', bin2hex(random_bytes(32)));
|
$db->setSetting('cron_token', bin2hex(random_bytes(32)));
|
||||||
$success = 'Neuer Cron-Token wurde generiert!';
|
flashSet(__('cron_token_generated'));
|
||||||
|
header('Location: settings.php#cron');
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token'])) {
|
||||||
|
|
@ -154,7 +143,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token']))
|
||||||
$error = __('error_csrf');
|
$error = __('error_csrf');
|
||||||
} else {
|
} else {
|
||||||
$db->setSetting('cron_token', '');
|
$db->setSetting('cron_token', '');
|
||||||
$success = 'Cron-Token wurde gelöscht!';
|
flashSet(__('cron_token_deleted'));
|
||||||
|
header('Location: settings.php#cron');
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,23 +157,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['change_password'])) {
|
||||||
try {
|
try {
|
||||||
$user = $auth->getCurrentUser();
|
$user = $auth->getCurrentUser();
|
||||||
if (!password_verify($_POST['current_password'], $user['password_hash'])) {
|
if (!password_verify($_POST['current_password'], $user['password_hash'])) {
|
||||||
throw new Exception('Aktuelles Passwort ist falsch');
|
throw new Exception(__('error_pw_current'));
|
||||||
}
|
}
|
||||||
if (strlen($_POST['new_password']) < 8) {
|
if (strlen($_POST['new_password']) < 8) {
|
||||||
throw new Exception(__('settings_pw_minlength'));
|
throw new Exception(__('settings_pw_minlength'));
|
||||||
}
|
}
|
||||||
if ($_POST['new_password'] !== $_POST['confirm_password']) {
|
if ($_POST['new_password'] !== $_POST['confirm_password']) {
|
||||||
throw new Exception('Passwörter stimmen nicht überein');
|
throw new Exception(__('error_pw_mismatch'));
|
||||||
}
|
}
|
||||||
$db->query("UPDATE users SET password_hash = ? WHERE id = ?",
|
$db->query("UPDATE users SET password_hash = ? WHERE id = ?",
|
||||||
[password_hash($_POST['new_password'], PASSWORD_DEFAULT), $user['id']]);
|
[password_hash($_POST['new_password'], PASSWORD_DEFAULT), $user['id']]);
|
||||||
$success = __('settings_pw_changed');
|
flashSet(__('settings_pw_changed'));
|
||||||
|
header('Location: settings.php#password');
|
||||||
|
exit;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = $e->getMessage();
|
$error = $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (empty($success) && empty($error) && ($flash = flashGet())) {
|
||||||
|
$success = $flash['message'];
|
||||||
|
}
|
||||||
|
|
||||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||||
$host = $_SERVER['HTTP_HOST'];
|
$host = $_SERVER['HTTP_HOST'];
|
||||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME'], 2);
|
$scriptPath = dirname($_SERVER['SCRIPT_NAME'], 2);
|
||||||
|
|
@ -208,30 +205,16 @@ $cs = [
|
||||||
'smtp_username' => $db->getSetting('smtp_username', ''),
|
'smtp_username' => $db->getSetting('smtp_username', ''),
|
||||||
'smtp_password' => $db->getSetting('smtp_password', ''),
|
'smtp_password' => $db->getSetting('smtp_password', ''),
|
||||||
'smtp_encryption' => $db->getSetting('smtp_encryption', 'tls'),
|
'smtp_encryption' => $db->getSetting('smtp_encryption', 'tls'),
|
||||||
|
'smtp_verify_ssl' => $db->getSetting('smtp_verify_ssl', '0'),
|
||||||
'smtp_from_email' => $db->getSetting('smtp_from_email', ''),
|
'smtp_from_email' => $db->getSetting('smtp_from_email', ''),
|
||||||
'smtp_from_name' => $db->getSetting('smtp_from_name', ''),
|
'smtp_from_name' => $db->getSetting('smtp_from_name', ''),
|
||||||
'system_url' => $db->getSetting('system_url', $autoDetectedUrl),
|
'system_url' => $db->getSetting('system_url', $autoDetectedUrl),
|
||||||
'email_voucher_subject' => $db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'),
|
'email_voucher_subject' => $db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'),
|
||||||
'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\n<strong>Maximale Geräte:</strong> {MAX_USES}<br>\n<strong>Standort:</strong> {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}"),
|
'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nIhr Code: {VOUCHER_CODE}\n\nGültigkeit: 8h\nGeräte: {MAX_USES}\nSite: {SITE_NAME}"),
|
||||||
'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'),
|
'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'),
|
||||||
'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"),
|
'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"),
|
||||||
'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()),
|
'tinymce_api_key' => $db->getSetting('tinymce_api_key', ''),
|
||||||
'brand_accent' => $db->getSetting('brand_accent', '') ?: Ui::DEFAULT_ACCENT,
|
'print_template' => $db->getSetting('print_template', '<div style="text-align:center;padding:40px"><h1>{APP_TITLE}</h1><h2>WLAN Code</h2><div style="font-size:48px;font-weight:bold;margin:30px 0;font-family:monospace">{VOUCHER_CODE}</div><p><strong>Gültig bis:</strong> {EXPIRY_DATE} {EXPIRY_TIME}</p><p><strong>Site:</strong> {SITE_NAME}</p><p><strong>Geräte:</strong> {MAX_USES}</p><hr style="margin:30px 0"><div>{INSTRUCTIONS}</div></div>'),
|
||||||
'brand_accent_dark' => $db->getSetting('brand_accent_dark', '') ?: Ui::DEFAULT_ACCENT_DARK,
|
|
||||||
'brand_gradient_from' => $db->getSetting('brand_gradient_from', '') ?: Ui::DEFAULT_GRADIENT_FROM,
|
|
||||||
'brand_gradient_to' => $db->getSetting('brand_gradient_to', '') ?: Ui::DEFAULT_GRADIENT_TO,
|
|
||||||
'brand_radius' => $db->getSetting('brand_radius', (string)Ui::DEFAULT_RADIUS),
|
|
||||||
'login_panel_enabled' => $db->getSetting('login_panel_enabled', '1'),
|
|
||||||
'login_brand_name' => $db->getSetting('login_brand_name', ''),
|
|
||||||
'login_logo_url' => $db->getSetting('login_logo_url', ''),
|
|
||||||
'login_claim_title' => $db->getSetting('login_claim_title', ''),
|
|
||||||
'login_claim_text' => $db->getSetting('login_claim_text', ''),
|
|
||||||
'login_features' => $db->getSetting('login_features', ''),
|
|
||||||
'login_footer' => $db->getSetting('login_footer', ''),
|
|
||||||
'login_bg_image' => $db->getSetting('login_bg_image', ''),
|
|
||||||
'login_bg_from' => $db->getSetting('login_bg_from', '#3b2f8f'),
|
|
||||||
'login_bg_to' => $db->getSetting('login_bg_to', '#6d5ce7'),
|
|
||||||
'login_bg_overlay' => $db->getSetting('login_bg_overlay', '40'),
|
|
||||||
'cron_token' => $db->getSetting('cron_token', ''),
|
'cron_token' => $db->getSetting('cron_token', ''),
|
||||||
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
|
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
|
||||||
];
|
];
|
||||||
|
|
@ -246,63 +229,98 @@ $adminBase = '';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
||||||
|
|
||||||
<!-- TinyMCE wird lokal ausgeliefert (GPL-Variante) – keine externen Aufrufe. -->
|
<?php if (!empty($cs['tinymce_api_key'])): ?>
|
||||||
<?= Ui::script('assets/vendor/tinymce/tinymce.min.js', '../') ?>
|
<script src="https://cdn.tiny.cloud/1/<?= htmlspecialchars($cs['tinymce_api_key']) ?>/tinymce/6/tinymce.min.js"></script>
|
||||||
<script>window.TINYMCE_BASE_URL = '../assets/vendor/tinymce';</script>
|
<?php else: ?>
|
||||||
|
<script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js"></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-header { margin-bottom: 30px; }
|
||||||
|
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
|
||||||
|
.tab-container { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; }
|
||||||
|
.tab-navigation { display: flex; background: var(--bg-table-head); border-bottom: 2px solid var(--border-color); overflow-x: auto; position: sticky; top: 70px; z-index: 50; }
|
||||||
|
.tab-button { padding: 15px 20px; background: transparent; border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-secondary); transition: all 0.3s; white-space: nowrap; display: flex; align-items: center; gap: 7px; }
|
||||||
|
.tab-button:hover { background: rgba(102,126,234,0.1); color: var(--accent); }
|
||||||
|
.tab-button.active { color: var(--accent); border-bottom-color: var(--accent); background: var(--bg-card); }
|
||||||
|
.tab-content { display: none; padding: 30px; animation: fadeIn 0.2s; }
|
||||||
|
.tab-content.active { display: block; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.form-group { margin-bottom: 20px; }
|
||||||
|
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
||||||
|
input[type="text"], input[type="url"], input[type="password"], input[type="number"], input[type="email"], select, textarea { width: 100%; padding: 11px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; transition: border-color 0.2s; font-family: inherit; background: var(--bg-input); color: var(--text-primary); }
|
||||||
|
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
textarea { resize: vertical; min-height: 100px; }
|
||||||
|
.checkbox-group { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.checkbox-group input { width: auto; accent-color: var(--accent); }
|
||||||
|
.help-text { font-size: 12px; color: var(--text-muted); margin-top: 5px; }
|
||||||
|
.info-box { background: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||||
|
.info-box h4 { color: #0066cc; margin-bottom: 8px; font-size: 14px; }
|
||||||
|
.info-box p { color: #004d99; font-size: 13px; line-height: 1.5; }
|
||||||
|
.section-divider { border: none; border-top: 2px solid var(--border-color); margin: 28px 0; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
|
||||||
|
.placeholder-info { background: #fff9e6; border: 1px solid #ffe066; border-radius: 8px; padding: 14px; margin: 14px 0; }
|
||||||
|
.placeholder-info h4 { color: #996600; margin-bottom: 8px; font-size: 14px; }
|
||||||
|
.placeholder-info code { background: #fff; padding: 2px 6px; border-radius: 3px; font-size: 12px; color: #d63384; }
|
||||||
|
.placeholder-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-danger { background: #dc3545; color: white; }
|
||||||
|
.btn-small { padding: 6px 12px; font-size: 13px; }
|
||||||
|
.token-box { background: var(--bg-hover); padding: 14px; border-radius: 6px; font-family: monospace; word-break: break-all; color: var(--text-primary); }
|
||||||
|
code { background: var(--code-bg); color: var(--accent); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<h1 class="page-title"><?= __('settings_title') ?></h1>
|
||||||
<h1 class="page-title"><?= __('settings_title') ?></h1>
|
<p style="color: var(--text-muted); font-size: 14px;"><?= __('settings_subtitle') ?></p>
|
||||||
<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" aria-hidden="true"></i><span><?= htmlspecialchars($error) ?></span></div>
|
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i><span><?= htmlspecialchars($error) ?></span></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($success): ?>
|
<?php if ($success): ?>
|
||||||
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i><span><?= htmlspecialchars($success) ?></span></div>
|
<div class="alert alert-success"><i class="fas fa-check-circle"></i><span><?= htmlspecialchars($success) ?></span></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="tab-container">
|
<div class="tab-container">
|
||||||
<div class="tab-navigation" id="tabNav">
|
<div class="tab-navigation" id="tabNav">
|
||||||
<button class="tab-button<?= $activeTab === 'general' ? ' active' : '' ?>" data-tab="general"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></button>
|
<button class="tab-button active" data-tab="general"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></button>
|
||||||
<button class="tab-button<?= $activeTab === 'defaults' ? ' active' : '' ?>" data-tab="defaults"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></button>
|
<button class="tab-button" data-tab="defaults"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></button>
|
||||||
<button class="tab-button<?= $activeTab === 'branding' ? ' active' : '' ?>" data-tab="branding"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></button>
|
<button class="tab-button" data-tab="cron"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></button>
|
||||||
<button class="tab-button<?= $activeTab === 'login' ? ' active' : '' ?>" data-tab="login"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></button>
|
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft"></i> <?= __('settings_tab_m365') ?></button>
|
||||||
<button class="tab-button<?= $activeTab === 'cron' ? ' active' : '' ?>" data-tab="cron"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></button>
|
<button class="tab-button" data-tab="smtp"><i class="fas fa-envelope"></i> <?= __('settings_tab_smtp') ?></button>
|
||||||
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft" aria-hidden="true"></i> <?= __('settings_tab_m365') ?></button>
|
<button class="tab-button" data-tab="templates_email"><i class="fas fa-file-alt"></i> <?= __('settings_tab_templates_email') ?></button>
|
||||||
<button class="tab-button<?= $activeTab === 'smtp' ? ' active' : '' ?>" data-tab="smtp"><i class="fas fa-envelope" aria-hidden="true"></i> <?= __('settings_tab_smtp') ?></button>
|
<button class="tab-button" data-tab="system"><i class="fas fa-cogs"></i> <?= __('settings_tab_system') ?></button>
|
||||||
<button class="tab-button<?= $activeTab === 'templates_email' ? ' active' : '' ?>" data-tab="templates_email"><i class="fas fa-file-alt" aria-hidden="true"></i> <?= __('settings_tab_templates_email') ?></button>
|
<button class="tab-button" data-tab="password"><i class="fas fa-key"></i> <?= __('settings_tab_password') ?></button>
|
||||||
<button class="tab-button<?= $activeTab === 'system' ? ' active' : '' ?>" data-tab="system"><i class="fas fa-cogs" aria-hidden="true"></i> <?= __('settings_tab_system') ?></button>
|
|
||||||
<button class="tab-button<?= $activeTab === 'password' ? ' active' : '' ?>" data-tab="password"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Allgemein -->
|
<!-- Allgemein -->
|
||||||
<div id="tab-general" class="tab-content<?= $activeTab === 'general' ? ' active' : '' ?>">
|
<div id="tab-general" class="tab-content active">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></h2>
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="general">
|
<input type="hidden" name="form_type" value="general">
|
||||||
<div class="form-group"><label><?= __('settings_app_title') ?></label><input type="text" name="app_title" value="<?= htmlspecialchars($cs['app_title']) ?>" required></div>
|
<div class="form-group"><label><?= __('settings_app_title') ?></label><input type="text" name="app_title" value="<?= htmlspecialchars($cs['app_title']) ?>" required></div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<?= Ui::imageField('logo_url', __('settings_logo_url'), $cs['logo_url'], __('settings_upload_hint')) ?>
|
<div class="form-group"><label><?= __('settings_logo_url') ?></label><input type="url" name="logo_url" value="<?= htmlspecialchars($cs['logo_url']) ?>" placeholder="https://example.com/logo.png"></div>
|
||||||
<?= Ui::imageField('favicon_url', __('settings_favicon_url'), $cs['favicon_url'], __('settings_favicon_hint'), 'image/x-icon,image/png,image/svg+xml') ?>
|
<div class="form-group"><label><?= __('settings_favicon_url') ?></label><input type="url" name="favicon_url" value="<?= htmlspecialchars($cs['favicon_url']) ?>" placeholder="https://example.com/favicon.ico"><div class="help-text"><?= __('settings_favicon_hint') ?></div></div>
|
||||||
</div>
|
</div>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<div class="form-group"><label><?= __('settings_instr_header') ?></label><input type="text" name="instruction_header" value="<?= htmlspecialchars($cs['instruction_header']) ?>"></div>
|
<div class="form-group"><label><?= __('settings_instr_header') ?></label><input type="text" name="instruction_header" value="<?= htmlspecialchars($cs['instruction_header']) ?>"></div>
|
||||||
<div class="form-group"><label><?= __('settings_instr_text') ?></label><textarea name="instruction_text" class="tinymce-editor"><?= htmlspecialchars($cs['instruction_text']) ?></textarea></div>
|
<div class="form-group"><label><?= __('settings_instr_text') ?></label><textarea name="instruction_text" class="tinymce-editor"><?= htmlspecialchars($cs['instruction_text']) ?></textarea></div>
|
||||||
<div class="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="public_access" id="public_access" <?= $cs['public_access'] == '1' ? 'checked' : '' ?>><label for="public_access" style="margin:0;"><?= __('settings_public_access') ?></label></div>
|
<div class="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="public_access" id="public_access" <?= $cs['public_access'] == '1' ? 'checked' : '' ?>><label for="public_access" style="margin:0;"><?= __('settings_public_access') ?></label></div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Voucher-Standards -->
|
<!-- Voucher-Standards -->
|
||||||
<div id="tab-defaults" class="tab-content<?= $activeTab === 'defaults' ? ' active' : '' ?>">
|
<div id="tab-defaults" class="tab-content">
|
||||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></h2>
|
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></h2>
|
||||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_defaults_hint') ?></p>
|
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;">Diese Werte werden als Vorgabe im Voucher-Formular verwendet.</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="defaults">
|
<input type="hidden" name="form_type" value="defaults">
|
||||||
|
|
@ -324,184 +342,37 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-box" style="margin-top: 10px;">
|
<div class="info-box" style="margin-top: 10px;">
|
||||||
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> Gültigkeits-Referenz</h4>
|
<h4><i class="fas fa-info-circle"></i> Gültigkeits-Referenz</h4>
|
||||||
<p>
|
<p>
|
||||||
60 Min = 1 Stunde | 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" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top: 16px;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Design & Branding -->
|
|
||||||
<div id="tab-branding" class="tab-content<?= $activeTab === 'branding' ? ' active' : '' ?>">
|
|
||||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></h2>
|
|
||||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_branding_intro') ?></p>
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
|
||||||
<input type="hidden" name="form_type" value="branding">
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_brand_accent') ?></label>
|
|
||||||
<div class="color-field">
|
|
||||||
<input type="color" class="color-swatch" data-target="brand_accent" value="<?= htmlspecialchars($cs['brand_accent']) ?>">
|
|
||||||
<input type="text" id="brand_accent" name="brand_accent" value="<?= htmlspecialchars($cs['brand_accent']) ?>" placeholder="<?= Ui::DEFAULT_ACCENT ?>">
|
|
||||||
</div>
|
|
||||||
<div class="help-text"><?= __('settings_brand_accent_hint') ?></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_brand_accent_dark') ?></label>
|
|
||||||
<div class="color-field">
|
|
||||||
<input type="color" class="color-swatch" data-target="brand_accent_dark" value="<?= htmlspecialchars($cs['brand_accent_dark']) ?>">
|
|
||||||
<input type="text" id="brand_accent_dark" name="brand_accent_dark" value="<?= htmlspecialchars($cs['brand_accent_dark']) ?>" placeholder="<?= Ui::DEFAULT_ACCENT_DARK ?>">
|
|
||||||
</div>
|
|
||||||
<div class="help-text"><?= __('settings_brand_accent_dark_hint') ?></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_brand_gradient_from') ?></label>
|
|
||||||
<div class="color-field">
|
|
||||||
<input type="color" class="color-swatch" data-target="brand_gradient_from" value="<?= htmlspecialchars($cs['brand_gradient_from']) ?>">
|
|
||||||
<input type="text" id="brand_gradient_from" name="brand_gradient_from" value="<?= htmlspecialchars($cs['brand_gradient_from']) ?>">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_brand_gradient_to') ?></label>
|
|
||||||
<div class="color-field">
|
|
||||||
<input type="color" class="color-swatch" data-target="brand_gradient_to" value="<?= htmlspecialchars($cs['brand_gradient_to']) ?>">
|
|
||||||
<input type="text" id="brand_gradient_to" name="brand_gradient_to" value="<?= htmlspecialchars($cs['brand_gradient_to']) ?>">
|
|
||||||
</div>
|
|
||||||
<div class="help-text"><?= __('settings_brand_gradient_hint') ?></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_brand_radius') ?></label>
|
|
||||||
<select id="brand_radius" name="brand_radius">
|
|
||||||
<?php foreach ([6 => __('settings_brand_radius_sharp'), 14 => __('settings_brand_radius_default'), 20 => __('settings_brand_radius_round')] as $value => $label): ?>
|
|
||||||
<option value="<?= $value ?>" <?= (int)$cs['brand_radius'] === $value ? 'selected' : '' ?>><?= htmlspecialchars($label) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="brandPreview" style="margin-top: 8px;">
|
|
||||||
<h3><?= __('settings_brand_preview') ?></h3>
|
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:14px;">
|
|
||||||
<span class="brand-mark" style="width:38px;height:38px;"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
|
||||||
<button type="button" class="btn btn-primary"><i class="fas fa-ticket" aria-hidden="true"></i> <?= __('voucher_create_btn') ?></button>
|
|
||||||
<button type="button" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
|
||||||
<span class="badge badge-success"><?= __('status_valid') ?></span>
|
|
||||||
<span class="chip"><?= __('nav_vouchers') ?></span>
|
|
||||||
<a href="#" onclick="return false;"><?= __('settings_brand_link') ?></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top:16px;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login-Seite -->
|
|
||||||
<div id="tab-login" class="tab-content<?= $activeTab === 'login' ? ' active' : '' ?>">
|
|
||||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></h2>
|
|
||||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_login_intro') ?></p>
|
|
||||||
<form method="post" enctype="multipart/form-data">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
|
||||||
<input type="hidden" name="form_type" value="login">
|
|
||||||
|
|
||||||
<div class="checkbox-group" style="margin-bottom: 20px;">
|
|
||||||
<input type="checkbox" name="login_panel_enabled" id="login_panel_enabled" <?= $cs['login_panel_enabled'] === '1' ? 'checked' : '' ?>>
|
|
||||||
<label for="login_panel_enabled" style="margin:0;"><?= __('settings_login_panel') ?></label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_login_brand') ?></label>
|
|
||||||
<input type="text" name="login_brand_name" value="<?= htmlspecialchars($cs['login_brand_name']) ?>" placeholder="<?= htmlspecialchars($cs['app_title']) ?>">
|
|
||||||
<div class="help-text"><?= __('settings_login_brand_hint') ?></div>
|
|
||||||
</div>
|
|
||||||
<?= Ui::imageField('login_logo_url', __('settings_login_logo'), $cs['login_logo_url'], __('settings_login_logo_hint')) ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="section-divider">
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_login_claim_title') ?></label>
|
|
||||||
<input type="text" name="login_claim_title" value="<?= htmlspecialchars($cs['login_claim_title']) ?>" placeholder="<?= htmlspecialchars(__('auth_claim_title')) ?>">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_login_claim_text') ?></label>
|
|
||||||
<textarea name="login_claim_text" rows="3" placeholder="<?= htmlspecialchars(__('auth_claim_text')) ?>"><?= htmlspecialchars($cs['login_claim_text']) ?></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_login_features') ?></label>
|
|
||||||
<textarea name="login_features" rows="4" placeholder="<?= htmlspecialchars(__('auth_feature_1') . "\n" . __('auth_feature_2') . "\n" . __('auth_feature_3')) ?>"><?= htmlspecialchars($cs['login_features']) ?></textarea>
|
|
||||||
<div class="help-text"><?= __('settings_login_features_hint') ?></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_login_footer') ?></label>
|
|
||||||
<input type="text" name="login_footer" value="<?= htmlspecialchars($cs['login_footer']) ?>" placeholder="© <?= date('Y') ?> <?= htmlspecialchars($cs['app_title']) ?>">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="section-divider">
|
|
||||||
|
|
||||||
<?= Ui::imageField('login_bg_image', __('settings_login_bg_image'), $cs['login_bg_image'], __('settings_login_bg_image_hint')) ?>
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_login_bg_from') ?></label>
|
|
||||||
<div class="color-field">
|
|
||||||
<input type="color" class="color-swatch" data-target="login_bg_from"
|
|
||||||
value="<?= preg_match('/^#[0-9a-fA-F]{6}$/', $cs['login_bg_from']) ? htmlspecialchars($cs['login_bg_from']) : '#3b2f8f' ?>">
|
|
||||||
<input type="text" id="login_bg_from" name="login_bg_from" value="<?= htmlspecialchars($cs['login_bg_from']) ?>" placeholder="#3b2f8f">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_login_bg_to') ?></label>
|
|
||||||
<div class="color-field">
|
|
||||||
<input type="color" class="color-swatch" data-target="login_bg_to"
|
|
||||||
value="<?= preg_match('/^#[0-9a-fA-F]{6}$/', $cs['login_bg_to']) ? htmlspecialchars($cs['login_bg_to']) : '#6d5ce7' ?>">
|
|
||||||
<input type="text" id="login_bg_to" name="login_bg_to" value="<?= htmlspecialchars($cs['login_bg_to']) ?>" placeholder="#6d5ce7">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label><?= __('settings_login_overlay') ?></label>
|
|
||||||
<input type="number" name="login_bg_overlay" min="0" max="90" value="<?= (int)$cs['login_bg_overlay'] ?>">
|
|
||||||
<div class="help-text"><?= __('settings_login_overlay_hint') ?></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:16px;">
|
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
|
||||||
<a href="../login.php?preview=1" target="_blank" rel="noopener" class="btn btn-secondary">
|
|
||||||
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> <?= __('settings_login_preview') ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cron-Sync -->
|
<!-- Cron-Sync -->
|
||||||
<div id="tab-cron" class="tab-content<?= $activeTab === 'cron' ? ' active' : '' ?>">
|
<div id="tab-cron" class="tab-content">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></h2>
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> <?= __('settings_cron_what') ?></h4>
|
<h4><i class="fas fa-info-circle"></i> Was macht der Cron-Job?</h4>
|
||||||
<p>Der Cron-Job synchronisiert automatisch alle Voucher von Ihren UniFi Controllern in die lokale Datenbank.</p>
|
<p>Der Cron-Job synchronisiert automatisch alle Voucher von Ihren UniFi Controllern in die lokale Datenbank.</p>
|
||||||
</div>
|
</div>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<?php if (empty($cs['cron_token'])): ?>
|
<?php if (empty($cs['cron_token'])): ?>
|
||||||
<p style="color: var(--text-muted); margin-bottom: 15px;"><i class="fas fa-exclamation-triangle" style="color: var(--warning);" aria-hidden="true"></i> Kein Token konfiguriert.</p>
|
<p style="color: var(--text-muted); margin-bottom: 15px;"><i class="fas fa-exclamation-triangle" style="color: var(--warning);"></i> Kein Token konfiguriert.</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<button type="submit" name="generate_cron_token" class="btn btn-primary"><i class="fas fa-key" aria-hidden="true"></i> Token generieren</button>
|
<button type="submit" name="generate_cron_token" class="btn btn-primary"><i class="fas fa-key"></i> Token generieren</button>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="token-box" style="margin-bottom: 15px;"><?= htmlspecialchars($cs['cron_token']) ?></div>
|
<div class="token-box" style="margin-bottom: 15px;"><?= htmlspecialchars($cs['cron_token']) ?></div>
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
|
||||||
<button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy" aria-hidden="true"></i> Kopieren</button>
|
<button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy"></i> Kopieren</button>
|
||||||
<form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync" aria-hidden="true"></i> Neu generieren</button></form>
|
<form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync"></i> Neu generieren</button></form>
|
||||||
<form method="post" style="display:inline;" onsubmit="return confirm('<?= __('js_confirm_delete_token') ?>');"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="delete_cron_token" class="btn btn-secondary" style="color: var(--danger);"><i class="fas fa-trash" aria-hidden="true"></i> Löschen</button></form>
|
<form method="post" style="display:inline;" onsubmit="return confirm('<?= addslashes(__('confirm_delete_token')) ?>');"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="delete_cron_token" class="btn btn-secondary" style="color: var(--danger);"><i class="fas fa-trash"></i> Löschen</button></form>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -511,7 +382,7 @@ $adminBase = '';
|
||||||
<label>Cron-URL</label>
|
<label>Cron-URL</label>
|
||||||
<div style="display:flex;gap:10px;">
|
<div style="display:flex;gap:10px;">
|
||||||
<input type="text" id="cronUrl" value="<?= htmlspecialchars($cronUrl) ?>" readonly>
|
<input type="text" id="cronUrl" value="<?= htmlspecialchars($cronUrl) ?>" readonly>
|
||||||
<button onclick="copyToClipboard(document.getElementById('cronUrl').value)" class="btn btn-secondary"><i class="fas fa-copy" aria-hidden="true"></i></button>
|
<button onclick="copyToClipboard(document.getElementById('cronUrl').value)" class="btn btn-secondary"><i class="fas fa-copy"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="placeholder-info" style="background: var(--bg-hover); border-color: var(--border-color);">
|
<div class="placeholder-info" style="background: var(--bg-hover); border-color: var(--border-color);">
|
||||||
|
|
@ -522,7 +393,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
<?php if ($cs['cron_token']): ?>
|
<?php if ($cs['cron_token']): ?>
|
||||||
<div style="margin-top: 20px;">
|
<div style="margin-top: 20px;">
|
||||||
<button onclick="testCronJob()" class="btn btn-primary" id="testCronBtn"><i class="fas fa-play" aria-hidden="true"></i> Jetzt ausführen</button>
|
<button onclick="testCronJob()" class="btn btn-primary" id="testCronBtn"><i class="fas fa-play"></i> Jetzt ausführen</button>
|
||||||
<span id="testCronResult" style="margin-left: 15px;"></span>
|
<span id="testCronResult" style="margin-left: 15px;"></span>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
@ -531,24 +402,24 @@ $adminBase = '';
|
||||||
|
|
||||||
<!-- M365 -->
|
<!-- M365 -->
|
||||||
<div id="tab-m365" class="tab-content">
|
<div id="tab-m365" class="tab-content">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fab fa-microsoft" aria-hidden="true"></i> Microsoft 365</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fab fa-microsoft"></i> Microsoft 365</h2>
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> Azure AD App</h4>
|
<h4><i class="fas fa-info-circle"></i> Azure AD App</h4>
|
||||||
<p>Redirect URI: <strong><?= $protocol . '://' . $host . $scriptPath ?>/m365_callback.php</strong></p>
|
<p>Redirect URI: <strong><?= $protocol . '://' . $host . $scriptPath ?>/m365_callback.php</strong></p>
|
||||||
</div>
|
</div>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="m365">
|
<input type="hidden" name="form_type" value="m365">
|
||||||
<div class="form-group"><label>Client ID</label><input type="text" name="m365_client_id" value="<?= htmlspecialchars($cs['m365_client_id']) ?>"></div>
|
<div class="form-group"><label>Client ID</label><input type="text" name="m365_client_id" value="<?= htmlspecialchars($cs['m365_client_id']) ?>"></div>
|
||||||
<div class="form-group"><label>Client Secret</label><input type="password" name="m365_client_secret" value="<?= htmlspecialchars($cs['m365_client_secret']) ?>"></div>
|
<div class="form-group"><label>Client Secret</label><input type="password" name="m365_client_secret" placeholder="<?= $cs['m365_client_secret'] !== '' ? '••••••••' : '' ?>"><div class="help-text"><?= __('m365_secret_hint') ?></div></div>
|
||||||
<div class="form-group"><label>Tenant ID</label><input type="text" name="m365_tenant_id" value="<?= htmlspecialchars($cs['m365_tenant_id']) ?>"></div>
|
<div class="form-group"><label>Tenant ID</label><input type="text" name="m365_tenant_id" value="<?= htmlspecialchars($cs['m365_tenant_id']) ?>"></div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SMTP -->
|
<!-- SMTP -->
|
||||||
<div id="tab-smtp" class="tab-content<?= $activeTab === 'smtp' ? ' active' : '' ?>">
|
<div id="tab-smtp" class="tab-content">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope" aria-hidden="true"></i> SMTP</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope"></i> SMTP</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="smtp">
|
<input type="hidden" name="form_type" value="smtp">
|
||||||
|
|
@ -558,62 +429,63 @@ $adminBase = '';
|
||||||
<div class="form-group"><label>Port</label><input type="number" name="smtp_port" value="<?= htmlspecialchars($cs['smtp_port']) ?>"></div>
|
<div class="form-group"><label>Port</label><input type="number" name="smtp_port" value="<?= htmlspecialchars($cs['smtp_port']) ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label>Verschlüsselung</label><select name="smtp_encryption"><option value="tls" <?= $cs['smtp_encryption']==='tls'?'selected':'' ?>>TLS</option><option value="ssl" <?= $cs['smtp_encryption']==='ssl'?'selected':'' ?>>SSL</option><option value="none" <?= $cs['smtp_encryption']==='none'?'selected':'' ?>>Keine</option></select></div>
|
<div class="form-group"><label>Verschlüsselung</label><select name="smtp_encryption"><option value="tls" <?= $cs['smtp_encryption']==='tls'?'selected':'' ?>>TLS</option><option value="ssl" <?= $cs['smtp_encryption']==='ssl'?'selected':'' ?>>SSL</option><option value="none" <?= $cs['smtp_encryption']==='none'?'selected':'' ?>>Keine</option></select></div>
|
||||||
|
<div class="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="smtp_verify_ssl" id="smtp_verify_ssl" <?= $cs['smtp_verify_ssl'] == '1' ? 'checked' : '' ?>><label for="smtp_verify_ssl" style="margin:0;"><?= __('smtp_verify_ssl') ?></label></div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group"><label>Benutzername</label><input type="text" name="smtp_username" value="<?= htmlspecialchars($cs['smtp_username']) ?>"></div>
|
<div class="form-group"><label>Benutzername</label><input type="text" name="smtp_username" value="<?= htmlspecialchars($cs['smtp_username']) ?>"></div>
|
||||||
<div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="<?= __('settings_leave_empty') ?>"></div>
|
<div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="Leer = nicht ändern"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group"><label>Absender E-Mail</label><input type="email" name="smtp_from_email" value="<?= htmlspecialchars($cs['smtp_from_email']) ?>"></div>
|
<div class="form-group"><label>Absender E-Mail</label><input type="email" name="smtp_from_email" value="<?= htmlspecialchars($cs['smtp_from_email']) ?>"></div>
|
||||||
<div class="form-group"><label><?= __('settings_smtp_from_name') ?></label><input type="text" name="smtp_from_name" value="<?= htmlspecialchars($cs['smtp_from_name']) ?>"></div>
|
<div class="form-group"><label>Absender Name</label><input type="text" name="smtp_from_name" value="<?= htmlspecialchars($cs['smtp_from_name']) ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">SMTP testen</h3>
|
<h3 style="margin-bottom:15px; color: var(--text-primary);">SMTP testen</h3>
|
||||||
<div style="display:flex;gap:10px;align-items:flex-end;">
|
<div style="display:flex;gap:10px;align-items:flex-end;">
|
||||||
<div style="flex:1;"><label>Test-E-Mail senden an</label><input type="email" id="smtpTestEmail" placeholder="empfaenger@example.com" style="margin-top:6px;"></div>
|
<div style="flex:1;"><label>Test-E-Mail senden an</label><input type="email" id="smtpTestEmail" placeholder="empfaenger@example.com" style="margin-top:6px;"></div>
|
||||||
<button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn"><i class="fas fa-paper-plane" aria-hidden="true"></i> <?= __('btn_test') ?></button>
|
<button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn"><i class="fas fa-paper-plane"></i> <?= __('btn_test') ?></button>
|
||||||
</div>
|
</div>
|
||||||
<span id="smtpTestResult" style="display:block;margin-top:10px;font-size:13px;"></span>
|
<span id="smtpTestResult" style="display:block;margin-top:10px;font-size:13px;"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- E-Mail Templates -->
|
<!-- E-Mail Templates -->
|
||||||
<div id="tab-templates_email" class="tab-content<?= $activeTab === 'templates_email' ? ' active' : '' ?>">
|
<div id="tab-templates_email" class="tab-content">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt" aria-hidden="true"></i> E-Mail Templates</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt"></i> E-Mail Templates</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="templates">
|
<input type="hidden" name="form_type" value="templates">
|
||||||
<div class="form-group"><label>System-URL</label><input type="url" name="system_url" value="<?= htmlspecialchars($cs['system_url']) ?>"><div class="help-text">Auto: <code><?= $autoDetectedUrl ?></code></div></div>
|
<div class="form-group"><label>System-URL</label><input type="url" name="system_url" value="<?= htmlspecialchars($cs['system_url']) ?>"><div class="help-text">Auto: <code><?= $autoDetectedUrl ?></code></div></div>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<h3 style="margin-bottom:15px; color: var(--text-primary);"><?= __('settings_tpl_voucher_mail') ?></h3>
|
<h3 style="margin-bottom:15px; color: var(--text-primary);">Voucher E-Mail</h3>
|
||||||
<div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{VOUCHER_CARD}</code><code>{VOUCHER_CODE}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div><p class="help-text"><?= __('settings_card_hint') ?></p></div>
|
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{VOUCHER_CODE}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div></div>
|
||||||
<div class="form-group"><label>Betreff</label><input type="text" name="email_voucher_subject" value="<?= htmlspecialchars($cs['email_voucher_subject']) ?>"></div>
|
<div class="form-group"><label>Betreff</label><input type="text" name="email_voucher_subject" value="<?= htmlspecialchars($cs['email_voucher_subject']) ?>"></div>
|
||||||
<div class="form-group"><label>E-Mail Text</label><textarea name="email_voucher_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_voucher_body']) ?></textarea></div>
|
<div class="form-group"><label>E-Mail Text</label><textarea name="email_voucher_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_voucher_body']) ?></textarea></div>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<h3 style="margin-bottom:15px; color: var(--text-primary);"><?= __('settings_tpl_user_notify') ?></h3>
|
<h3 style="margin-bottom:15px; color: var(--text-primary);">Benutzer-Benachrichtigung</h3>
|
||||||
<div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{USER_NAME}</code><code>{CHANGES}</code><code>{APP_TITLE}</code><code>{SYSTEM_URL}</code></div></div>
|
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{USER_NAME}</code><code>{CHANGES}</code><code>{APP_TITLE}</code><code>{SYSTEM_URL}</code></div></div>
|
||||||
<div class="form-group"><label>Betreff</label><input type="text" name="email_user_notification_subject" value="<?= htmlspecialchars($cs['email_user_notification_subject']) ?>"></div>
|
<div class="form-group"><label>Betreff</label><input type="text" name="email_user_notification_subject" value="<?= htmlspecialchars($cs['email_user_notification_subject']) ?>"></div>
|
||||||
<div class="form-group"><label>E-Mail Text</label><textarea name="email_user_notification_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_user_notification_body']) ?></textarea></div>
|
<div class="form-group"><label>E-Mail Text</label><textarea name="email_user_notification_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_user_notification_body']) ?></textarea></div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System -->
|
<!-- System -->
|
||||||
<div id="tab-system" class="tab-content<?= $activeTab === 'system' ? ' active' : '' ?>">
|
<div id="tab-system" class="tab-content">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs" aria-hidden="true"></i> System & Erweitert</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs"></i> System & Erweitert</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<input type="hidden" name="form_type" value="system">
|
<input type="hidden" name="form_type" value="system">
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> WYSIWYG-Editor</h4>
|
<h4><i class="fas fa-info-circle"></i> TinyMCE API Key</h4>
|
||||||
<p><?= __('settings_editor_hint') ?> <code>assets/vendor/</code> <?= __('settings_editor_hint2') ?></p>
|
<p>Kostenlosen API Key: <a href="https://www.tiny.cloud/auth/signup/" target="_blank" rel="noopener" style="color:#0066cc;">tiny.cloud/signup</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group"><label>TinyMCE API Key</label><input type="text" name="tinymce_api_key" value="<?= htmlspecialchars($cs['tinymce_api_key']) ?>" placeholder="your-api-key-here"><div class="help-text">Für WYSIWYG-Editor in Anleitungen</div></div>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">Druck-Template</h3>
|
<h3 style="margin-bottom:15px; color: var(--text-primary);">Druck-Template</h3>
|
||||||
<div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{QR_CODE}</code><code>{VOUCHER_CODE}</code><code>{EXPIRY_DATE}</code><code>{EXPIRY_TIME}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div><p class="help-text"><?= __('settings_qr_hint') ?></p></div>
|
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{VOUCHER_CODE}</code><code>{EXPIRY_DATE}</code><code>{EXPIRY_TIME}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div></div>
|
||||||
<div class="form-group"><label><?= __('settings_print_template') ?></label><textarea name="print_template" class="tinymce-editor" style="min-height:250px;"><?= htmlspecialchars($cs['print_template']) ?></textarea></div>
|
<div class="form-group"><label>HTML Template für Voucher-Druck</label><textarea name="print_template" class="tinymce-editor" style="min-height:250px;"><?= htmlspecialchars($cs['print_template']) ?></textarea></div>
|
||||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||||
</form>
|
</form>
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">System-Information</h3>
|
<h3 style="margin-bottom:15px; color: var(--text-primary);">System-Information</h3>
|
||||||
|
|
@ -625,19 +497,19 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Passwort -->
|
<!-- Passwort -->
|
||||||
<div id="tab-password" class="tab-content<?= $activeTab === 'password' ? ' active' : '' ?>">
|
<div id="tab-password" class="tab-content">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key"></i> <?= __('settings_tab_password') ?></h2>
|
||||||
<form method="post" style="max-width:500px;">
|
<form method="post" style="max-width:500px;">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<div class="form-group"><label><?= __('settings_pw_current') ?></label><input type="password" name="current_password" required></div>
|
<div class="form-group"><label><?= __('settings_pw_current') ?></label><input type="password" name="current_password" required></div>
|
||||||
<div class="form-group"><label><?= __('settings_pw_new') ?></label><input type="password" name="new_password" required minlength="8"><div class="help-text"><?= __('settings_pw_minlength') ?></div></div>
|
<div class="form-group"><label><?= __('settings_pw_new') ?></label><input type="password" name="new_password" required minlength="8"><div class="help-text"><?= __('settings_pw_minlength') ?></div></div>
|
||||||
<div class="form-group"><label><?= __('settings_pw_confirm') ?></label><input type="password" name="confirm_password" required></div>
|
<div class="form-group"><label><?= __('settings_pw_confirm') ?></label><input type="password" name="confirm_password" required></div>
|
||||||
<button type="submit" name="change_password" class="btn btn-primary"><i class="fas fa-lock" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button>
|
<button type="submit" name="change_password" class="btn btn-primary"><i class="fas fa-lock"></i> <?= __('settings_tab_password') ?></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</div><!-- main-content -->
|
||||||
|
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -653,43 +525,7 @@ document.querySelectorAll('.tab-button').forEach(btn => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Branding-Vorschau live faerben
|
// Restore tab from hash
|
||||||
function updateBrandPreview() {
|
|
||||||
const preview = document.getElementById('brandPreview');
|
|
||||||
if (!preview) return;
|
|
||||||
const accent = (document.getElementById('brand_accent') || {}).value || '';
|
|
||||||
const from = (document.getElementById('brand_gradient_from') || {}).value || '';
|
|
||||||
const to = (document.getElementById('brand_gradient_to') || {}).value || '';
|
|
||||||
const radius = (document.getElementById('brand_radius') || {}).value || '14';
|
|
||||||
if (/^#[0-9a-fA-F]{6}$/.test(accent)) {
|
|
||||||
preview.style.setProperty('--accent', accent);
|
|
||||||
preview.style.setProperty('--accent-hover', `color-mix(in srgb, ${accent} 84%, #000)`);
|
|
||||||
preview.style.setProperty('--accent-soft', `color-mix(in srgb, ${accent} 12%, #fff)`);
|
|
||||||
preview.style.setProperty('--accent-border', `color-mix(in srgb, ${accent} 32%, #fff)`);
|
|
||||||
}
|
|
||||||
if (/^#[0-9a-fA-F]{6}$/.test(from) && /^#[0-9a-fA-F]{6}$/.test(to)) {
|
|
||||||
preview.style.setProperty('--brand-gradient', `linear-gradient(135deg, ${from} 0%, ${to} 100%)`);
|
|
||||||
}
|
|
||||||
preview.style.setProperty('--r-lg', radius + 'px');
|
|
||||||
}
|
|
||||||
['brand_accent', 'brand_gradient_from', 'brand_gradient_to', 'brand_radius'].forEach(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.addEventListener('input', updateBrandPreview);
|
|
||||||
if (el) el.addEventListener('change', updateBrandPreview);
|
|
||||||
});
|
|
||||||
updateBrandPreview();
|
|
||||||
|
|
||||||
// Farbwähler und Hex-Feld synchron halten
|
|
||||||
document.querySelectorAll('.color-swatch').forEach(swatch => {
|
|
||||||
const field = document.getElementById(swatch.dataset.target);
|
|
||||||
if (!field) return;
|
|
||||||
swatch.addEventListener('input', () => { field.value = swatch.value; updateBrandPreview(); });
|
|
||||||
field.addEventListener('input', () => {
|
|
||||||
if (/^#[0-9a-fA-F]{6}$/.test(field.value.trim())) swatch.value = field.value.trim();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tab aus Anker uebernehmen (der Query-Parameter wird serverseitig gesetzt)
|
|
||||||
window.addEventListener('DOMContentLoaded', function() {
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
const hash = location.hash.substring(1);
|
const hash = location.hash.substring(1);
|
||||||
if (hash) {
|
if (hash) {
|
||||||
|
|
@ -703,9 +539,9 @@ async function testSmtp() {
|
||||||
const email = document.getElementById('smtpTestEmail').value.trim();
|
const email = document.getElementById('smtpTestEmail').value.trim();
|
||||||
const btn = document.getElementById('smtpTestBtn');
|
const btn = document.getElementById('smtpTestBtn');
|
||||||
const result = document.getElementById('smtpTestResult');
|
const result = document.getElementById('smtpTestResult');
|
||||||
if (!email) { result.textContent = '<?= __('js_enter_email') ?>'; return; }
|
if (!email) { result.textContent = 'Bitte E-Mail eingeben.'; return; }
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('ajax_smtp_test', '1');
|
fd.append('ajax_smtp_test', '1');
|
||||||
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
|
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
|
||||||
|
|
@ -715,52 +551,38 @@ async function testSmtp() {
|
||||||
result.textContent = data.message;
|
result.textContent = data.message;
|
||||||
result.style.color = data.success ? 'var(--success)' : 'var(--danger)';
|
result.style.color = data.success ? 'var(--success)' : 'var(--danger)';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fas fa-paper-plane" aria-hidden="true"></i> Testen';
|
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Testen';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testCronJob() {
|
async function testCronJob() {
|
||||||
const btn = document.getElementById('testCronBtn');
|
const btn = document.getElementById('testCronBtn');
|
||||||
const result = document.getElementById('testCronResult');
|
const result = document.getElementById('testCronResult');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= __('js_running') ?>';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Läuft...';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('../cron_sync.php?token=<?= htmlspecialchars($cs['cron_token']) ?>');
|
const res = await fetch('../cron_sync.php?token=<?= htmlspecialchars($cs['cron_token']) ?>');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
result.innerHTML = data.success
|
result.innerHTML = data.success
|
||||||
? `<span style="color:var(--success)"><i class="fas fa-check-circle" aria-hidden="true"></i> ${data.message}</span>`
|
? `<span style="color:var(--success)"><i class="fas fa-check-circle"></i> ${data.message}</span>`
|
||||||
: `<span style="color:var(--danger)"><i class="fas fa-times-circle" aria-hidden="true"></i> ${data.message}</span>`;
|
: `<span style="color:var(--danger)"><i class="fas fa-times-circle"></i> ${data.message}</span>`;
|
||||||
if (data.success) showToast('success', 'Cron ausgeführt', data.message);
|
if (data.success) showToast('success', 'Cron ausgeführt', data.message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result.innerHTML = `<span style="color:var(--danger)"><?= __('js_error') ?>: ${e.message}</span>`;
|
result.innerHTML = `<span style="color:var(--danger)">Fehler: ${e.message}</span>`;
|
||||||
}
|
}
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fas fa-play" aria-hidden="true"></i> <?= __('js_run_now') ?>';
|
btn.innerHTML = '<i class="fas fa-play"></i> Jetzt ausführen';
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTinyMCE() {
|
function initTinyMCE() {
|
||||||
if (typeof tinymce === 'undefined') return;
|
tinymce.init({
|
||||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
||||||
const config = {
|
|
||||||
selector: '.tinymce-editor',
|
selector: '.tinymce-editor',
|
||||||
height: 350,
|
height: 350,
|
||||||
menubar: false,
|
menubar: false,
|
||||||
plugins: ['advlist','autolink','lists','link','searchreplace','visualblocks','code','fullscreen','table','help','wordcount'],
|
plugins: ['advlist','autolink','lists','link','searchreplace','visualblocks','code','fullscreen','table','help','wordcount'],
|
||||||
toolbar: 'undo redo | blocks | bold italic forecolor | alignleft aligncenter alignright | bullist numlist | removeformat | help',
|
toolbar: 'undo redo | blocks | bold italic forecolor | alignleft aligncenter alignright | bullist numlist | removeformat | help',
|
||||||
content_style: "body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.6; }",
|
content_style: 'body { font-family: -apple-system, sans-serif; font-size: 14px; line-height: 1.6; }',
|
||||||
skin: dark ? 'oxide-dark' : 'oxide',
|
|
||||||
content_css: dark ? 'dark' : 'default',
|
|
||||||
branding: false, promotion: false
|
branding: false, promotion: false
|
||||||
};
|
});
|
||||||
if (window.TINYMCE_BASE_URL) {
|
|
||||||
tinymce.baseURL = window.TINYMCE_BASE_URL;
|
|
||||||
config.base_url = window.TINYMCE_BASE_URL;
|
|
||||||
config.suffix = '.min';
|
|
||||||
if (document.documentElement.lang === 'de') {
|
|
||||||
config.language = 'de';
|
|
||||||
config.language_url = window.TINYMCE_BASE_URL + '/langs/de.js';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tinymce.init(config);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
231
admin/sites.php
|
|
@ -8,6 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
require_once __DIR__ . '/../includes/UniFiController.php';
|
require_once __DIR__ . '/../includes/UniFiController.php';
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
require_once __DIR__ . '/../includes/Helpers.php';
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
$auth->requireAdmin();
|
$auth->requireAdmin();
|
||||||
|
|
@ -18,6 +19,32 @@ I18n::init();
|
||||||
$error = '';
|
$error = '';
|
||||||
$success = '';
|
$success = '';
|
||||||
|
|
||||||
|
// AJAX: Verbindungstest mit gespeicherten Zugangsdaten (Health-Check pro Site)
|
||||||
|
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['ajax_test_site'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
|
echo json_encode(['success' => false, 'message' => __('error_csrf')]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$site = $db->fetchOne("SELECT * FROM sites WHERE id=?", [(int)$_POST['ajax_test_site']]);
|
||||||
|
if (!$site) {
|
||||||
|
echo json_encode(['success' => false, 'message' => __('error_site_not_found')]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$test = UniFiController::testConnection(
|
||||||
|
$site['unifi_controller_url'],
|
||||||
|
$site['unifi_username'],
|
||||||
|
Crypto::decrypt($site['unifi_password']),
|
||||||
|
$site['site_id'],
|
||||||
|
$site['ssl_verify'] ?? 0
|
||||||
|
);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => $test === true,
|
||||||
|
'message' => $test === true ? __('site_test_ok') : __('site_test_fail') . ': ' . $test,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Edit site
|
// Edit site
|
||||||
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
|
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
|
||||||
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) {
|
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) {
|
||||||
|
|
@ -31,18 +58,27 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
|
||||||
$username = trim($_POST['username']);
|
$username = trim($_POST['username']);
|
||||||
$password = $_POST['password'];
|
$password = $_POST['password'];
|
||||||
$publicAccess = isset($_POST['public_access']) ? 1 : 0;
|
$publicAccess = isset($_POST['public_access']) ? 1 : 0;
|
||||||
|
$sslVerify = isset($_POST['ssl_verify']) ? 1 : 0;
|
||||||
if (empty($name)||empty($siteIdStr)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
|
if (empty($name)||empty($siteIdStr)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
|
||||||
if (!empty($password)) {
|
if (!empty($password)) {
|
||||||
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr);
|
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr,$sslVerify);
|
||||||
if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test);
|
if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
|
||||||
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=? WHERE id=?",
|
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=?,ssl_verify=? WHERE id=?",
|
||||||
[$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$siteId]);
|
[$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify,$siteId]);
|
||||||
} else {
|
} else {
|
||||||
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=? WHERE id=?",
|
// Auch ohne Passwortaenderung testen (mit gespeichertem Passwort) –
|
||||||
[$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$siteId]);
|
// sonst fallen Tippfehler in URL/Username erst beim naechsten Voucher auf.
|
||||||
|
$stored = $db->fetchOne("SELECT unifi_password FROM sites WHERE id=?", [$siteId]);
|
||||||
|
if (!$stored) throw new Exception(__('error_site_not_found'));
|
||||||
|
$test = UniFiController::testConnection($controllerUrl,$username,Crypto::decrypt($stored['unifi_password']),$siteIdStr,$sslVerify);
|
||||||
|
if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
|
||||||
|
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=?,ssl_verify=? WHERE id=?",
|
||||||
|
[$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$sslVerify,$siteId]);
|
||||||
}
|
}
|
||||||
$auth->writeAuditLog($_SESSION['user_id'],'site_edit','site',$siteId,"Site {$name} aktualisiert");
|
$auth->writeAuditLog($_SESSION['user_id'],'site_edit','site',$siteId,"Site {$name} aktualisiert");
|
||||||
$success = __('sites_updated');
|
flashSet(__('sites_updated'));
|
||||||
|
header('Location: sites.php');
|
||||||
|
exit;
|
||||||
} catch (Exception $e) { $error = $e->getMessage(); }
|
} catch (Exception $e) { $error = $e->getMessage(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -59,38 +95,49 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_site'])) {
|
||||||
$username = trim($_POST['username']);
|
$username = trim($_POST['username']);
|
||||||
$password = $_POST['password'];
|
$password = $_POST['password'];
|
||||||
$publicAccess = isset($_POST['public_access']) ? 1 : 0;
|
$publicAccess = isset($_POST['public_access']) ? 1 : 0;
|
||||||
|
$sslVerify = isset($_POST['ssl_verify']) ? 1 : 0;
|
||||||
if (empty($name)||empty($siteId)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
|
if (empty($name)||empty($siteId)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
|
||||||
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId);
|
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId,$sslVerify);
|
||||||
if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test);
|
if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
|
||||||
$newId = $db->execute("INSERT INTO sites (name,site_id,unifi_controller_url,unifi_username,unifi_password,public_access) VALUES (?,?,?,?,?,?)",
|
$newId = $db->execute("INSERT INTO sites (name,site_id,unifi_controller_url,unifi_username,unifi_password,public_access,ssl_verify) VALUES (?,?,?,?,?,?,?)",
|
||||||
[$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess]);
|
[$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify]);
|
||||||
$auth->writeAuditLog($_SESSION['user_id'],'site_create','site',$newId,"Site {$name} erstellt");
|
$auth->writeAuditLog($_SESSION['user_id'],'site_create','site',$newId,"Site {$name} erstellt");
|
||||||
$success = __('sites_added');
|
flashSet(__('sites_added'));
|
||||||
|
header('Location: sites.php');
|
||||||
|
exit;
|
||||||
} catch (Exception $e) { $error = $e->getMessage(); }
|
} catch (Exception $e) { $error = $e->getMessage(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete site
|
// Delete site (POST + PRG)
|
||||||
if (isset($_GET['delete']) && isset($_GET['token'])) {
|
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_site'])) {
|
||||||
if ($auth->validateCsrfToken($_GET['token'])) {
|
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$delId = (int)$_GET['delete'];
|
$delId = (int)$_POST['delete_site'];
|
||||||
$db->query("DELETE FROM sites WHERE id=?", [$delId]);
|
$db->query("DELETE FROM sites WHERE id=?", [$delId]);
|
||||||
$auth->writeAuditLog($_SESSION['user_id'],'site_delete','site',$delId,'Site gelöscht');
|
$auth->writeAuditLog($_SESSION['user_id'],'site_delete','site',$delId,'Site gelöscht');
|
||||||
$success = __('sites_deleted');
|
flashSet(__('sites_deleted'));
|
||||||
|
header('Location: sites.php');
|
||||||
|
exit;
|
||||||
} else { $error = __('error_csrf'); }
|
} else { $error = __('error_csrf'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle site
|
// Toggle site (POST + PRG)
|
||||||
if (isset($_GET['toggle']) && isset($_GET['token'])) {
|
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_site'])) {
|
||||||
if ($auth->validateCsrfToken($_GET['token'])) {
|
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_GET['toggle']]);
|
$site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_POST['toggle_site']]);
|
||||||
if ($site) {
|
if ($site) {
|
||||||
$db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_GET['toggle']]);
|
$db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_POST['toggle_site']]);
|
||||||
$success = 'Site-Status aktualisiert!';
|
flashSet(__('sites_status_updated'));
|
||||||
|
header('Location: sites.php');
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
} else { $error = __('error_csrf'); }
|
} else { $error = __('error_csrf'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (empty($success) && empty($error) && ($flash = flashGet())) {
|
||||||
|
$success = $flash['message'];
|
||||||
|
}
|
||||||
|
|
||||||
$sites = $db->fetchAll("SELECT * FROM sites ORDER BY name");
|
$sites = $db->fetchAll("SELECT * FROM sites ORDER BY name");
|
||||||
$currentPage = 'sites';
|
$currentPage = 'sites';
|
||||||
?>
|
?>
|
||||||
|
|
@ -101,24 +148,61 @@ $currentPage = 'sites';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('sites_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('sites_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.sites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 18px; }
|
||||||
|
.site-card { background: var(--bg-card); border: 2px solid var(--border-color); border-radius: 14px; padding: 20px; transition: border-color .2s, box-shadow .2s; }
|
||||||
|
.site-card:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(102,126,234,.15); }
|
||||||
|
.site-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 14px; }
|
||||||
|
.site-name { font-size: 17px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
|
||||||
|
.site-id-label { font-size: 12px; color: var(--text-muted); font-family: monospace; }
|
||||||
|
.site-info { margin: 14px 0; font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.site-info-item { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
|
||||||
|
.site-actions { display: flex; gap: 7px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color); flex-wrap: wrap; }
|
||||||
|
.btn { padding: 8px 15px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
||||||
|
.btn-secondary:hover { background: var(--border-color); }
|
||||||
|
.btn-danger { background: var(--danger); color: white; }
|
||||||
|
.btn-success { background: var(--success); color: white; }
|
||||||
|
.btn-sm { padding: 6px 11px; font-size: 12px; }
|
||||||
|
.modal { display: none; position: fixed; inset: 0; background: var(--modal-overlay); z-index: 1000; align-items: center; justify-content: center; }
|
||||||
|
.modal.active { display: flex; }
|
||||||
|
.modal-content { background: var(--bg-card); border-radius: 14px; max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border-color); }
|
||||||
|
.modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.modal-title { font-size: 19px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
|
||||||
|
.modal-body { padding: 22px 25px; }
|
||||||
|
.form-group { margin-bottom: 17px; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
||||||
|
input[type="text"], input[type="password"], input[type="url"] { width: 100%; padding: 11px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color .2s; }
|
||||||
|
input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.checkbox-group { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.checkbox-group input { width: auto; }
|
||||||
|
.empty-card { background: var(--bg-card); border-radius: 14px; border: 1px solid var(--border-color); padding: 60px 20px; text-align: center; color: var(--text-muted); }
|
||||||
|
@media(max-width:768px){ .main-content{ margin-left:0!important; } .form-grid{ grid-template-columns:1fr; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title"><?= __('sites_title') ?></h1>
|
<h1 class="page-title"><?= __('sites_title') ?></h1>
|
||||||
<button onclick="openModal()" class="btn btn-primary">
|
<button onclick="openModal()" class="btn btn-primary">
|
||||||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?>
|
<i class="fas fa-plus"></i> <?= __('sites_add') ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
|
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($success): ?>
|
<?php if ($success): ?>
|
||||||
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
|
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (empty($sites)): ?>
|
<?php if (empty($sites)): ?>
|
||||||
<div class="empty-card">
|
<div class="empty-card">
|
||||||
<i class="fas fa-map-marker-alt" style="font-size:48px;margin-bottom:20px;opacity:.3;display:block;" aria-hidden="true"></i>
|
<i class="fas fa-map-marker-alt" style="font-size:48px;margin-bottom:20px;opacity:.3;display:block;"></i>
|
||||||
<p><?= __('sites_none') ?></p>
|
<p><?= __('sites_none') ?></p>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
@ -132,51 +216,62 @@ $currentPage = 'sites';
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<?php if ($site['is_active']): ?>
|
<?php if ($site['is_active']): ?>
|
||||||
<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_active') ?></span>
|
<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge badge-warning"><i class="fas fa-pause" aria-hidden="true"></i> <?= __('status_inactive') ?></span>
|
<span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($site['public_access']): ?>
|
<?php if ($site['public_access']): ?>
|
||||||
<span class="badge badge-info"><i class="fas fa-globe" aria-hidden="true"></i> <?= __('status_public') ?></span>
|
<span class="badge badge-info"><i class="fas fa-globe"></i> <?= __('status_public') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-info">
|
<div class="site-info">
|
||||||
<div class="site-info-item">
|
<div class="site-info-item">
|
||||||
<i class="fas fa-server" style="color:var(--accent);width:16px;" aria-hidden="true"></i>
|
<i class="fas fa-server" style="color:var(--accent);width:16px;"></i>
|
||||||
<span style="word-break:break-all;"><?= htmlspecialchars($site['unifi_controller_url']) ?></span>
|
<span style="word-break:break-all;"><?= htmlspecialchars($site['unifi_controller_url']) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-info-item">
|
<div class="site-info-item">
|
||||||
<i class="fas fa-user" style="color:var(--accent);width:16px;" aria-hidden="true"></i>
|
<i class="fas fa-user" style="color:var(--accent);width:16px;"></i>
|
||||||
<span><?= htmlspecialchars($site['unifi_username']) ?></span>
|
<span><?= htmlspecialchars($site['unifi_username']) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-info-item">
|
<div class="site-info-item">
|
||||||
<i class="fas fa-clock" style="color:var(--text-muted);width:16px;" aria-hidden="true"></i>
|
<i class="fas fa-clock" style="color:var(--text-muted);width:16px;"></i>
|
||||||
<span style="color:var(--text-muted);"><?= date('d.m.Y', strtotime($site['created_at'])) ?></span>
|
<span style="color:var(--text-muted);"><?= date('d.m.Y', strtotime($site['created_at'])) ?></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-actions">
|
<div class="site-actions">
|
||||||
<button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>)"
|
<button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>, <?= (int)($site['ssl_verify'] ?? 0) ?>)"
|
||||||
class="btn btn-secondary btn-sm">
|
class="btn btn-secondary btn-sm">
|
||||||
<i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?>
|
<i class="fas fa-edit"></i> <?= __('btn_edit') ?>
|
||||||
</button>
|
</button>
|
||||||
<a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<form method="post" style="display:inline;">
|
||||||
class="btn btn-secondary btn-sm">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i>
|
<input type="hidden" name="toggle_site" value="<?= $site['id'] ?>">
|
||||||
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
|
<button type="submit" class="btn btn-secondary btn-sm">
|
||||||
</a>
|
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>"></i>
|
||||||
<a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
|
||||||
class="btn btn-danger-soft btn-sm"
|
</button>
|
||||||
onclick="return confirm('<?= __('js_confirm_delete_site') ?>')">
|
</form>
|
||||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
<button type="button" class="btn btn-secondary btn-sm" onclick="testSite(<?= $site['id'] ?>, this)"
|
||||||
</a>
|
title="<?= __('site_test_btn') ?>" aria-label="<?= __('site_test_btn') ?>">
|
||||||
|
<i class="fas fa-plug"></i>
|
||||||
|
</button>
|
||||||
|
<form method="post" style="display:inline;"
|
||||||
|
onsubmit="return confirm('<?= addslashes(__('confirm_delete_site')) ?>')">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
<input type="hidden" name="delete_site" value="<?= $site['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm"
|
||||||
|
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</main>
|
</div><!-- /main-content -->
|
||||||
|
|
||||||
<!-- Add Site Modal -->
|
<!-- Add Site Modal -->
|
||||||
<div id="addSiteModal" class="modal">
|
<div id="addSiteModal" class="modal">
|
||||||
|
|
@ -196,7 +291,7 @@ $currentPage = 'sites';
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><?= __('sites_site_id') ?></label>
|
<label><?= __('sites_site_id') ?></label>
|
||||||
<input type="text" name="site_id" required placeholder="z.B. default">
|
<input type="text" name="site_id" required placeholder="z.B. default">
|
||||||
<small style="color:var(--text-muted);font-size:12px;"><?= __('sites_id_hint') ?></small>
|
<small style="color:var(--text-muted);font-size:12px;">Zu finden in der UniFi Controller URL</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><?= __('sites_controller') ?></label>
|
<label><?= __('sites_controller') ?></label>
|
||||||
|
|
@ -217,9 +312,14 @@ $currentPage = 'sites';
|
||||||
<input type="checkbox" id="add_public" name="public_access">
|
<input type="checkbox" id="add_public" name="public_access">
|
||||||
<label for="add_public" style="margin:0;"><?= __('sites_public') ?></label>
|
<label for="add_public" style="margin:0;"><?= __('sites_public') ?></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<input type="checkbox" id="add_ssl_verify" name="ssl_verify">
|
||||||
|
<label for="add_ssl_verify" style="margin:0;"><?= __('sites_ssl_verify') ?></label>
|
||||||
|
</div>
|
||||||
|
<small style="color:var(--text-muted);font-size:12px;display:block;margin-top:-10px;margin-bottom:14px;"><?= __('sites_ssl_verify_hint') ?></small>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn">
|
<button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn">
|
||||||
<i class="fas fa-save" aria-hidden="true"></i> <?= __('sites_add') ?>
|
<i class="fas fa-save"></i> <?= __('sites_add') ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="closeModal('addSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('addSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -259,7 +359,7 @@ $currentPage = 'sites';
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><?= __('sites_password_edit') ?></label>
|
<label><?= __('sites_password_edit') ?></label>
|
||||||
<input type="password" id="edit_password" name="password" placeholder="<?= __('sites_pw_unchanged') ?>">
|
<input type="password" id="edit_password" name="password" placeholder="Leer lassen = nicht ändern">
|
||||||
<small style="color:var(--text-muted);font-size:12px;"><?= __('sites_password_hint') ?></small>
|
<small style="color:var(--text-muted);font-size:12px;"><?= __('sites_password_hint') ?></small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -267,9 +367,14 @@ $currentPage = 'sites';
|
||||||
<input type="checkbox" id="edit_public_access" name="public_access">
|
<input type="checkbox" id="edit_public_access" name="public_access">
|
||||||
<label for="edit_public_access" style="margin:0;"><?= __('sites_public') ?></label>
|
<label for="edit_public_access" style="margin:0;"><?= __('sites_public') ?></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<input type="checkbox" id="edit_ssl_verify" name="ssl_verify">
|
||||||
|
<label for="edit_ssl_verify" style="margin:0;"><?= __('sites_ssl_verify') ?></label>
|
||||||
|
</div>
|
||||||
|
<small style="color:var(--text-muted);font-size:12px;display:block;margin-top:-10px;margin-bottom:14px;"><?= __('sites_ssl_verify_hint') ?></small>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn">
|
<button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn">
|
||||||
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
|
<i class="fas fa-save"></i> <?= __('btn_save') ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="closeModal('editSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('editSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -278,13 +383,13 @@ $currentPage = 'sites';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast-container" role="status" aria-live="polite"></div>
|
<div id="toast-container"></div>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function openModal() { document.getElementById('addSiteModal').classList.add('active'); }
|
function openModal() { document.getElementById('addSiteModal').classList.add('active'); }
|
||||||
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
||||||
|
|
||||||
function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAccess) {
|
function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAccess, sslVerify) {
|
||||||
document.getElementById('edit_site_id').value = id;
|
document.getElementById('edit_site_id').value = id;
|
||||||
document.getElementById('edit_name').value = name;
|
document.getElementById('edit_name').value = name;
|
||||||
document.getElementById('edit_site_id_str').value = siteIdStr;
|
document.getElementById('edit_site_id_str').value = siteIdStr;
|
||||||
|
|
@ -292,18 +397,19 @@ function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAcces
|
||||||
document.getElementById('edit_username').value = username;
|
document.getElementById('edit_username').value = username;
|
||||||
document.getElementById('edit_password').value = '';
|
document.getElementById('edit_password').value = '';
|
||||||
document.getElementById('edit_public_access').checked = publicAccess == 1;
|
document.getElementById('edit_public_access').checked = publicAccess == 1;
|
||||||
|
document.getElementById('edit_ssl_verify').checked = sslVerify == 1;
|
||||||
document.getElementById('editSiteModal').classList.add('active');
|
document.getElementById('editSiteModal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('addSiteForm').addEventListener('submit', function() {
|
document.getElementById('addSiteForm').addEventListener('submit', function() {
|
||||||
const btn = document.getElementById('addSiteSubmitBtn');
|
const btn = document.getElementById('addSiteSubmitBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= addslashes(__('sites_testing')) ?>';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>';
|
||||||
});
|
});
|
||||||
document.getElementById('editSiteForm').addEventListener('submit', function() {
|
document.getElementById('editSiteForm').addEventListener('submit', function() {
|
||||||
const btn = document.getElementById('editSiteSubmitBtn');
|
const btn = document.getElementById('editSiteSubmitBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= addslashes(__('sites_testing')) ?>';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>';
|
||||||
});
|
});
|
||||||
|
|
||||||
['addSiteModal','editSiteModal'].forEach(id => {
|
['addSiteModal','editSiteModal'].forEach(id => {
|
||||||
|
|
@ -311,6 +417,23 @@ document.getElementById('editSiteForm').addEventListener('submit', function() {
|
||||||
if (e.target === this) closeModal(id);
|
if (e.target === this) closeModal(id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function testSite(siteId, btn) {
|
||||||
|
const original = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('ajax_test_site', siteId);
|
||||||
|
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
|
||||||
|
const result = await fetch('sites.php', { method: 'POST', body: fd }).then(r => r.json());
|
||||||
|
showToast(result.success ? 'success' : 'error', '<?= addslashes(__('site_test_btn')) ?>', result.message);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('error', '<?= addslashes(__('site_test_btn')) ?>', e.message);
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = original;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ require_once __DIR__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
require_once __DIR__ . '/../includes/Helpers.php';
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
$auth->requireAdmin();
|
$auth->requireAdmin();
|
||||||
|
|
@ -41,7 +42,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) {
|
||||||
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
|
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
|
||||||
);
|
);
|
||||||
$success = __('templates_added');
|
flashSet(__('templates_added'));
|
||||||
|
header('Location: templates.php');
|
||||||
|
exit;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = $e->getMessage();
|
$error = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
@ -71,23 +74,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
|
||||||
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?",
|
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?",
|
||||||
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id]
|
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id]
|
||||||
);
|
);
|
||||||
$success = __('templates_updated');
|
flashSet(__('templates_updated'));
|
||||||
|
header('Location: templates.php');
|
||||||
|
exit;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = $e->getMessage();
|
$error = $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profil löschen
|
// Profil löschen (POST + PRG)
|
||||||
if (isset($_GET['delete']) && isset($_GET['token'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_template'])) {
|
||||||
if ($auth->validateCsrfToken($_GET['token'])) {
|
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_GET['delete']]);
|
$db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_POST['delete_template']]);
|
||||||
$success = __('templates_deleted');
|
flashSet(__('templates_deleted'));
|
||||||
|
header('Location: templates.php');
|
||||||
|
exit;
|
||||||
} else {
|
} else {
|
||||||
$error = __('error_csrf');
|
$error = __('error_csrf');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (empty($success) && empty($error) && ($flash = flashGet())) {
|
||||||
|
$success = $flash['message'];
|
||||||
|
}
|
||||||
|
|
||||||
$templates = $db->fetchAll("SELECT t.*, u.name as creator FROM voucher_templates t LEFT JOIN users u ON t.created_by = u.id ORDER BY t.is_active DESC, t.name");
|
$templates = $db->fetchAll("SELECT t.*, u.name as creator FROM voucher_templates t LEFT JOIN users u ON t.created_by = u.id ORDER BY t.is_active DESC, t.name");
|
||||||
|
|
||||||
$currentPage = 'templates';
|
$currentPage = 'templates';
|
||||||
|
|
@ -100,22 +111,56 @@ $adminBase = '';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('templates_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('templates_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; flex-wrap: wrap; gap: 15px; }
|
||||||
|
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.card { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 25px; }
|
||||||
|
.card-header { padding: 20px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.card-title { font-size: 18px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.table { width: 100%; border-collapse: collapse; }
|
||||||
|
.table th { text-align: left; padding: 12px 15px; background: var(--bg-table-head); color: var(--text-secondary); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.table td { padding: 14px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
|
||||||
|
.table tr:last-child td { border-bottom: none; }
|
||||||
|
.table tr:hover td { background: var(--bg-hover); }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-danger { background: var(--danger); color: white; }
|
||||||
|
.btn-small { padding: 6px 12px; font-size: 12px; }
|
||||||
|
.modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--modal-overlay); z-index: 500; align-items: center; justify-content: center; }
|
||||||
|
.modal.active { display: flex; }
|
||||||
|
.modal-content { background: var(--bg-card); border-radius: 15px; max-width: 520px; width: 90%; max-height: 90vh; overflow-y: auto; }
|
||||||
|
.modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.modal-title { font-size: 18px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
|
||||||
|
.modal-body { padding: 25px; }
|
||||||
|
.form-group { margin-bottom: 18px; }
|
||||||
|
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
||||||
|
input[type="text"], input[type="number"], textarea, select { width: 100%; padding: 11px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color 0.2s; font-family: inherit; }
|
||||||
|
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.checkbox-group { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.checkbox-group input { width: auto; accent-color: var(--accent); }
|
||||||
|
.help-text { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||||
|
.empty-state i { font-size: 48px; margin-bottom: 20px; opacity: 0.3; display: block; }
|
||||||
|
.duration-badge { display: inline-flex; align-items: center; gap: 5px; background: var(--bg-hover); padding: 3px 10px; border-radius: 20px; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title"><?= __('templates_title') ?></h1>
|
<h1 class="page-title"><?= __('templates_title') ?></h1>
|
||||||
<p class="page-subtitle"><?= __('templates_subtitle') ?></p>
|
<p style="color: var(--text-muted); font-size: 14px; margin-top: 5px;"><?= __('templates_subtitle') ?></p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="openAddModal()" class="btn btn-primary">
|
<button onclick="openAddModal()" class="btn btn-primary">
|
||||||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('templates_add') ?>
|
<i class="fas fa-plus"></i> <?= __('templates_add') ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
|
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($success): ?>
|
<?php if ($success): ?>
|
||||||
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
|
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -125,15 +170,14 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
<?php if (empty($templates)): ?>
|
<?php if (empty($templates)): ?>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-layer-group" aria-hidden="true"></i>
|
<i class="fas fa-layer-group"></i>
|
||||||
<p style="font-size: 15px; margin-bottom: 8px;"><?= __('templates_none') ?></p>
|
<p style="font-size: 15px; margin-bottom: 8px;"><?= __('templates_none') ?></p>
|
||||||
<p style="font-size: 13px;"><?= __('templates_add_hint') ?></p>
|
<p style="font-size: 13px;"><?= __('templates_add_hint') ?></p>
|
||||||
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top: 20px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('templates_add') ?></button>
|
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top: 20px;"><i class="fas fa-plus"></i> <?= __('templates_add') ?></button>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div style="overflow-x: auto;">
|
<div style="overflow-x: auto;">
|
||||||
<div class="table-container">
|
<table class="table">
|
||||||
<table class="table table-stack">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?= __('templates_name') ?></th>
|
<th><?= __('templates_name') ?></th>
|
||||||
|
|
@ -147,11 +191,11 @@ $adminBase = '';
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($templates as $t): ?>
|
<?php foreach ($templates as $t): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="<?= __('templates_name') ?>"><strong><?= htmlspecialchars($t['name']) ?></strong></td>
|
<td><strong><?= htmlspecialchars($t['name']) ?></strong></td>
|
||||||
<td data-label="<?= __('templates_devices') ?>">
|
<td>
|
||||||
<span class="duration-badge"><i class="fas fa-mobile-alt" aria-hidden="true"></i> <?= (int)$t['max_uses'] ?></span>
|
<span class="duration-badge"><i class="fas fa-mobile-alt"></i> <?= (int)$t['max_uses'] ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="<?= __('templates_duration') ?>">
|
<td>
|
||||||
<?php
|
<?php
|
||||||
$m = (int)$t['expire_minutes'];
|
$m = (int)$t['expire_minutes'];
|
||||||
if ($m >= 1440 && $m % 1440 === 0) {
|
if ($m >= 1440 && $m % 1440 === 0) {
|
||||||
|
|
@ -162,39 +206,44 @@ $adminBase = '';
|
||||||
$durLabel = $m . ' Min.';
|
$durLabel = $m . ' Min.';
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<span class="duration-badge"><i class="fas fa-clock" aria-hidden="true"></i> <?= $durLabel ?></span>
|
<span class="duration-badge"><i class="fas fa-clock"></i> <?= $durLabel ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="<?= __('templates_desc') ?>" style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
|
<td style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
|
||||||
<td data-label="<?= __('label_status') ?>">
|
<td>
|
||||||
<?php if ($t['is_active']): ?>
|
<?php if ($t['is_active']): ?>
|
||||||
<span class="badge badge-success"><?= __('status_active') ?></span>
|
<span class="badge badge-success"><?= __('status_active') ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge badge-secondary"><?= __('status_inactive') ?></span>
|
<span class="badge badge-secondary"><?= __('status_inactive') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="<?= __('label_actions') ?>">
|
<td>
|
||||||
<button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)"
|
<button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)"
|
||||||
class="btn btn-secondary btn-small"><i class="fas fa-edit" aria-hidden="true"></i></button>
|
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
|
||||||
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<form method="post" style="display:inline;"
|
||||||
onclick="return confirm('<?= __('js_confirm_delete_template') ?>')"
|
onsubmit="return confirm('<?= addslashes(__('confirm_delete_template')) ?>')">
|
||||||
class="btn btn-danger-soft btn-small"><i class="fas fa-trash" aria-hidden="true"></i></a>
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
<input type="hidden" name="delete_template" value="<?= $t['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-danger btn-small"
|
||||||
|
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</div><!-- main-content -->
|
||||||
|
|
||||||
<!-- Modal: Hinzufügen -->
|
<!-- Modal: Hinzufügen -->
|
||||||
<div id="addModal" class="modal">
|
<div id="addModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title"><i class="fas fa-plus-circle" style="color: var(--accent);" aria-hidden="true"></i> <?= __('templates_add') ?></h2>
|
<h2 class="modal-title"><i class="fas fa-plus-circle" style="color: var(--accent);"></i> <?= __('templates_add') ?></h2>
|
||||||
<button class="modal-close" onclick="closeModal('addModal')">×</button>
|
<button class="modal-close" onclick="closeModal('addModal')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
@ -210,17 +259,17 @@ $adminBase = '';
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><?= __('templates_duration') ?> *</label>
|
<label><?= __('templates_duration') ?> *</label>
|
||||||
<input type="number" name="expire_minutes" value="480" min="1" max="525600">
|
<input type="number" name="expire_minutes" value="480" min="1" max="525600">
|
||||||
<div class="help-text"><?= __('templates_minutes_hint') ?></div>
|
<div class="help-text">480 = 8 Stunden</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="<?= __('templates_desc_placeholder') ?>"></textarea></div>
|
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="Kurze Beschreibung für Ihr Team"></textarea></div>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||||
<div class="form-group"><label>Download (kbit/s)</label><input type="number" name="qos_rate_max_down" min="0" placeholder="0 = unbegrenzt"></div>
|
<div class="form-group"><label>Download (kbit/s)</label><input type="number" name="qos_rate_max_down" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
<div class="form-group"><label>Upload (kbit/s)</label><input type="number" name="qos_rate_max_up" min="0" placeholder="0 = unbegrenzt"></div>
|
<div class="form-group"><label>Upload (kbit/s)</label><input type="number" name="qos_rate_max_up" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
<div class="form-group"><label>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" min="0" placeholder="0 = unbegrenzt"></div>
|
<div class="form-group"><label>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||||
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -232,7 +281,7 @@ $adminBase = '';
|
||||||
<div id="editModal" class="modal">
|
<div id="editModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title"><i class="fas fa-edit" style="color: var(--accent);" aria-hidden="true"></i> <?= __('templates_edit') ?></h2>
|
<h2 class="modal-title"><i class="fas fa-edit" style="color: var(--accent);"></i> <?= __('templates_edit') ?></h2>
|
||||||
<button class="modal-close" onclick="closeModal('editModal')">×</button>
|
<button class="modal-close" onclick="closeModal('editModal')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
@ -261,7 +310,7 @@ $adminBase = '';
|
||||||
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
|
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:10px;">
|
<div style="display:flex;gap:10px;">
|
||||||
<button type="submit" name="edit_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="edit_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||||
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
209
admin/users.php
|
|
@ -8,6 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
|
||||||
require_once __DIR__ . '/../includes/Auth.php';
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
require_once __DIR__ . '/../includes/Mailer.php';
|
require_once __DIR__ . '/../includes/Mailer.php';
|
||||||
require_once __DIR__ . '/../includes/I18n.php';
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
require_once __DIR__ . '/../includes/Helpers.php';
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
$auth->requireAdmin();
|
$auth->requireAdmin();
|
||||||
|
|
@ -20,10 +21,11 @@ I18n::init();
|
||||||
$error = '';
|
$error = '';
|
||||||
$success = '';
|
$success = '';
|
||||||
|
|
||||||
// Send password reset link
|
// Send password reset link (POST statt GET: kein CSRF-Token in URLs/Referrern,
|
||||||
if (isset($_GET['send_reset']) && isset($_GET['token'])) {
|
// keine versehentliche Ausloesung durch Link-Prefetching)
|
||||||
if ($auth->validateCsrfToken($_GET['token'])) {
|
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['send_reset'])) {
|
||||||
$targetUser = $db->fetchOne("SELECT * FROM users WHERE id=? AND is_active=1 AND password_hash IS NOT NULL", [(int)$_GET['send_reset']]);
|
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
|
$targetUser = $db->fetchOne("SELECT * FROM users WHERE id=? AND is_active=1 AND password_hash IS NOT NULL", [(int)$_POST['send_reset']]);
|
||||||
if ($targetUser) {
|
if ($targetUser) {
|
||||||
try {
|
try {
|
||||||
$db->execute("DELETE FROM password_reset_tokens WHERE user_id=?", [$targetUser['id']]);
|
$db->execute("DELETE FROM password_reset_tokens WHERE user_id=?", [$targetUser['id']]);
|
||||||
|
|
@ -39,12 +41,14 @@ if (isset($_GET['send_reset']) && isset($_GET['token'])) {
|
||||||
$resetUrl = $systemUrl . '/reset_password.php?token=' . $token;
|
$resetUrl = $systemUrl . '/reset_password.php?token=' . $token;
|
||||||
$mailer->sendRaw($targetUser['email'], $appTitle . ' – Passwort zurücksetzen',
|
$mailer->sendRaw($targetUser['email'], $appTitle . ' – Passwort zurücksetzen',
|
||||||
"Hallo {$targetUser['name']},\n\nEin Administrator hat für Sie einen Passwort-Reset-Link erstellt:\n\n{$resetUrl}\n\n(Gültig für 1 Stunde)\n\n{$appTitle}");
|
"Hallo {$targetUser['name']},\n\nEin Administrator hat für Sie einen Passwort-Reset-Link erstellt:\n\n{$resetUrl}\n\n(Gültig für 1 Stunde)\n\n{$appTitle}");
|
||||||
$success = 'Passwort-Reset-Link wurde an ' . htmlspecialchars($targetUser['email']) . ' gesendet.';
|
flashSet(__('reset_link_sent', ['email' => $targetUser['email']]));
|
||||||
|
header('Location: users.php');
|
||||||
|
exit;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = 'Fehler beim Senden: ' . $e->getMessage();
|
$error = 'Fehler beim Senden: ' . $e->getMessage();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$error = 'Benutzer nicht gefunden oder kein lokales Passwort.';
|
$error = __('reset_link_failed');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$error = __('error_csrf');
|
$error = __('error_csrf');
|
||||||
|
|
@ -60,6 +64,11 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
|
||||||
$userId = (int)$_POST['user_id'];
|
$userId = (int)$_POST['user_id'];
|
||||||
$isAdmin = isset($_POST['is_admin']) ? 1 : 0;
|
$isAdmin = isset($_POST['is_admin']) ? 1 : 0;
|
||||||
$siteIds = $_POST['site_ids'] ?? [];
|
$siteIds = $_POST['site_ids'] ?? [];
|
||||||
|
// Lockout-Schutz: Der letzte Weg ins Admin-Panel darf nicht
|
||||||
|
// versehentlich gekappt werden.
|
||||||
|
if ($userId === (int)$_SESSION['user_id'] && !$isAdmin) {
|
||||||
|
throw new Exception(__('error_self_demote'));
|
||||||
|
}
|
||||||
$oldUser = $db->fetchOne("SELECT * FROM users WHERE id=?", [$userId]);
|
$oldUser = $db->fetchOne("SELECT * FROM users WHERE id=?", [$userId]);
|
||||||
$oldSites= $db->fetchAll("SELECT s.name FROM sites s INNER JOIN user_site_access usa ON s.id=usa.site_id WHERE usa.user_id=?", [$userId]);
|
$oldSites= $db->fetchAll("SELECT s.name FROM sites s INNER JOIN user_site_access usa ON s.id=usa.site_id WHERE usa.user_id=?", [$userId]);
|
||||||
$db->query("UPDATE users SET is_admin=? WHERE id=?", [$isAdmin, $userId]);
|
$db->query("UPDATE users SET is_admin=? WHERE id=?", [$isAdmin, $userId]);
|
||||||
|
|
@ -83,8 +92,10 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
|
||||||
if (!empty($removedSites)) $changes[] = 'Zugriff entfernt von: ' . implode(', ', $removedSites);
|
if (!empty($removedSites)) $changes[] = 'Zugriff entfernt von: ' . implode(', ', $removedSites);
|
||||||
if ($isAdmin && !$oldUser['is_admin']) $changes[] = 'Sie haben nun Zugriff auf alle Sites';
|
if ($isAdmin && !$oldUser['is_admin']) $changes[] = 'Sie haben nun Zugriff auf alle Sites';
|
||||||
if (!empty($changes)) $mailer->sendUserNotification($oldUser['email'], $oldUser['name'], $changes);
|
if (!empty($changes)) $mailer->sendUserNotification($oldUser['email'], $oldUser['name'], $changes);
|
||||||
$success = __('users_updated') . (!empty($changes) ? ' '.__('users_notified') : '');
|
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'user_edit', 'user', $userId, implode('; ', $changes) ?: 'Keine Änderungen');
|
$auth->writeAuditLog($_SESSION['user_id'], 'user_edit', 'user', $userId, implode('; ', $changes) ?: 'Keine Änderungen');
|
||||||
|
flashSet(__('users_updated') . (!empty($changes) ? ' '.__('users_notified') : ''));
|
||||||
|
header('Location: users.php');
|
||||||
|
exit;
|
||||||
} catch (Exception $e) { $error = $e->getMessage(); }
|
} catch (Exception $e) { $error = $e->getMessage(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -103,59 +114,72 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_user'])) {
|
||||||
if (empty($email)||empty($name)||empty($password)) throw new Exception(__('error_fill_all'));
|
if (empty($email)||empty($name)||empty($password)) throw new Exception(__('error_fill_all'));
|
||||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
|
||||||
if (strlen($password) < 8) throw new Exception(__('settings_pw_minlength'));
|
if (strlen($password) < 8) throw new Exception(__('settings_pw_minlength'));
|
||||||
if ($db->fetchOne("SELECT id FROM users WHERE email=?", [$email])) throw new Exception('E-Mail bereits vorhanden');
|
if ($db->fetchOne("SELECT id FROM users WHERE email=?", [$email])) throw new Exception(__('error_email_exists'));
|
||||||
$userId = $auth->registerUser($email, $name, $password, $isAdmin);
|
$userId = $auth->registerUser($email, $name, $password, $isAdmin);
|
||||||
if (!$userId) throw new Exception('Benutzer konnte nicht erstellt werden');
|
if (!$userId) throw new Exception(__('error_user_create'));
|
||||||
if (!$isAdmin && !empty($siteIds)) {
|
if (!$isAdmin && !empty($siteIds)) {
|
||||||
foreach ($siteIds as $siteId) {
|
foreach ($siteIds as $siteId) {
|
||||||
$db->execute("INSERT INTO user_site_access (user_id, site_id) VALUES (?,?)", [$userId, $siteId]);
|
$db->execute("INSERT INTO user_site_access (user_id, site_id) VALUES (?,?)", [$userId, $siteId]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'user_create', 'user', $userId, "Benutzer {$name} erstellt");
|
$auth->writeAuditLog($_SESSION['user_id'], 'user_create', 'user', $userId, "Benutzer {$name} erstellt");
|
||||||
$success = __('users_added');
|
flashSet(__('users_added'));
|
||||||
|
header('Location: users.php');
|
||||||
|
exit;
|
||||||
} catch (Exception $e) { $error = $e->getMessage(); }
|
} catch (Exception $e) { $error = $e->getMessage(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete user
|
// Delete user (POST + PRG)
|
||||||
if (isset($_GET['delete']) && isset($_GET['token'])) {
|
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_user'])) {
|
||||||
if ($auth->validateCsrfToken($_GET['token'])) {
|
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$deleteId = (int)$_GET['delete'];
|
$deleteId = (int)$_POST['delete_user'];
|
||||||
if ($deleteId === (int)$_SESSION['user_id']) {
|
if ($deleteId === (int)$_SESSION['user_id']) {
|
||||||
$error = 'Sie können sich nicht selbst löschen';
|
$error = __('error_self_delete');
|
||||||
} else {
|
} else {
|
||||||
$db->query("DELETE FROM users WHERE id=?", [$deleteId]);
|
$db->query("DELETE FROM users WHERE id=?", [$deleteId]);
|
||||||
$auth->writeAuditLog($_SESSION['user_id'], 'user_delete', 'user', $deleteId, 'Benutzer gelöscht');
|
$auth->writeAuditLog($_SESSION['user_id'], 'user_delete', 'user', $deleteId, 'Benutzer gelöscht');
|
||||||
$success = __('users_deleted');
|
flashSet(__('users_deleted'));
|
||||||
|
header('Location: users.php');
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
} else { $error = __('error_csrf'); }
|
} else { $error = __('error_csrf'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle user active
|
// Toggle user active (POST + PRG)
|
||||||
if (isset($_GET['toggle']) && isset($_GET['token'])) {
|
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_user'])) {
|
||||||
if ($auth->validateCsrfToken($_GET['token'])) {
|
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$toggleId = (int)$_GET['toggle'];
|
$toggleId = (int)$_POST['toggle_user'];
|
||||||
if ($toggleId === (int)$_SESSION['user_id']) {
|
if ($toggleId === (int)$_SESSION['user_id']) {
|
||||||
$error = 'Sie können sich nicht selbst deaktivieren';
|
$error = __('error_self_deactivate');
|
||||||
} else {
|
} else {
|
||||||
$user = $db->fetchOne("SELECT is_active FROM users WHERE id=?", [$toggleId]);
|
$user = $db->fetchOne("SELECT is_active FROM users WHERE id=?", [$toggleId]);
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$newStatus = $user['is_active'] ? 0 : 1;
|
$newStatus = $user['is_active'] ? 0 : 1;
|
||||||
$db->query("UPDATE users SET is_active=? WHERE id=?", [$newStatus, $toggleId]);
|
$db->query("UPDATE users SET is_active=? WHERE id=?", [$newStatus, $toggleId]);
|
||||||
$success = 'Benutzer-Status aktualisiert!';
|
flashSet(__('users_status_updated'));
|
||||||
|
header('Location: users.php');
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { $error = __('error_csrf'); }
|
} else { $error = __('error_csrf'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator)
|
// 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator)
|
||||||
if (isset($_GET['reset_2fa']) && isset($_GET['token'])) {
|
// POST + PRG wie die uebrigen state-aendernden Aktionen
|
||||||
if ($auth->validateCsrfToken($_GET['token'])) {
|
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['reset_2fa'])) {
|
||||||
$auth->disableTotp((int)$_GET['reset_2fa']);
|
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
$success = '2FA des Benutzers wurde zurückgesetzt.';
|
$auth->disableTotp((int)$_POST['reset_2fa']);
|
||||||
|
flashSet('2FA des Benutzers wurde zurückgesetzt.');
|
||||||
|
header('Location: users.php');
|
||||||
|
exit;
|
||||||
} else { $error = __('error_csrf'); }
|
} else { $error = __('error_csrf'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (empty($success) && empty($error) && ($flash = flashGet())) {
|
||||||
|
$success = $flash['message'];
|
||||||
|
}
|
||||||
|
|
||||||
$users = $db->fetchAll("SELECT * FROM users ORDER BY name");
|
$users = $db->fetchAll("SELECT * FROM users ORDER BY name");
|
||||||
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
|
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
|
||||||
$userSiteAccess = [];
|
$userSiteAccess = [];
|
||||||
|
|
@ -171,30 +195,67 @@ $currentPage = 'users';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('users_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('users_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 24px; }
|
||||||
|
.card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); }
|
||||||
|
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.table { width: 100%; border-collapse: collapse; }
|
||||||
|
.table th { text-align: left; padding: 12px 15px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.table td { padding: 13px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
|
||||||
|
.table tr:last-child td { border-bottom: none; }
|
||||||
|
.table tr:hover { background: var(--bg-hover); }
|
||||||
|
.btn { padding: 8px 16px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
||||||
|
.btn-secondary:hover { background: var(--border-color); }
|
||||||
|
.btn-danger { background: var(--danger); color: white; }
|
||||||
|
.btn-warning { background: var(--warning); color: #333; }
|
||||||
|
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||||
|
.modal { display: none; position: fixed; inset: 0; background: var(--modal-overlay); z-index: 1000; align-items: center; justify-content: center; }
|
||||||
|
.modal.active { display: flex; }
|
||||||
|
.modal-content { background: var(--bg-card); border-radius: 14px; max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border-color); }
|
||||||
|
.modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.modal-title { font-size: 19px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
|
||||||
|
.modal-body { padding: 22px 25px; }
|
||||||
|
.form-group { margin-bottom: 18px; }
|
||||||
|
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
||||||
|
input[type="text"], input[type="email"], input[type="password"] { width: 100%; padding: 11px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color .2s; }
|
||||||
|
input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.checkbox-group input { width: auto; }
|
||||||
|
.site-selection { border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; max-height: 180px; overflow-y: auto; background: var(--bg-hover); }
|
||||||
|
.empty-state { text-align: center; padding: 50px 20px; color: var(--text-muted); }
|
||||||
|
.empty-state i { font-size: 40px; margin-bottom: 15px; opacity: .3; display: block; }
|
||||||
|
.action-btns { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||||
|
@media(max-width:768px){ .main-content{ margin-left:0!important; } .table th:nth-child(5),.table td:nth-child(5){ display:none; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title"><?= __('users_title') ?></h1>
|
<h1 class="page-title"><?= __('users_title') ?></h1>
|
||||||
<button onclick="openModal()" class="btn btn-primary">
|
<button onclick="openModal()" class="btn btn-primary">
|
||||||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('users_add') ?>
|
<i class="fas fa-plus"></i> <?= __('users_add') ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
|
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($success): ?>
|
<?php if ($success): ?>
|
||||||
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
|
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h2 class="card-title"><?= __('users_all') ?></h2></div>
|
<div class="card-header"><h2 class="card-title"><?= __('users_all') ?></h2></div>
|
||||||
<div class="card-body" style="padding:0;">
|
<div class="card-body" style="padding:0;">
|
||||||
<?php if (empty($users)): ?>
|
<?php if (empty($users)): ?>
|
||||||
<div class="empty-state"><i class="fas fa-users" aria-hidden="true"></i><p><?= __('users_none_found') ?></p></div>
|
<div class="empty-state"><i class="fas fa-users"></i><p><?= __('users_none_found') ?></p></div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<div class="table-container">
|
<table class="table">
|
||||||
<table class="table table-stack">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?= __('label_name') ?></th>
|
<th><?= __('label_name') ?></th>
|
||||||
|
|
@ -210,28 +271,28 @@ $currentPage = 'users';
|
||||||
<?php foreach ($users as $user): ?>
|
<?php foreach ($users as $user): ?>
|
||||||
<?php $userSiteIds = array_column($userSiteAccess[$user['id']]??[], 'id'); ?>
|
<?php $userSiteIds = array_column($userSiteAccess[$user['id']]??[], 'id'); ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="<?= __('label_name') ?>">
|
<td>
|
||||||
<strong><?= htmlspecialchars($user['name']) ?></strong>
|
<strong><?= htmlspecialchars($user['name']) ?></strong>
|
||||||
<?php if ($user['id'] == $_SESSION['user_id']): ?>
|
<?php if ($user['id'] == $_SESSION['user_id']): ?>
|
||||||
<span class="badge badge-info"><?= __('users_you') ?></span>
|
<span class="badge badge-info"><?= __('users_you') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="<?= __('label_email') ?>"><?= htmlspecialchars($user['email']) ?></td>
|
<td><?= htmlspecialchars($user['email']) ?></td>
|
||||||
<td data-label="<?= __('label_role') ?>">
|
<td>
|
||||||
<?php if ($user['is_admin']): ?>
|
<?php if ($user['is_admin']): ?>
|
||||||
<span class="badge badge-danger"><i class="fas fa-crown" aria-hidden="true"></i> <?= __('status_admin') ?></span>
|
<span class="badge badge-danger"><i class="fas fa-crown"></i> <?= __('status_admin') ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge badge-info"><?= __('status_user') ?></span>
|
<span class="badge badge-info"><?= __('status_user') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="<?= __('label_status') ?>">
|
<td>
|
||||||
<?php if ($user['is_active']): ?>
|
<?php if ($user['is_active']): ?>
|
||||||
<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_active') ?></span>
|
<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge badge-warning"><i class="fas fa-pause" aria-hidden="true"></i> <?= __('status_inactive') ?></span>
|
<span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="<?= __('users_site_access') ?>">
|
<td>
|
||||||
<?php if ($user['is_admin']): ?>
|
<?php if ($user['is_admin']): ?>
|
||||||
<em style="color:var(--text-muted);"><?= __('users_all_sites') ?></em>
|
<em style="color:var(--text-muted);"><?= __('users_all_sites') ?></em>
|
||||||
<?php elseif (!empty($userSiteAccess[$user['id']])): ?>
|
<?php elseif (!empty($userSiteAccess[$user['id']])): ?>
|
||||||
|
|
@ -242,7 +303,7 @@ $currentPage = 'users';
|
||||||
<em style="color:var(--text-muted);"><?= __('users_none') ?></em>
|
<em style="color:var(--text-muted);"><?= __('users_none') ?></em>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="<?= __('users_last_login') ?>" style="font-size:13px;">
|
<td style="font-size:13px;">
|
||||||
<?php if ($user['last_login']): ?>
|
<?php if ($user['last_login']): ?>
|
||||||
<?= date('d.m.Y H:i', strtotime($user['last_login'])) ?>
|
<?= date('d.m.Y H:i', strtotime($user['last_login'])) ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
@ -253,32 +314,49 @@ $currentPage = 'users';
|
||||||
<div class="action-btns">
|
<div class="action-btns">
|
||||||
<button onclick="openEditModal(<?= $user['id'] ?>, '<?= htmlspecialchars($user['name'], ENT_QUOTES) ?>', <?= $user['is_admin'] ?>, [<?= implode(',', array_map('intval', $userSiteIds)) ?>])"
|
<button onclick="openEditModal(<?= $user['id'] ?>, '<?= htmlspecialchars($user['name'], ENT_QUOTES) ?>', <?= $user['is_admin'] ?>, [<?= implode(',', array_map('intval', $userSiteIds)) ?>])"
|
||||||
class="btn btn-secondary btn-sm" title="<?= __('btn_edit') ?>">
|
class="btn btn-secondary btn-sm" title="<?= __('btn_edit') ?>">
|
||||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
<?php if ($user['id'] != $_SESSION['user_id']): ?>
|
<?php if ($user['id'] != $_SESSION['user_id']): ?>
|
||||||
<a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<form method="post" style="display:inline;">
|
||||||
class="btn btn-secondary btn-sm" title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i>
|
<input type="hidden" name="toggle_user" value="<?= $user['id'] ?>">
|
||||||
</a>
|
<button type="submit" class="btn btn-secondary btn-sm"
|
||||||
|
title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>"
|
||||||
|
aria-label="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>">
|
||||||
|
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<?php if ($smtpEnabled && !empty($user['password_hash'])): ?>
|
<?php if ($smtpEnabled && !empty($user['password_hash'])): ?>
|
||||||
<a href="?send_reset=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<form method="post" style="display:inline;"
|
||||||
class="btn btn-warning btn-sm" title="<?= __('users_reset_pw') ?>"
|
onsubmit="return confirm('<?= addslashes(__('confirm_send_reset', ['email' => $user['email']])) ?>')">
|
||||||
onclick="return confirm('Passwort-Reset-Link senden an <?= htmlspecialchars($user['email'], ENT_QUOTES) ?>?')">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<i class="fas fa-key" aria-hidden="true"></i>
|
<input type="hidden" name="send_reset" value="<?= $user['id'] ?>">
|
||||||
</a>
|
<button type="submit" class="btn btn-warning btn-sm"
|
||||||
|
title="<?= __('users_reset_pw') ?>" aria-label="<?= __('users_reset_pw') ?>">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($user['totp_enabled'])): ?>
|
<?php if (!empty($user['totp_enabled'])): ?>
|
||||||
<a href="?reset_2fa=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<form method="post" style="display:inline;"
|
||||||
class="btn btn-secondary btn-sm" title="2FA zurücksetzen"
|
onsubmit="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
|
||||||
onclick="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<i class="fas fa-user-shield" aria-hidden="true"></i>
|
<input type="hidden" name="reset_2fa" value="<?= $user['id'] ?>">
|
||||||
</a>
|
<button type="submit" class="btn btn-secondary btn-sm"
|
||||||
|
title="2FA zurücksetzen" aria-label="2FA zurücksetzen">
|
||||||
|
<i class="fas fa-user-shield"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<form method="post" style="display:inline;"
|
||||||
class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>"
|
onsubmit="return confirm('<?= addslashes(__('confirm_delete_user')) ?>')">
|
||||||
onclick="return confirm('<?= __('js_confirm_delete_user') ?>')">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
<input type="hidden" name="delete_user" value="<?= $user['id'] ?>">
|
||||||
</a>
|
<button type="submit" class="btn btn-danger btn-sm"
|
||||||
|
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -287,12 +365,11 @@ $currentPage = 'users';
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</div><!-- /main-content -->
|
||||||
|
|
||||||
<!-- Add User Modal -->
|
<!-- Add User Modal -->
|
||||||
<div id="addUserModal" class="modal">
|
<div id="addUserModal" class="modal">
|
||||||
|
|
@ -342,7 +419,7 @@ $currentPage = 'users';
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" name="add_user" class="btn btn-primary" style="flex:1;">
|
<button type="submit" name="add_user" class="btn btn-primary" style="flex:1;">
|
||||||
<i class="fas fa-save" aria-hidden="true"></i> <?= __('users_save') ?>
|
<i class="fas fa-save"></i> <?= __('users_save') ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="closeModal('addUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('addUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -386,7 +463,7 @@ $currentPage = 'users';
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" name="edit_user" class="btn btn-primary" style="flex:1;">
|
<button type="submit" name="edit_user" class="btn btn-primary" style="flex:1;">
|
||||||
<i class="fas fa-save" aria-hidden="true"></i> <?= __('users_save_edit') ?>
|
<i class="fas fa-save"></i> <?= __('users_save_edit') ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="closeModal('editUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('editUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -395,7 +472,7 @@ $currentPage = 'users';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast-container" role="status" aria-live="polite"></div>
|
<div id="toast-container"></div>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function openModal() { document.getElementById('addUserModal').classList.add('active'); }
|
function openModal() { document.getElementById('addUserModal').classList.add('active'); }
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,19 @@ if (isset($_GET['export_csv']) && isset($_GET['site_id'])) {
|
||||||
if (!$site) { http_response_code(404); exit; }
|
if (!$site) { http_response_code(404); exit; }
|
||||||
$rows = $db->fetchAll("SELECT voucher_code,voucher_name,max_uses,expire_minutes,status,used_count,created_at,expires_at FROM vouchers WHERE site_id=? ORDER BY created_at DESC", [$siteId]);
|
$rows = $db->fetchAll("SELECT voucher_code,voucher_name,max_uses,expire_minutes,status,used_count,created_at,expires_at FROM vouchers WHERE site_id=? ORDER BY created_at DESC", [$siteId]);
|
||||||
$filename = 'vouchers_' . preg_replace('/[^a-z0-9]/i','_',$site['name']) . '_' . date('Ymd_His') . '.csv';
|
$filename = 'vouchers_' . preg_replace('/[^a-z0-9]/i','_',$site['name']) . '_' . date('Ymd_His') . '.csv';
|
||||||
|
// Schutz vor CSV/Excel-Formula-Injection: Zellen, die mit =, +, -, @ oder
|
||||||
|
// Tab beginnen (Nutzereingabe voucher_name!), mit Apostroph neutralisieren.
|
||||||
|
$csvSafe = function ($v) {
|
||||||
|
$v = (string)$v;
|
||||||
|
return preg_match('/^[=+\-@\t]/', $v) ? "'" . $v : $v;
|
||||||
|
};
|
||||||
header('Content-Type: text/csv; charset=UTF-8');
|
header('Content-Type: text/csv; charset=UTF-8');
|
||||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
$out = fopen('php://output','w');
|
$out = fopen('php://output','w');
|
||||||
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
|
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||||
fputcsv($out,['Code','Name','Max. Geräte','Gültigkeit (Min)','Status','Genutzt','Erstellt','Läuft ab'],';');
|
fputcsv($out,['Code','Name','Max. Geräte','Gültigkeit (Min)','Status','Genutzt','Erstellt','Läuft ab'],';');
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
fputcsv($out,[$r['voucher_code'],$r['voucher_name'],$r['max_uses'],$r['expire_minutes'],$r['status'],$r['used_count'],$r['created_at'],$r['expires_at']??''],';');
|
fputcsv($out,[$csvSafe($r['voucher_code']),$csvSafe($r['voucher_name']),$r['max_uses'],$r['expire_minutes'],$r['status'],$r['used_count'],$r['created_at'],$r['expires_at']??''],';');
|
||||||
}
|
}
|
||||||
fclose($out); exit;
|
fclose($out); exit;
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +50,7 @@ if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) {
|
||||||
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
|
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
|
||||||
if ($syncFirst) {
|
if ($syncFirst) {
|
||||||
try {
|
try {
|
||||||
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']);
|
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id'],$site['ssl_verify'] ?? 0);
|
||||||
$ctrl->syncVouchersToDatabase($db,$siteId);
|
$ctrl->syncVouchersToDatabase($db,$siteId);
|
||||||
$db->execute("INSERT INTO settings (setting_key,setting_value) VALUES ('last_cron_sync',NOW()) ON DUPLICATE KEY UPDATE setting_value=NOW()");
|
$db->execute("INSERT INTO settings (setting_key,setting_value) VALUES ('last_cron_sync',NOW()) ON DUPLICATE KEY UPDATE setting_value=NOW()");
|
||||||
} catch (Exception $e) { error_log("Sync error: ".$e->getMessage()); }
|
} catch (Exception $e) { error_log("Sync error: ".$e->getMessage()); }
|
||||||
|
|
@ -83,12 +89,12 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
|
||||||
$siteId = (int)$_POST['site_id'];
|
$siteId = (int)$_POST['site_id'];
|
||||||
$site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]);
|
$site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]);
|
||||||
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
|
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
|
||||||
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']);
|
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id'],$site['ssl_verify'] ?? 0);
|
||||||
if ($ctrl->deleteVoucher($voucherId)) {
|
if ($ctrl->deleteVoucher($voucherId)) {
|
||||||
$db->execute("DELETE FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$voucherId,$siteId]);
|
$db->execute("DELETE FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$voucherId,$siteId]);
|
||||||
echo json_encode(['success'=>true,'message'=>'Voucher erfolgreich gelöscht!']);
|
echo json_encode(['success'=>true,'message'=>__('voucher_deleted')]);
|
||||||
} else {
|
} else {
|
||||||
echo json_encode(['success'=>false,'message'=>'Voucher konnte nicht gelöscht werden']);
|
echo json_encode(['success'=>false,'message'=>__('voucher_delete_failed')]);
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
echo json_encode(['success'=>false,'message'=>'Fehler: '.$e->getMessage()]);
|
echo json_encode(['success'=>false,'message'=>'Fehler: '.$e->getMessage()]);
|
||||||
|
|
@ -130,22 +136,77 @@ $currentPage = 'vouchers';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('vouchers_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('vouchers_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.page-header { margin-bottom: 28px; }
|
||||||
|
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 12px; }
|
||||||
|
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
|
||||||
|
.live-indicator { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--success); }
|
||||||
|
.live-indicator .dot { width: 8px; height: 8px; background: var(--success); border-radius: 50%; animation: pulse 2s infinite; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.5} }
|
||||||
|
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); margin-bottom: 22px; overflow: hidden; }
|
||||||
|
.card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.card-body { padding: 22px; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px,1fr)); gap: 16px; margin-bottom: 22px; }
|
||||||
|
.stat-card { background: var(--bg-card); padding: 18px; border-radius: 12px; box-shadow: 0 2px 8px var(--shadow); border: 1px solid var(--border-color); }
|
||||||
|
.stat-label { color: var(--text-muted); font-size: 13px; margin-bottom: 6px; }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.stat-value.valid { color: var(--success); }
|
||||||
|
.stat-value.used { color: var(--warning); }
|
||||||
|
.stat-value.expired { color: var(--danger); }
|
||||||
|
.site-selector { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.site-selector select { padding: 10px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; min-width: 220px; cursor: pointer; background: var(--bg-input); color: var(--text-primary); }
|
||||||
|
.site-selector select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.search-bar { padding: 10px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); min-width: 200px; }
|
||||||
|
.search-bar:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.btn { padding: 9px 18px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
||||||
|
.btn-secondary:hover { background: var(--border-color); }
|
||||||
|
.btn-danger { background: var(--danger); color: white; }
|
||||||
|
.btn-danger:hover { opacity: .88; }
|
||||||
|
.btn-success { background: var(--success); color: white; }
|
||||||
|
.btn-sm { padding: 5px 11px; font-size: 12px; }
|
||||||
|
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
.table-container { overflow-x: auto; }
|
||||||
|
.table { width: 100%; border-collapse: collapse; }
|
||||||
|
.table th { text-align: left; padding: 11px 14px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; white-space: nowrap; }
|
||||||
|
.table td { padding: 13px 14px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 13px; }
|
||||||
|
.table tr:last-child td { border-bottom: none; }
|
||||||
|
.table tr:hover { background: var(--bg-hover); }
|
||||||
|
.table tr.deleting { opacity: .45; pointer-events: none; }
|
||||||
|
code { background: var(--bg-hover); padding: 4px 8px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; letter-spacing: 1px; }
|
||||||
|
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||||
|
.empty-state i { font-size: 42px; margin-bottom: 18px; opacity: .3; display: block; }
|
||||||
|
.loading { display: flex; align-items: center; justify-content: center; padding: 60px; color: var(--text-muted); gap: 12px; }
|
||||||
|
.loading i { font-size: 22px; animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { from{transform:rotate(0)}to{transform:rotate(360deg)} }
|
||||||
|
.filter-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.filter-btn { padding: 7px 14px; border: 2px solid var(--border-color); background: var(--bg-card); color: var(--text-secondary); border-radius: 8px; cursor: pointer; font-size: 13px; transition: all .2s; }
|
||||||
|
.filter-btn:hover { border-color: var(--accent); }
|
||||||
|
.filter-btn.active { border-color: var(--accent); background: var(--accent); color: white; }
|
||||||
|
.usage-info { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.usage-bar { width: 50px; height: 5px; background: var(--border-color); border-radius: 3px; overflow: hidden; }
|
||||||
|
.usage-bar-fill { height: 100%; background: var(--accent); border-radius: 3px; }
|
||||||
|
.voucher-note { max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.no-sites-warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 12px; padding: 30px; text-align: center; color: #856404; }
|
||||||
|
@media(max-width:768px){ .main-content{ margin-left:0!important; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<h1 class="page-title">
|
||||||
<h1 class="page-title">
|
<?= __('vouchers_title') ?>
|
||||||
<?= __('vouchers_title') ?>
|
<span class="live-indicator"><span class="dot"></span>LIVE</span>
|
||||||
<span class="live-indicator"><span class="dot"></span>LIVE</span>
|
</h1>
|
||||||
</h1>
|
<p class="page-subtitle"><?= __('vouchers_subtitle') ?></p>
|
||||||
<p class="page-subtitle"><?= __('vouchers_subtitle') ?></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (empty($sites)): ?>
|
<?php if (empty($sites)): ?>
|
||||||
<div class="no-sites-warning">
|
<div class="no-sites-warning">
|
||||||
<i class="fas fa-triangle-exclamation" style="font-size:26px;margin-bottom:12px;display:block;" aria-hidden="true"></i>
|
<i class="fas fa-exclamation-triangle" style="font-size:48px;margin-bottom:15px;opacity:.7;display:block;"></i>
|
||||||
<h3><?= __('vouchers_no_sites') ?></h3>
|
<h3><?= __('vouchers_no_sites') ?></h3>
|
||||||
<a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('nav_sites') ?></a>
|
<a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus"></i> <?= __('nav_sites') ?></a>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
||||||
|
|
@ -167,8 +228,8 @@ $currentPage = 'vouchers';
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<input type="search" id="searchInput" class="search-bar" placeholder="<?= __('vouchers_search') ?>" oninput="filterAndRender()" style="display:none;">
|
<input type="search" id="searchInput" class="search-bar" placeholder="<?= __('vouchers_search') ?>" oninput="filterAndRender()" style="display:none;">
|
||||||
<button id="refreshBtn" class="btn btn-secondary" onclick="loadVouchers(true)" disabled>
|
<button id="refreshBtn" class="btn btn-success" onclick="loadVouchers(true)" disabled>
|
||||||
<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('btn_refresh') ?>
|
<i class="fas fa-sync-alt"></i> <?= __('btn_refresh') ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -187,25 +248,23 @@ $currentPage = 'vouchers';
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title" id="voucherListTitle">Vouchers</h2>
|
<h2 class="card-title" id="voucherListTitle">Vouchers</h2>
|
||||||
<a id="csvExportBtn" style="display:none;" class="btn btn-secondary btn-sm" href="#">
|
<a id="csvExportBtn" style="display:none;" class="btn btn-secondary btn-sm" href="#">
|
||||||
<i class="fas fa-download" aria-hidden="true"></i> <?= __('btn_export_csv') ?>
|
<i class="fas fa-download"></i> <?= __('btn_export_csv') ?>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" style="padding:0;">
|
<div class="card-body" style="padding:0;">
|
||||||
<div id="voucherContent">
|
<div id="voucherContent">
|
||||||
<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= __('vouchers_select_hint') ?></p></div>
|
<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('vouchers_select_hint') ?></p></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</main>
|
</div><!-- /main-content -->
|
||||||
|
|
||||||
<div id="toast-container" role="status" aria-live="polite"></div>
|
<div id="toast-container"></div>
|
||||||
<script src="../assets/global.js"></script>
|
<script src="../assets/global.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const csrfToken = '<?= $auth->getCsrfToken() ?>';
|
const csrfToken = '<?= $auth->getCsrfToken() ?>';
|
||||||
// Datums- und Zeitformat folgen der gewaehlten Sprache
|
|
||||||
const LOCALE = '<?= I18n::getLanguage() === 'en' ? 'en-GB' : 'de-DE' ?>';
|
|
||||||
let currentSiteId = null;
|
let currentSiteId = null;
|
||||||
let allVouchers = [];
|
let allVouchers = [];
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
|
|
@ -220,7 +279,7 @@ async function loadVouchers(syncFirst=false) {
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
|
||||||
if (!siteId) {
|
if (!siteId) {
|
||||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= addslashes(__('vouchers_select_hint')) ?></p></div>`;
|
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_select_hint')) ?></p></div>`;
|
||||||
document.getElementById('statsContainer').style.display = 'none';
|
document.getElementById('statsContainer').style.display = 'none';
|
||||||
searchInput.style.display = 'none';
|
searchInput.style.display = 'none';
|
||||||
refreshBtn.disabled = true;
|
refreshBtn.disabled = true;
|
||||||
|
|
@ -229,9 +288,9 @@ async function loadVouchers(syncFirst=false) {
|
||||||
|
|
||||||
currentSiteId = siteId;
|
currentSiteId = siteId;
|
||||||
refreshBtn.disabled = false;
|
refreshBtn.disabled = false;
|
||||||
refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
|
refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
|
||||||
|
|
||||||
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner" aria-hidden="true"></i><span>${syncFirst ? 'Synchronisiere...' : 'Lade...'}</span></div>`;
|
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner"></i><span>${syncFirst ? '<?= addslashes(__('syncing')) ?>' : '<?= addslashes(__('loading')) ?>'}</span></div>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json());
|
const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json());
|
||||||
|
|
@ -247,17 +306,17 @@ async function loadVouchers(syncFirst=false) {
|
||||||
const csvBtn = document.getElementById('csvExportBtn');
|
const csvBtn = document.getElementById('csvExportBtn');
|
||||||
csvBtn.style.display = 'inline-flex';
|
csvBtn.style.display = 'inline-flex';
|
||||||
csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`;
|
csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`;
|
||||||
if (syncFirst) showToast('success', '<?= addslashes(__('btn_refresh')) ?>', `${result.count} Vouchers geladen`);
|
if (syncFirst) showToast('success', '<?= addslashes(__('btn_refresh')) ?>', <?= json_encode(__('vouchers_loaded')) ?>.replace('{count}', result.count));
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)" aria-hidden="true"></i><p>${result.message}</p></div>`;
|
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>${result.message}</p></div>`;
|
||||||
document.getElementById('statsContainer').style.display = 'none';
|
document.getElementById('statsContainer').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)" aria-hidden="true"></i><p>Verbindungsfehler: ${e.message}</p></div>`;
|
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>Verbindungsfehler: ${e.message}</p></div>`;
|
||||||
document.getElementById('statsContainer').style.display = 'none';
|
document.getElementById('statsContainer').style.display = 'none';
|
||||||
}
|
}
|
||||||
refreshBtn.disabled = false;
|
refreshBtn.disabled = false;
|
||||||
refreshBtn.innerHTML = '<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= addslashes(__('btn_refresh')) ?>';
|
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= addslashes(__('btn_refresh')) ?>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStats() {
|
function updateStats() {
|
||||||
|
|
@ -295,7 +354,7 @@ function renderVouchers() {
|
||||||
const pageVouchers = vouchers.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE);
|
const pageVouchers = vouchers.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE);
|
||||||
|
|
||||||
if (vouchers.length === 0) {
|
if (vouchers.length === 0) {
|
||||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`;
|
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,7 +370,7 @@ function renderVouchers() {
|
||||||
<button class="filter-btn ${currentFilter==='expired'?'active':''}" data-filter="expired" onclick="setFilter('expired')"><?= __('vouchers_filter_expired') ?> (${expiredCnt})</button>
|
<button class="filter-btn ${currentFilter==='expired'?'active':''}" data-filter="expired" onclick="setFilter('expired')"><?= __('vouchers_filter_expired') ?> (${expiredCnt})</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-container"><table class="table table-stack">
|
<div class="table-container"><table class="table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th><?= __('label_created') ?></th>
|
<th><?= __('label_created') ?></th>
|
||||||
<th><?= __('label_code') ?></th>
|
<th><?= __('label_code') ?></th>
|
||||||
|
|
@ -332,21 +391,21 @@ function renderVouchers() {
|
||||||
})() : '';
|
})() : '';
|
||||||
const usagePct = v.quota > 0 ? Math.min(100, (v.used/v.quota)*100) : 0;
|
const usagePct = v.quota > 0 ? Math.min(100, (v.used/v.quota)*100) : 0;
|
||||||
const statusBadge = v.status==='valid'
|
const statusBadge = v.status==='valid'
|
||||||
? `<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_valid') ?></span>`
|
? `<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_valid') ?></span>`
|
||||||
: v.status==='used'
|
: v.status==='used'
|
||||||
? `<span class="badge badge-warning"><i class="fas fa-user-check" aria-hidden="true"></i> <?= __('status_used') ?></span>`
|
? `<span class="badge badge-warning"><i class="fas fa-user-check"></i> <?= __('status_used') ?></span>`
|
||||||
: `<span class="badge badge-danger"><i class="fas fa-times" aria-hidden="true"></i> <?= __('status_expired') ?></span>`;
|
: `<span class="badge badge-danger"><i class="fas fa-times"></i> <?= __('status_expired') ?></span>`;
|
||||||
|
|
||||||
html += `<tr id="voucher-${v._id}">
|
html += `<tr id="voucher-${v._id}">
|
||||||
<td data-label="<?= __('label_created') ?>"><strong>${createDate.toLocaleDateString(LOCALE)}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString(LOCALE,{hour:'2-digit',minute:'2-digit'})}</small></td>
|
<td><strong>${createDate.toLocaleDateString('de-DE')}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}</small></td>
|
||||||
<td data-label="<?= __('label_code') ?>"><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','<?= __('js_copied') ?>')" title="<?= __('js_copy') ?>" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
|
<td><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','<?= addslashes(__('toast_copied')) ?>')" title="<?= addslashes(__('click_to_copy')) ?>" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
|
||||||
<td class="voucher-note" data-label="<?= __('label_note') ?>" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
|
<td class="voucher-note" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
|
||||||
<td data-label="<?= __('label_status') ?>">${statusBadge}</td>
|
<td>${statusBadge}</td>
|
||||||
<td data-label="<?= __('label_usage') ?>"><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
|
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
|
||||||
<td data-label="<?= __('label_expires') ?>">${remaining?`<span style="color:var(--success)"><i class="fas fa-clock" aria-hidden="true"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} <?= __('label_minutes_short') ?></small></td>
|
<td>${remaining?`<span style="color:var(--success)"><i class="fas fa-clock"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} Min.</small></td>
|
||||||
<td data-label="<?= __('label_actions') ?>" style="white-space:nowrap;">
|
<td style="white-space:nowrap;">
|
||||||
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="<?= __('vouchers_resend') ?>" aria-label="<?= __('vouchers_resend') ?>"><i class="fas fa-envelope" aria-hidden="true"></i></button>
|
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="Per E-Mail senden"><i class="fas fa-envelope"></i></button>
|
||||||
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>"><i class="fas fa-trash" aria-hidden="true"></i></button>
|
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
|
|
@ -358,11 +417,11 @@ function renderVouchers() {
|
||||||
<span style="font-size:13px;color:var(--text-muted);">${<?= json_encode(__('vouchers_page_of')) ?>
|
<span style="font-size:13px;color:var(--text-muted);">${<?= json_encode(__('vouchers_page_of')) ?>
|
||||||
.replace('{current}',currentPage).replace('{total}',totalPages)} (${vouchers.length})</span>
|
.replace('{current}',currentPage).replace('{total}',totalPages)} (${vouchers.length})</span>
|
||||||
<div style="display:flex;gap:5px;">
|
<div style="display:flex;gap:5px;">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage-1})" ${currentPage<=1?'disabled':''}><i class="fas fa-chevron-left" aria-hidden="true"></i></button>`;
|
<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage-1})" ${currentPage<=1?'disabled':''}><i class="fas fa-chevron-left"></i></button>`;
|
||||||
for (let p=Math.max(1,currentPage-2); p<=Math.min(totalPages,currentPage+2); p++) {
|
for (let p=Math.max(1,currentPage-2); p<=Math.min(totalPages,currentPage+2); p++) {
|
||||||
html += `<button class="btn btn-sm ${p===currentPage?'btn-primary':'btn-secondary'}" onclick="setPage(${p})">${p}</button>`;
|
html += `<button class="btn btn-sm ${p===currentPage?'btn-primary':'btn-secondary'}" onclick="setPage(${p})">${p}</button>`;
|
||||||
}
|
}
|
||||||
html += `<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
|
html += `<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}><i class="fas fa-chevron-right"></i></button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -386,7 +445,7 @@ async function resendVoucher(voucherId, code) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteVoucher(voucherId) {
|
async function deleteVoucher(voucherId) {
|
||||||
if (!confirm('<?= __('js_confirm_delete_voucher') ?>')) return;
|
if (!confirm('<?= addslashes(__('confirm_delete_voucher')) ?>')) return;
|
||||||
const row = document.getElementById(`voucher-${voucherId}`);
|
const row = document.getElementById(`voucher-${voucherId}`);
|
||||||
if (row) row.classList.add('deleting');
|
if (row) row.classList.add('deleting');
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
2240
assets/global.css
|
|
@ -1,21 +1,7 @@
|
||||||
/* === DARK MODE ===
|
/* === DARK MODE === */
|
||||||
Reihenfolge: ausdrueckliche Auswahl des Nutzers > Systemeinstellung. */
|
|
||||||
(function() {
|
(function() {
|
||||||
const saved = localStorage.getItem('theme');
|
const saved = localStorage.getItem('theme') || 'light';
|
||||||
// Der Inline-Schnipsel im <head> hat das Theme bereits gesetzt – diese
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
// Entscheidung wird hier nicht überschrieben.
|
|
||||||
const current = document.documentElement.getAttribute('data-theme');
|
|
||||||
const system = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
||||||
document.documentElement.setAttribute('data-theme', saved || current || system);
|
|
||||||
|
|
||||||
// Solange nichts ausgewaehlt wurde, folgt die Oberflaeche dem System.
|
|
||||||
if (!saved && window.matchMedia) {
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
||||||
if (localStorage.getItem('theme')) return;
|
|
||||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
|
||||||
if (typeof updateDarkModeBtn === 'function') updateDarkModeBtn();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
|
|
@ -31,12 +17,7 @@ function updateDarkModeBtn() {
|
||||||
const btn = document.getElementById('darkModeBtn');
|
const btn = document.getElementById('darkModeBtn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
const icon = btn.querySelector('i');
|
btn.textContent = isDark ? '☀️' : '🌙';
|
||||||
if (icon) {
|
|
||||||
icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
|
|
||||||
} else {
|
|
||||||
btn.textContent = isDark ? '☀' : '☾';
|
|
||||||
}
|
|
||||||
btn.title = isDark ? 'Light Mode' : 'Dark Mode';
|
btn.title = isDark ? 'Light Mode' : 'Dark Mode';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,6 +35,9 @@ document.addEventListener('DOMContentLoaded', updateDarkModeBtn);
|
||||||
container.id = 'toast-container';
|
container.id = 'toast-container';
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
}
|
}
|
||||||
|
// Screenreader ueber neue Toasts informieren
|
||||||
|
container.setAttribute('role', 'status');
|
||||||
|
container.setAttribute('aria-live', 'polite');
|
||||||
}
|
}
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
@ -61,8 +45,8 @@ document.addEventListener('DOMContentLoaded', updateDarkModeBtn);
|
||||||
const icons = {
|
const icons = {
|
||||||
success: '✓',
|
success: '✓',
|
||||||
error: '✕',
|
error: '✕',
|
||||||
info: 'i',
|
info: 'ℹ',
|
||||||
warning: '!'
|
warning: '⚠'
|
||||||
};
|
};
|
||||||
|
|
||||||
window.showToast = function(type, title, message, duration) {
|
window.showToast = function(type, title, message, duration) {
|
||||||
|
|
@ -111,13 +95,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (overlay) overlay.addEventListener('click', closeMobileSidebar);
|
if (overlay) overlay.addEventListener('click', closeMobileSidebar);
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') closeMobileSidebar();
|
if (e.key === 'Escape') {
|
||||||
|
closeMobileSidebar();
|
||||||
|
// Offene Modals per Esc schliessen (Accessibility)
|
||||||
|
document.querySelectorAll('.modal.active').forEach(m => m.classList.remove('active'));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* === LANGUAGE SWITCHER === */
|
/* === LANGUAGE SWITCHER === */
|
||||||
function switchLanguage(lang) {
|
function switchLanguage(lang) {
|
||||||
fetch('?set_lang=' + lang, { method: 'GET' }).then(() => location.reload());
|
// Direkter Navigationswechsel statt fetch+reload: vermeidet den
|
||||||
|
// "Formular erneut senden?"-Dialog und erhaelt bestehende URL-Parameter.
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('set_lang', lang);
|
||||||
|
window.location.href = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === CLIPBOARD === */
|
/* === CLIPBOARD === */
|
||||||
|
|
|
||||||
30
assets/vendor/README.md
vendored
|
|
@ -1,30 +0,0 @@
|
||||||
# Lokale Drittanbieter-Assets
|
|
||||||
|
|
||||||
Alle Frontend-Bibliotheken werden aus diesem Ordner ausgeliefert. Damit gibt es
|
|
||||||
im Betrieb **keine Verbindungen zu externen CDNs** – wichtig für den Datenschutz
|
|
||||||
(keine IP-Übertragung an Dritte) und für abgeschottete Netze ohne Internetzugang.
|
|
||||||
|
|
||||||
| Ordner | Inhalt | Version | Lizenz |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `inter/` | Schriftschnitte 400/500/600/700 als woff2 | Inter 4.0 | SIL OFL 1.1 |
|
|
||||||
| `fontawesome/` | Solid- und Brands-Icons, gekürzte CSS (nur woff2) | Font Awesome Free 6.4.0 | Icons CC BY 4.0, Fonts SIL OFL 1.1, Code MIT |
|
|
||||||
| `chartjs/` | Diagramme für Dashboard und Reporting | Chart.js 4.4.0 | MIT |
|
|
||||||
| `qrcodejs/` | QR-Code-Erzeugung im Browser | qrcodejs 1.0.0 | MIT |
|
|
||||||
| `tinymce/` | WYSIWYG-Editor (GPL-Variante), auf die genutzten Plugins gekürzt | TinyMCE 6.8.3 | GPL-2.0-or-later |
|
|
||||||
|
|
||||||
Eingebunden werden sie über `Ui::head()` bzw. `Ui::script()` aus
|
|
||||||
`includes/Ui.php` – inklusive Versionsstempel (`?v=<filemtime>`), damit Browser
|
|
||||||
nach einem Update nicht die alten Dateien aus dem Cache verwenden.
|
|
||||||
|
|
||||||
## Aktualisieren
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Beispiel Chart.js
|
|
||||||
curl -L -o assets/vendor/chartjs/chart.umd.min.js \
|
|
||||||
https://cdn.jsdelivr.net/npm/chart.js@<version>/dist/chart.umd.min.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Bei TinyMCE werden nur `tinymce.min.js`, `themes/silver`, `models/dom`,
|
|
||||||
`icons/default`, die Skins `oxide`/`oxide-dark`, die deutsche Sprachdatei und
|
|
||||||
die tatsächlich genutzten Plugins übernommen (siehe `initTinyMCE()` in
|
|
||||||
`admin/settings.php`).
|
|
||||||
20
assets/vendor/chartjs/chart.umd.min.js
vendored
11
assets/vendor/fontawesome/fontawesome.css
vendored
BIN
assets/vendor/inter/Inter-Bold.woff2
vendored
BIN
assets/vendor/inter/Inter-Medium.woff2
vendored
BIN
assets/vendor/inter/Inter-Regular.woff2
vendored
BIN
assets/vendor/inter/Inter-SemiBold.woff2
vendored
30
assets/vendor/inter/inter.css
vendored
|
|
@ -1,30 +0,0 @@
|
||||||
/* Inter v4 – lokal ausgeliefert (SIL Open Font License 1.1).
|
|
||||||
Quelle: https://github.com/rsms/inter/releases – nur die vier genutzten Schnitte. */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('Inter-Regular.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('Inter-Medium.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('Inter-SemiBold.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('Inter-Bold.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
1
assets/vendor/qrcodejs/qrcode.min.js
vendored
406
assets/vendor/tinymce/langs/de.js
vendored
|
|
@ -1,406 +0,0 @@
|
||||||
tinymce.addI18n("de", {
|
|
||||||
"Redo": "Wiederholen",
|
|
||||||
"Undo": "R\xfcckg\xe4ngig machen",
|
|
||||||
"Cut": "Ausschneiden",
|
|
||||||
"Copy": "Kopieren",
|
|
||||||
"Paste": "Einf\xfcgen",
|
|
||||||
"Select all": "Alles ausw\xe4hlen",
|
|
||||||
"New document": "Neues Dokument",
|
|
||||||
"Ok": "Ok",
|
|
||||||
"Cancel": "Abbrechen",
|
|
||||||
"Visual aids": "Visuelle Hilfen",
|
|
||||||
"Bold": "Fett",
|
|
||||||
"Italic": "Kursiv",
|
|
||||||
"Underline": "Unterstrichen",
|
|
||||||
"Strikethrough": "Durchgestrichen",
|
|
||||||
"Superscript": "Hochgestellt",
|
|
||||||
"Subscript": "Tiefgestellt",
|
|
||||||
"Clear formatting": "Formatierung entfernen",
|
|
||||||
"Remove": "Entfernen",
|
|
||||||
"Align left": "Linksb\xfcndig ausrichten",
|
|
||||||
"Align center": "Zentrieren",
|
|
||||||
"Align right": "Rechtsb\xfcndig ausrichten",
|
|
||||||
"No alignment": "Keine Ausrichtung",
|
|
||||||
"Justify": "Blocksatz",
|
|
||||||
"Bullet list": "Aufz\xe4hlung",
|
|
||||||
"Numbered list": "Nummerierte Liste",
|
|
||||||
"Decrease indent": "Einzug verkleinern",
|
|
||||||
"Increase indent": "Einzug vergr\xf6\xdfern",
|
|
||||||
"Close": "Schlie\xdfen",
|
|
||||||
"Formats": "Formate",
|
|
||||||
"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X/C/V keyboard shortcuts instead.": "Ihr Browser unterst\xfctzt leider keinen direkten Zugriff auf die Zwischenablage. Bitte benutzen Sie die Tastenkombinationen Strg+X/C/V.",
|
|
||||||
"Headings": "\xdcberschriften",
|
|
||||||
"Heading 1": "\xdcberschrift 1",
|
|
||||||
"Heading 2": "\xdcberschrift 2",
|
|
||||||
"Heading 3": "\xdcberschrift 3",
|
|
||||||
"Heading 4": "\xdcberschrift 4",
|
|
||||||
"Heading 5": "\xdcberschrift 5",
|
|
||||||
"Heading 6": "\xdcberschrift 6",
|
|
||||||
"Preformatted": "Vorformatiert",
|
|
||||||
"Div": "Div",
|
|
||||||
"Pre": "Pre",
|
|
||||||
"Code": "Code",
|
|
||||||
"Paragraph": "Absatz",
|
|
||||||
"Blockquote": "Blockzitat",
|
|
||||||
"Inline": "Zeichenformate",
|
|
||||||
"Blocks": "Bl\xf6cke",
|
|
||||||
"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Einf\xfcgen ist nun im unformatierten Textmodus. Inhalte werden ab jetzt als unformatierter Text eingef\xfcgt, bis Sie diese Einstellung wieder deaktivieren.",
|
|
||||||
"Fonts": "Schriftarten",
|
|
||||||
"Font sizes": "Schriftgr\xf6\xdfen",
|
|
||||||
"Class": "Klasse",
|
|
||||||
"Browse for an image": "Bild...",
|
|
||||||
"OR": "ODER",
|
|
||||||
"Drop an image here": "Bild hier ablegen",
|
|
||||||
"Upload": "Hochladen",
|
|
||||||
"Uploading image": "Bild wird hochgeladen",
|
|
||||||
"Block": "Blocksatz",
|
|
||||||
"Align": "Ausrichtung",
|
|
||||||
"Default": "Standard",
|
|
||||||
"Circle": "Kreis",
|
|
||||||
"Disc": "Scheibe",
|
|
||||||
"Square": "Rechteck",
|
|
||||||
"Lower Alpha": "Lateinisches Alphabet in Kleinbuchstaben",
|
|
||||||
"Lower Greek": "Griechische Kleinbuchstaben",
|
|
||||||
"Lower Roman": "Kleiner r\xf6mischer Buchstabe",
|
|
||||||
"Upper Alpha": "Lateinisches Alphabet in Gro\xdfbuchstaben",
|
|
||||||
"Upper Roman": "Gro\xdfer r\xf6mischer Buchstabe",
|
|
||||||
"Anchor...": "Textmarke",
|
|
||||||
"Anchor": "Anker",
|
|
||||||
"Name": "Name",
|
|
||||||
"ID": "ID",
|
|
||||||
"ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Die ID muss mit einem Buchstaben beginnen gefolgt von Buchstaben, Zahlen, Bindestrichen, Punkten, Doppelpunkten oder Unterstrichen.",
|
|
||||||
"You have unsaved changes are you sure you want to navigate away?": "Die \xc4nderungen wurden noch nicht gespeichert. Sind Sie sicher, dass Sie diese Seite verlassen wollen?",
|
|
||||||
"Restore last draft": "Letzten Entwurf wiederherstellen",
|
|
||||||
"Special character...": "Sonderzeichen...",
|
|
||||||
"Special Character": "Sonderzeichen",
|
|
||||||
"Source code": "Quellcode",
|
|
||||||
"Insert/Edit code sample": "Codebeispiel einf\xfcgen/bearbeiten",
|
|
||||||
"Language": "Sprache",
|
|
||||||
"Code sample...": "Codebeispiel...",
|
|
||||||
"Left to right": "Von links nach rechts",
|
|
||||||
"Right to left": "Von rechts nach links",
|
|
||||||
"Title": "Titel",
|
|
||||||
"Fullscreen": "Vollbild",
|
|
||||||
"Action": "Aktion",
|
|
||||||
"Shortcut": "Tastenkombination",
|
|
||||||
"Help": "Hilfe",
|
|
||||||
"Address": "Adresse",
|
|
||||||
"Focus to menubar": "Fokus auf Men\xfcleiste",
|
|
||||||
"Focus to toolbar": "Fokus auf Symbolleiste",
|
|
||||||
"Focus to element path": "Fokus auf Elementpfad",
|
|
||||||
"Focus to contextual toolbar": "Fokus auf kontextbezogene Symbolleiste",
|
|
||||||
"Insert link (if link plugin activated)": "Link einf\xfcgen (wenn Link-Plugin aktiviert ist)",
|
|
||||||
"Save (if save plugin activated)": "Speichern (wenn Save-Plugin aktiviert ist)",
|
|
||||||
"Find (if searchreplace plugin activated)": "Suchen (wenn Suchen/Ersetzen-Plugin aktiviert ist)",
|
|
||||||
"Plugins installed ({0}):": "Installierte Plugins ({0}):",
|
|
||||||
"Premium plugins:": "Premium-Plugins:",
|
|
||||||
"Learn more...": "Erfahren Sie mehr dazu...",
|
|
||||||
"You are using {0}": "Sie verwenden {0}",
|
|
||||||
"Plugins": "Plugins",
|
|
||||||
"Handy Shortcuts": "Praktische Tastenkombinationen",
|
|
||||||
"Horizontal line": "Horizontale Linie",
|
|
||||||
"Insert/edit image": "Bild einf\xfcgen/bearbeiten",
|
|
||||||
"Alternative description": "Alternative Beschreibung",
|
|
||||||
"Accessibility": "Barrierefreiheit",
|
|
||||||
"Image is decorative": "Bild ist dekorativ",
|
|
||||||
"Source": "Quelle",
|
|
||||||
"Dimensions": "Abmessungen",
|
|
||||||
"Constrain proportions": "Seitenverh\xe4ltnis beibehalten",
|
|
||||||
"General": "Allgemein",
|
|
||||||
"Advanced": "Erweitert",
|
|
||||||
"Style": "Formatvorlage",
|
|
||||||
"Vertical space": "Vertikaler Raum",
|
|
||||||
"Horizontal space": "Horizontaler Raum",
|
|
||||||
"Border": "Rahmen",
|
|
||||||
"Insert image": "Bild einf\xfcgen",
|
|
||||||
"Image...": "Bild...",
|
|
||||||
"Image list": "Bildliste",
|
|
||||||
"Resize": "Skalieren",
|
|
||||||
"Insert date/time": "Datum/Uhrzeit einf\xfcgen",
|
|
||||||
"Date/time": "Datum/Uhrzeit",
|
|
||||||
"Insert/edit link": "Link einf\xfcgen/bearbeiten",
|
|
||||||
"Text to display": "Anzuzeigender Text",
|
|
||||||
"Url": "URL",
|
|
||||||
"Open link in...": "Link \xf6ffnen in...",
|
|
||||||
"Current window": "Aktuelles Fenster",
|
|
||||||
"None": "Keine",
|
|
||||||
"New window": "Neues Fenster",
|
|
||||||
"Open link": "Link \xf6ffnen",
|
|
||||||
"Remove link": "Link entfernen",
|
|
||||||
"Anchors": "Anker",
|
|
||||||
"Link...": "Link...",
|
|
||||||
"Paste or type a link": "Link einf\xfcgen oder eingeben",
|
|
||||||
"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "Diese URL scheint eine E-Mail-Adresse zu sein. M\xf6chten Sie das dazu ben\xf6tigte mailto: voranstellen?",
|
|
||||||
"The URL you entered seems to be an external link. Do you want to add the required http:// prefix?": "Diese URL scheint ein externer Link zu sein. M\xf6chten Sie das dazu ben\xf6tigte http:// voranstellen?",
|
|
||||||
"The URL you entered seems to be an external link. Do you want to add the required https:// prefix?": "Die eingegebene URL scheint ein externer Link zu sein. Soll das fehlende https:// davor erg\xe4nzt werden?",
|
|
||||||
"Link list": "Linkliste",
|
|
||||||
"Insert video": "Video einf\xfcgen",
|
|
||||||
"Insert/edit video": "Video einf\xfcgen/bearbeiten",
|
|
||||||
"Insert/edit media": "Medien einf\xfcgen/bearbeiten",
|
|
||||||
"Alternative source": "Alternative Quelle",
|
|
||||||
"Alternative source URL": "URL der alternativen Quelle",
|
|
||||||
"Media poster (Image URL)": "Medienposter (Bild-URL)",
|
|
||||||
"Paste your embed code below:": "F\xfcgen Sie Ihren Einbettungscode unten ein:",
|
|
||||||
"Embed": "Einbettung",
|
|
||||||
"Media...": "Medien...",
|
|
||||||
"Nonbreaking space": "Gesch\xfctztes Leerzeichen",
|
|
||||||
"Page break": "Seitenumbruch",
|
|
||||||
"Paste as text": "Als Text einf\xfcgen",
|
|
||||||
"Preview": "Vorschau",
|
|
||||||
"Print": "Drucken",
|
|
||||||
"Print...": "Drucken...",
|
|
||||||
"Save": "Speichern",
|
|
||||||
"Find": "Suchen",
|
|
||||||
"Replace with": "Ersetzen durch",
|
|
||||||
"Replace": "Ersetzen",
|
|
||||||
"Replace all": "Alle ersetzen",
|
|
||||||
"Previous": "Vorherige",
|
|
||||||
"Next": "N\xe4chste",
|
|
||||||
"Find and Replace": "Suchen und Ersetzen",
|
|
||||||
"Find and replace...": "Suchen und ersetzen...",
|
|
||||||
"Could not find the specified string.": "Die angegebene Zeichenfolge wurde nicht gefunden.",
|
|
||||||
"Match case": "Gro\xdf-/Kleinschreibung beachten",
|
|
||||||
"Find whole words only": "Nur ganze W\xf6rter suchen",
|
|
||||||
"Find in selection": "In Auswahl suchen",
|
|
||||||
"Insert table": "Tabelle einf\xfcgen",
|
|
||||||
"Table properties": "Tabelleneigenschaften",
|
|
||||||
"Delete table": "Tabelle l\xf6schen",
|
|
||||||
"Cell": "Zelle",
|
|
||||||
"Row": "Zeile",
|
|
||||||
"Column": "Spalte",
|
|
||||||
"Cell properties": "Zelleigenschaften",
|
|
||||||
"Merge cells": "Zellen verbinden",
|
|
||||||
"Split cell": "Zelle aufteilen",
|
|
||||||
"Insert row before": "Neue Zeile davor einf\xfcgen",
|
|
||||||
"Insert row after": "Neue Zeile danach einf\xfcgen",
|
|
||||||
"Delete row": "Zeile l\xf6schen",
|
|
||||||
"Row properties": "Zeileneigenschaften",
|
|
||||||
"Cut row": "Zeile ausschneiden",
|
|
||||||
"Cut column": "Spalte ausschneiden",
|
|
||||||
"Copy row": "Zeile kopieren",
|
|
||||||
"Copy column": "Spalte kopieren",
|
|
||||||
"Paste row before": "Zeile davor einf\xfcgen",
|
|
||||||
"Paste column before": "Spalte davor einf\xfcgen",
|
|
||||||
"Paste row after": "Zeile danach einf\xfcgen",
|
|
||||||
"Paste column after": "Spalte danach einf\xfcgen",
|
|
||||||
"Insert column before": "Neue Spalte davor einf\xfcgen",
|
|
||||||
"Insert column after": "Neue Spalte danach einf\xfcgen",
|
|
||||||
"Delete column": "Spalte l\xf6schen",
|
|
||||||
"Cols": "Spalten",
|
|
||||||
"Rows": "Zeilen",
|
|
||||||
"Width": "Breite",
|
|
||||||
"Height": "H\xf6he",
|
|
||||||
"Cell spacing": "Zellenabstand",
|
|
||||||
"Cell padding": "Zelleninnenabstand",
|
|
||||||
"Row clipboard actions": "Zeilen-Zwischenablage-Aktionen",
|
|
||||||
"Column clipboard actions": "Spalten-Zwischenablage-Aktionen",
|
|
||||||
"Table styles": "Tabellenstil",
|
|
||||||
"Cell styles": "Zellstil",
|
|
||||||
"Column header": "Spaltenkopf",
|
|
||||||
"Row header": "Zeilenkopf",
|
|
||||||
"Table caption": "Tabellenbeschriftung",
|
|
||||||
"Caption": "Beschriftung",
|
|
||||||
"Show caption": "Beschriftung anzeigen",
|
|
||||||
"Left": "Links",
|
|
||||||
"Center": "Zentriert",
|
|
||||||
"Right": "Rechts",
|
|
||||||
"Cell type": "Zelltyp",
|
|
||||||
"Scope": "Bereich",
|
|
||||||
"Alignment": "Ausrichtung",
|
|
||||||
"Horizontal align": "Horizontal ausrichten",
|
|
||||||
"Vertical align": "Vertikal ausrichten",
|
|
||||||
"Top": "Oben",
|
|
||||||
"Middle": "Mitte",
|
|
||||||
"Bottom": "Unten",
|
|
||||||
"Header cell": "Kopfzelle",
|
|
||||||
"Row group": "Zeilengruppe",
|
|
||||||
"Column group": "Spaltengruppe",
|
|
||||||
"Row type": "Zeilentyp",
|
|
||||||
"Header": "Kopfzeile",
|
|
||||||
"Body": "Inhalt",
|
|
||||||
"Footer": "Fu\xdfzeile",
|
|
||||||
"Border color": "Rahmenfarbe",
|
|
||||||
"Solid": "Durchgezogen",
|
|
||||||
"Dotted": "Gepunktet",
|
|
||||||
"Dashed": "Gestrichelt",
|
|
||||||
"Double": "Doppelt",
|
|
||||||
"Groove": "Gekantet",
|
|
||||||
"Ridge": "Eingeritzt",
|
|
||||||
"Inset": "Eingelassen",
|
|
||||||
"Outset": "Hervorstehend",
|
|
||||||
"Hidden": "Unsichtbar",
|
|
||||||
"Insert template...": "Vorlage einf\xfcgen...",
|
|
||||||
"Templates": "Vorlagen",
|
|
||||||
"Template": "Vorlage",
|
|
||||||
"Insert Template": "Vorlage einf\xfcgen",
|
|
||||||
"Text color": "Textfarbe",
|
|
||||||
"Background color": "Hintergrundfarbe",
|
|
||||||
"Custom...": "Benutzerdefiniert...",
|
|
||||||
"Custom color": "Benutzerdefinierte Farbe",
|
|
||||||
"No color": "Keine Farbe",
|
|
||||||
"Remove color": "Farbauswahl aufheben",
|
|
||||||
"Show blocks": "Bl\xf6cke anzeigen",
|
|
||||||
"Show invisible characters": "Unsichtbare Zeichen anzeigen",
|
|
||||||
"Word count": "Anzahl der W\xf6rter",
|
|
||||||
"Count": "Anzahl",
|
|
||||||
"Document": "Dokument",
|
|
||||||
"Selection": "Auswahl",
|
|
||||||
"Words": "W\xf6rter",
|
|
||||||
"Words: {0}": "Wortzahl: {0}",
|
|
||||||
"{0} words": "{0} W\xf6rter",
|
|
||||||
"File": "Datei",
|
|
||||||
"Edit": "Bearbeiten",
|
|
||||||
"Insert": "Einf\xfcgen",
|
|
||||||
"View": "Ansicht",
|
|
||||||
"Format": "Format",
|
|
||||||
"Table": "Tabelle",
|
|
||||||
"Tools": "Werkzeuge",
|
|
||||||
"Powered by {0}": "Betrieben von {0}",
|
|
||||||
"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Rich-Text-Bereich. Dr\xfccken Sie Alt+F9 f\xfcr das Men\xfc. Dr\xfccken Sie Alt+F10 f\xfcr die Symbolleiste. Dr\xfccken Sie Alt+0 f\xfcr Hilfe.",
|
|
||||||
"Image title": "Bildtitel",
|
|
||||||
"Border width": "Rahmenbreite",
|
|
||||||
"Border style": "Rahmenstil",
|
|
||||||
"Error": "Fehler",
|
|
||||||
"Warn": "Warnung",
|
|
||||||
"Valid": "G\xfcltig",
|
|
||||||
"To open the popup, press Shift+Enter": "Dr\xfccken Sie Umschalt+Eingabe, um das Popup-Fenster zu \xf6ffnen.",
|
|
||||||
"Rich Text Area": "Rich-Text-Area",
|
|
||||||
"Rich Text Area. Press ALT-0 for help.": "Rich-Text-Bereich. Dr\xfccken Sie Alt+0 f\xfcr Hilfe.",
|
|
||||||
"System Font": "Betriebssystemschriftart",
|
|
||||||
"Failed to upload image: {0}": "Bild konnte nicht hochgeladen werden: {0}",
|
|
||||||
"Failed to load plugin: {0} from url {1}": "Plugin konnte nicht geladen werden: {0} von URL {1}",
|
|
||||||
"Failed to load plugin url: {0}": "Plugin-URL konnte nicht geladen werden: {0}",
|
|
||||||
"Failed to initialize plugin: {0}": "Plugin konnte nicht initialisiert werden: {0}",
|
|
||||||
"example": "Beispiel",
|
|
||||||
"Search": "Suchen",
|
|
||||||
"All": "Alle",
|
|
||||||
"Currency": "W\xe4hrung",
|
|
||||||
"Text": "Text",
|
|
||||||
"Quotations": "Anf\xfchrungszeichen",
|
|
||||||
"Mathematical": "Mathematisch",
|
|
||||||
"Extended Latin": "Erweitertes Latein",
|
|
||||||
"Symbols": "Symbole",
|
|
||||||
"Arrows": "Pfeile",
|
|
||||||
"User Defined": "Benutzerdefiniert",
|
|
||||||
"dollar sign": "Dollarzeichen",
|
|
||||||
"currency sign": "W\xe4hrungssymbol",
|
|
||||||
"euro-currency sign": "Eurozeichen",
|
|
||||||
"colon sign": "Doppelpunkt",
|
|
||||||
"cruzeiro sign": "Cruzeirozeichen",
|
|
||||||
"french franc sign": "Franczeichen",
|
|
||||||
"lira sign": "Lirezeichen",
|
|
||||||
"mill sign": "Millzeichen",
|
|
||||||
"naira sign": "Nairazeichen",
|
|
||||||
"peseta sign": "Pesetazeichen",
|
|
||||||
"rupee sign": "Rupiezeichen",
|
|
||||||
"won sign": "Wonzeichen",
|
|
||||||
"new sheqel sign": "Schekelzeichen",
|
|
||||||
"dong sign": "Dongzeichen",
|
|
||||||
"kip sign": "Kipzeichen",
|
|
||||||
"tugrik sign": "Tugrikzeichen",
|
|
||||||
"drachma sign": "Drachmezeichen",
|
|
||||||
"german penny symbol": "Pfennigzeichen",
|
|
||||||
"peso sign": "Pesozeichen",
|
|
||||||
"guarani sign": "Guaranizeichen",
|
|
||||||
"austral sign": "Australzeichen",
|
|
||||||
"hryvnia sign": "Hrywnjazeichen",
|
|
||||||
"cedi sign": "Cedizeichen",
|
|
||||||
"livre tournois sign": "Livrezeichen",
|
|
||||||
"spesmilo sign": "Spesmilozeichen",
|
|
||||||
"tenge sign": "Tengezeichen",
|
|
||||||
"indian rupee sign": "Indisches Rupiezeichen",
|
|
||||||
"turkish lira sign": "T\xfcrkisches Lirazeichen",
|
|
||||||
"nordic mark sign": "Zeichen nordische Mark",
|
|
||||||
"manat sign": "Manatzeichen",
|
|
||||||
"ruble sign": "Rubelzeichen",
|
|
||||||
"yen character": "Yenzeichen",
|
|
||||||
"yuan character": "Yuanzeichen",
|
|
||||||
"yuan character, in hong kong and taiwan": "Yuanzeichen in Hongkong und Taiwan",
|
|
||||||
"yen/yuan character variant one": "Yen-/Yuanzeichen Variante 1",
|
|
||||||
"Emojis": "Emojis",
|
|
||||||
"Emojis...": "Emojis...",
|
|
||||||
"Loading emojis...": "Lade Emojis...",
|
|
||||||
"Could not load emojis": "Emojis konnten nicht geladen werden",
|
|
||||||
"People": "Menschen",
|
|
||||||
"Animals and Nature": "Tiere und Natur",
|
|
||||||
"Food and Drink": "Essen und Trinken",
|
|
||||||
"Activity": "Aktivit\xe4t",
|
|
||||||
"Travel and Places": "Reisen und Orte",
|
|
||||||
"Objects": "Objekte",
|
|
||||||
"Flags": "Flaggen",
|
|
||||||
"Characters": "Zeichen",
|
|
||||||
"Characters (no spaces)": "Zeichen (ohne Leerzeichen)",
|
|
||||||
"{0} characters": "{0}\xa0Zeichen",
|
|
||||||
"Error: Form submit field collision.": "Fehler: Kollision der Formularbest\xe4tigungsfelder.",
|
|
||||||
"Error: No form element found.": "Fehler: Kein Formularelement gefunden.",
|
|
||||||
"Color swatch": "Farbpalette",
|
|
||||||
"Color Picker": "Farbwahl",
|
|
||||||
"Invalid hex color code: {0}": "Ung\xfcltiger Hexadezimal-Farbwert: {0}",
|
|
||||||
"Invalid input": "Ung\xfcltige Eingabe",
|
|
||||||
"R": "R",
|
|
||||||
"Red component": "Rotanteil",
|
|
||||||
"G": "G",
|
|
||||||
"Green component": "Gr\xfcnanteil",
|
|
||||||
"B": "B",
|
|
||||||
"Blue component": "Blauanteil",
|
|
||||||
"#": "#",
|
|
||||||
"Hex color code": "Hexadezimal-Farbwert",
|
|
||||||
"Range 0 to 255": "Spanne 0 bis 255",
|
|
||||||
"Turquoise": "T\xfcrkis",
|
|
||||||
"Green": "Gr\xfcn",
|
|
||||||
"Blue": "Blau",
|
|
||||||
"Purple": "Violett",
|
|
||||||
"Navy Blue": "Marineblau",
|
|
||||||
"Dark Turquoise": "Dunkelt\xfcrkis",
|
|
||||||
"Dark Green": "Dunkelgr\xfcn",
|
|
||||||
"Medium Blue": "Mittleres Blau",
|
|
||||||
"Medium Purple": "Mittelviolett",
|
|
||||||
"Midnight Blue": "Mitternachtsblau",
|
|
||||||
"Yellow": "Gelb",
|
|
||||||
"Orange": "Orange",
|
|
||||||
"Red": "Rot",
|
|
||||||
"Light Gray": "Hellgrau",
|
|
||||||
"Gray": "Grau",
|
|
||||||
"Dark Yellow": "Dunkelgelb",
|
|
||||||
"Dark Orange": "Dunkelorange",
|
|
||||||
"Dark Red": "Dunkelrot",
|
|
||||||
"Medium Gray": "Mittelgrau",
|
|
||||||
"Dark Gray": "Dunkelgrau",
|
|
||||||
"Light Green": "Hellgr\xfcn",
|
|
||||||
"Light Yellow": "Hellgelb",
|
|
||||||
"Light Red": "Hellrot",
|
|
||||||
"Light Purple": "Helllila",
|
|
||||||
"Light Blue": "Hellblau",
|
|
||||||
"Dark Purple": "Dunkellila",
|
|
||||||
"Dark Blue": "Dunkelblau",
|
|
||||||
"Black": "Schwarz",
|
|
||||||
"White": "Wei\xdf",
|
|
||||||
"Switch to or from fullscreen mode": "Vollbildmodus umschalten",
|
|
||||||
"Open help dialog": "Hilfe-Dialog \xf6ffnen",
|
|
||||||
"history": "Historie",
|
|
||||||
"styles": "Stile",
|
|
||||||
"formatting": "Formatierung",
|
|
||||||
"alignment": "Ausrichtung",
|
|
||||||
"indentation": "Einr\xfcckungen",
|
|
||||||
"Font": "Schriftart",
|
|
||||||
"Size": "Schriftgr\xf6\xdfe",
|
|
||||||
"More...": "Mehr...",
|
|
||||||
"Select...": "Auswahl...",
|
|
||||||
"Preferences": "Einstellungen",
|
|
||||||
"Yes": "Ja",
|
|
||||||
"No": "Nein",
|
|
||||||
"Keyboard Navigation": "Tastaturnavigation",
|
|
||||||
"Version": "Version",
|
|
||||||
"Code view": "Code Ansicht",
|
|
||||||
"Open popup menu for split buttons": "\xd6ffne Popup Menge um Buttons zu trennen",
|
|
||||||
"List Properties": "Liste Eigenschaften",
|
|
||||||
"List properties...": "Liste Eigenschaften",
|
|
||||||
"Start list at number": "Beginne Liste mit Nummer",
|
|
||||||
"Line height": "Liniendicke",
|
|
||||||
"Dropped file type is not supported": "Hereingezogener Dateityp wird nicht unterst\xfctzt",
|
|
||||||
"Loading...": "Wird geladen...",
|
|
||||||
"ImageProxy HTTP error: Rejected request": "Image Proxy HTTP Fehler: Abgewiesene Anfrage",
|
|
||||||
"ImageProxy HTTP error: Could not find Image Proxy": "Image Proxy HTTP Fehler: Kann Image Proxy nicht finden",
|
|
||||||
"ImageProxy HTTP error: Incorrect Image Proxy URL": "Image Proxy HTTP Fehler: Falsche Image Proxy URL",
|
|
||||||
"ImageProxy HTTP error: Unknown ImageProxy error": "Image Proxy HTTP Fehler: Unbekannter Image Proxy Fehler"
|
|
||||||
});
|
|
||||||
21
assets/vendor/tinymce/license.txt
vendored
|
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
/**
|
|
||||||
* TinyMCE version 6.8.3 (2024-02-08)
|
|
||||||
*/
|
|
||||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),i=t=>null==t,l=t=>!i(t);var o=tinymce.util.Tools.resolve("tinymce.util.Tools");class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return l(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=t=>e=>l(e)&&t.test(e.nodeName),d=u(/^(OL|UL|DL)$/),g=u(/^(TH|TD)$/),c=t=>i(t)||"default"===t?"":t,h=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,(r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let e=0,n=t.length;e<n;e++){const n=t[e];if(d(r=n)&&!/\btox\-/.test(r.className))return a.some(n);if(s(n,e))break}var r;return a.none()})(e,0,g).exists((e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e))))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)&&t.selection.isEditable()})(t,n)&&t.selection.isEditable())})(t,r.parents))),m=(t,s,r,n,i,l)=>{l.length>1?((t,s,r,n,i,l)=>{t.ui.registry.addSplitButton(s,{tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:t=>{t(o.map(l,(t=>{const e="OL"===i?"num":"bull",s="disc"===t||"decimal"===t?"default":t,r=c(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,(t=>t.toUpperCase())))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}})))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,i,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return a.from(s)})(t);return s.map((t=>e===t)).getOr(!1)},onSetup:h(t,i)})})(t,s,r,n,i,l):((t,s,r,n,i,l)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",onSetup:h(t,i),onAction:()=>t.queryCommandState(n)||""===l?t.execCommand(n):e(t,i,l)})})(t,s,r,n,i,c(l[0]))};t.add("advlist",(t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,circle,square".split(",")})})(t),(t=>{m(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),m(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",((s,r)=>{e(t,"UL",r["list-style-type"])})),t.addCommand("ApplyOrderedListStyle",((s,r)=>{e(t,"OL",r["list-style-type"])}))})(t)):console.error("Please use the Lists plugin together with the Advanced List plugin.")}))}();
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
/**
|
|
||||||
* TinyMCE version 6.8.3 (2024-02-08)
|
|
||||||
*/
|
|
||||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),n=t("autolink_pattern"),o=t("link_default_target"),r=t("link_default_protocol"),a=t("allow_unsafe_link_target"),s=("string",e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e));const l=(void 0,e=>undefined===e);const i=e=>!(e=>null==e)(e),c=Object.hasOwnProperty,d=e=>"\ufeff"===e;var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!d(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=n(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]"))return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),c.call(t,n))||"false"===i.getContentEditable(e);var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return $="www.",(b=t).length>=4&&b.substr(0,4)===$?t=r(e)+"://"+t:((e,t,n=0,o)=>{const r=e.indexOf(t,n);return-1!==r&&(!!l(o)||r+t.length<=o)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,$;return null},k=(e,t)=>{const{dom:n,selection:r}=e,{rng:l,url:i}=t,c=r.getBookmark();r.setRng(l);const d="createlink",u={command:d,ui:!1,value:i};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(d,!1,i),e.dispatch("ExecCommand",u);const t=o(e);if(s(t)){const o=r.getNode();n.setAttrib(o,"target",t),"_blank"!==t||a(e)||n.setAttrib(o,"rel","noopener")}}r.moveToBookmark(c),e.nodeChanged()},p=e=>{const t=m(e,-1);i(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);i(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}();
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
/**
|
|
||||||
* TinyMCE version 6.8.3 (2024-02-08)
|
|
||||||
*/
|
|
||||||
!function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",(e=>((e=>{e.addCommand("mceCodeEditor",(()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact((()=>{e.setContent(o)})),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)}))})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{})))}();
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.de',
|
|
||||||
'<h1>Grundlagen der Tastaturnavigation</h1>\n' +
|
|
||||||
'\n' +
|
|
||||||
'<dl>\n' +
|
|
||||||
' <dt>Fokus auf Menüleiste</dt>\n' +
|
|
||||||
' <dd>Windows oder Linux: ALT+F9</dd>\n' +
|
|
||||||
' <dd>macOS: ⌥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');
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.en',
|
|
||||||
'<h1>Begin keyboard navigation</h1>\n' +
|
|
||||||
'\n' +
|
|
||||||
'<dl>\n' +
|
|
||||||
' <dt>Focus the Menu bar</dt>\n' +
|
|
||||||
' <dd>Windows or Linux: Alt+F9</dd>\n' +
|
|
||||||
' <dd>macOS: ⌥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');
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
/**
|
|
||||||
* TinyMCE version 6.8.3 (2024-02-08)
|
|
||||||
*/
|
|
||||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const s=(t,s,o)=>{t.dom.toggleClass(t.getBody(),"mce-visualblocks"),o.set(!o.get()),((t,s)=>{t.dispatch("VisualBlocks",{state:s})})(t,o.get())},o=("visualblocks_default_state",t=>t.options.get("visualblocks_default_state"));const e=(t,s)=>o=>{o.setActive(s.get());const e=t=>o.setActive(t.state);return t.on("VisualBlocks",e),()=>t.off("VisualBlocks",e)};t.add("visualblocks",((t,l)=>{(t=>{(0,t.options.register)("visualblocks_default_state",{processor:"boolean",default:!1})})(t);const a=(t=>{let s=!1;return{get:()=>s,set:t=>{s=t}}})();((t,o,e)=>{t.addCommand("mceVisualBlocks",(()=>{s(t,0,e)}))})(t,0,a),((t,s)=>{const o=()=>t.execCommand("mceVisualBlocks");t.ui.registry.addToggleButton("visualblocks",{icon:"visualblocks",tooltip:"Show blocks",onAction:o,onSetup:e(t,s)}),t.ui.registry.addToggleMenuItem("visualblocks",{text:"Show blocks",icon:"visualblocks",onAction:o,onSetup:e(t,s)})})(t,a),((t,e,l)=>{t.on("PreviewFormats AfterPreviewFormats",(s=>{l.get()&&t.dom.toggleClass(t.getBody(),"mce-visualblocks","afterpreviewformats"===s.type)})),t.on("init",(()=>{o(t)&&s(t,0,l)}))})(t,0,a)}))}();
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
body{background-color:#222f3e;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}
|
|
||||||
4
assets/vendor/tinymce/tinymce.min.js
vendored
|
|
@ -2,17 +2,6 @@
|
||||||
"name": "friloo/unifi-voucher-tool",
|
"name": "friloo/unifi-voucher-tool",
|
||||||
"description": "Webbasiertes WLAN-Voucher-Management für UniFi OS",
|
"description": "Webbasiertes WLAN-Voucher-Management für UniFi OS",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool",
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Friederich Loheide",
|
|
||||||
"homepage": "https://loheide.eu"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool/issues",
|
|
||||||
"source": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool"
|
|
||||||
},
|
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=7.4"
|
"php": ">=7.4"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,8 @@ if (empty($cronToken)) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($providedToken !== $cronToken) {
|
// hash_equals: zeitkonstanter Vergleich (kein Timing-Seitenkanal)
|
||||||
|
if (!hash_equals((string)$cronToken, (string)$providedToken)) {
|
||||||
outputResponse([
|
outputResponse([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Ungültiger Token'
|
'message' => 'Ungültiger Token'
|
||||||
|
|
@ -174,7 +175,8 @@ try {
|
||||||
$site['unifi_controller_url'],
|
$site['unifi_controller_url'],
|
||||||
$site['unifi_username'],
|
$site['unifi_username'],
|
||||||
Crypto::decrypt($site['unifi_password']),
|
Crypto::decrypt($site['unifi_password']),
|
||||||
$site['site_id']
|
$site['site_id'],
|
||||||
|
$site['ssl_verify'] ?? 0
|
||||||
);
|
);
|
||||||
|
|
||||||
$stats = $controller->syncVouchersToDatabase($db, $site['id']);
|
$stats = $controller->syncVouchersToDatabase($db, $site['id']);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
<?php
|
<?php
|
||||||
// Minimaler Test für Cron-Sync Debugging
|
// Minimaler Test für Cron-Sync Debugging
|
||||||
|
// Nur fuer angemeldete Admins zugaenglich (leakt sonst DB-Schema & Token-Status)
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
|
require_once __DIR__ . '/includes/Auth.php';
|
||||||
|
$cronTestAuth = new Auth();
|
||||||
|
$cronTestAuth->requireAdmin();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
echo json_encode(['step' => 1, 'message' => 'PHP läuft']);
|
echo json_encode(['step' => 1, 'message' => 'PHP läuft']);
|
||||||
|
|
|
||||||
43
database.sql
|
|
@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `sites` (
|
||||||
`unifi_password` VARCHAR(255) NOT NULL,
|
`unifi_password` VARCHAR(255) NOT NULL,
|
||||||
`is_active` TINYINT(1) DEFAULT 1,
|
`is_active` TINYINT(1) DEFAULT 1,
|
||||||
`public_access` TINYINT(1) DEFAULT 0,
|
`public_access` TINYINT(1) DEFAULT 0,
|
||||||
|
`ssl_verify` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
INDEX `idx_active` (`is_active`)
|
INDEX `idx_active` (`is_active`)
|
||||||
|
|
@ -66,34 +67,6 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` (
|
||||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `kiosks` (
|
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
|
||||||
`site_id` INT NOT NULL,
|
|
||||||
`template_id` INT NULL,
|
|
||||||
`name` VARCHAR(255) NOT NULL,
|
|
||||||
`token` VARCHAR(64) NOT NULL,
|
|
||||||
`headline` VARCHAR(255) NULL,
|
|
||||||
`subline` VARCHAR(500) NULL,
|
|
||||||
`logo_url` VARCHAR(500) NULL,
|
|
||||||
`background_url` VARCHAR(500) NULL,
|
|
||||||
`bg_overlay` TINYINT NOT NULL DEFAULT 45,
|
|
||||||
`accent_color` VARCHAR(7) NULL,
|
|
||||||
`card_style` ENUM('light','dark') NOT NULL DEFAULT 'light',
|
|
||||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
`daily_limit` INT NOT NULL DEFAULT 100,
|
|
||||||
`cooldown_seconds` INT NOT NULL DEFAULT 20,
|
|
||||||
`display_seconds` INT NOT NULL DEFAULT 90,
|
|
||||||
`last_used_at` TIMESTAMP NULL,
|
|
||||||
`created_by` INT NULL,
|
|
||||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE KEY `uniq_token` (`token`),
|
|
||||||
INDEX `idx_site` (`site_id`),
|
|
||||||
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (`template_id`) REFERENCES `voucher_templates`(`id`) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `api_keys` (
|
CREATE TABLE IF NOT EXISTS `api_keys` (
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
`name` VARCHAR(255) NOT NULL,
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
|
@ -121,7 +94,6 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
`site_id` INT NOT NULL,
|
`site_id` INT NOT NULL,
|
||||||
`user_id` INT,
|
`user_id` INT,
|
||||||
`kiosk_id` INT NULL,
|
|
||||||
`voucher_code` VARCHAR(50) NOT NULL,
|
`voucher_code` VARCHAR(50) NOT NULL,
|
||||||
`voucher_name` VARCHAR(255) NOT NULL,
|
`voucher_name` VARCHAR(255) NOT NULL,
|
||||||
`max_uses` INT NOT NULL,
|
`max_uses` INT NOT NULL,
|
||||||
|
|
@ -138,8 +110,7 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
|
||||||
INDEX `idx_site` (`site_id`),
|
INDEX `idx_site` (`site_id`),
|
||||||
INDEX `idx_created` (`created_at`),
|
INDEX `idx_created` (`created_at`),
|
||||||
INDEX `idx_unifi_id` (`unifi_voucher_id`),
|
INDEX `idx_unifi_id` (`unifi_voucher_id`),
|
||||||
INDEX `idx_status` (`status`),
|
INDEX `idx_status` (`status`)
|
||||||
INDEX `idx_kiosk` (`kiosk_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `sessions` (
|
CREATE TABLE IF NOT EXISTS `sessions` (
|
||||||
|
|
@ -177,6 +148,16 @@ CREATE TABLE IF NOT EXISTS `audit_log` (
|
||||||
INDEX `idx_created` (`created_at`)
|
INDEX `idx_created` (`created_at`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- IP-basiertes Request-Throttling (anonyme Voucher-Erstellung, Passwort-Resets)
|
||||||
|
CREATE TABLE IF NOT EXISTS `request_throttle` (
|
||||||
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
`ip_address` VARCHAR(45) NOT NULL,
|
||||||
|
`action` VARCHAR(50) NOT NULL,
|
||||||
|
`weight` INT NOT NULL DEFAULT 1,
|
||||||
|
`requested_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX `idx_throttle` (`action`, `ip_address`, `requested_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `password_reset_tokens` (
|
CREATE TABLE IF NOT EXISTS `password_reset_tokens` (
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
`user_id` INT NOT NULL,
|
`user_id` INT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,6 @@ services:
|
||||||
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
|
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
|
||||||
APP_KEY: ""
|
APP_KEY: ""
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
volumes:
|
|
||||||
# Hochgeladene Logos/Hintergruende ueberleben so ein Image-Update
|
|
||||||
- uploads:/var/www/html/uploads
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -39,4 +36,3 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
uploads:
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 389 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 446 KiB |
|
Before Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1,022 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 938 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 751 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 421 KiB |
|
|
@ -7,8 +7,8 @@ require_once __DIR__ . '/config.php';
|
||||||
require_once __DIR__ . '/includes/Database.php';
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
require_once __DIR__ . '/includes/Auth.php';
|
require_once __DIR__ . '/includes/Auth.php';
|
||||||
require_once __DIR__ . '/includes/Mailer.php';
|
require_once __DIR__ . '/includes/Mailer.php';
|
||||||
require_once __DIR__ . '/includes/Ui.php';
|
|
||||||
require_once __DIR__ . '/includes/I18n.php';
|
require_once __DIR__ . '/includes/I18n.php';
|
||||||
|
require_once __DIR__ . '/includes/Helpers.php';
|
||||||
|
|
||||||
$auth = new Auth();
|
$auth = new Auth();
|
||||||
if ($auth->isLoggedIn()) { header('Location: index.php'); exit; }
|
if ($auth->isLoggedIn()) { header('Location: index.php'); exit; }
|
||||||
|
|
@ -17,8 +17,18 @@ I18n::init();
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
$logoUrl = $db->getSetting('logo_url', '');
|
$logoUrl = $db->getSetting('logo_url', '');
|
||||||
|
$faviconUrl = $db->getSetting('favicon_url', '');
|
||||||
$systemUrl = rtrim($db->getSetting('system_url', ''), '/');
|
$systemUrl = rtrim($db->getSetting('system_url', ''), '/');
|
||||||
|
|
||||||
|
// Fallback: URL automatisch erkennen (wie im Mailer), sonst ist der
|
||||||
|
// Reset-Link in der E-Mail relativ und damit kaputt.
|
||||||
|
if (empty($systemUrl)) {
|
||||||
|
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||||
|
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
|
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||||
|
$systemUrl = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . $scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
$error = '';
|
$error = '';
|
||||||
$success = '';
|
$success = '';
|
||||||
|
|
||||||
|
|
@ -35,7 +45,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
} else {
|
} else {
|
||||||
$rl[] = $now;
|
$rl[] = $now;
|
||||||
$_SESSION['pwreset_times'] = $rl;
|
$_SESSION['pwreset_times'] = $rl;
|
||||||
$user = $db->fetchOne("SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL", [$email]);
|
|
||||||
|
// Zusaetzlich zum Session-Throttle: IP-Rate-Limit gegen Mail-Bombing
|
||||||
|
// (max. 5 Reset-Anfragen / 15 Min., umgeht Cookie-Loeschen). Bei Limit
|
||||||
|
// trotzdem die generische Erfolgsmeldung zeigen (keine Information
|
||||||
|
// darueber preisgeben, ob das Konto existiert).
|
||||||
|
$resetLimited = throttleHit($db, 'password_reset', 5, 15) === true;
|
||||||
|
|
||||||
|
$user = $resetLimited
|
||||||
|
? null
|
||||||
|
: $db->fetchOne("SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL", [$email]);
|
||||||
|
|
||||||
// Always show success (don't reveal whether email exists)
|
// Always show success (don't reveal whether email exists)
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
|
@ -84,12 +103,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= __('reset_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
<title><?= __('reset_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
<?= Ui::head($db) ?>
|
<?php if ($faviconUrl): ?>
|
||||||
|
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<link rel="stylesheet" href="assets/global.css">
|
||||||
|
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||||
|
.box { background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); max-width: 420px; width: 100%; padding: 45px 40px; text-align: center; }
|
||||||
|
.logo { max-width: 180px; height: auto; margin-bottom: 25px; }
|
||||||
|
h1 { color: var(--text-primary); font-size: 26px; margin-bottom: 8px; }
|
||||||
|
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 28px; line-height: 1.5; }
|
||||||
|
.form-group { margin-bottom: 18px; text-align: left; }
|
||||||
|
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
||||||
|
input[type="email"] { width: 100%; padding: 13px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; background: var(--bg-input); color: var(--text-primary); transition: border-color 0.2s; }
|
||||||
|
input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 8px; }
|
||||||
|
.btn:hover { background: var(--accent-hover); transform: translateY(-2px); }
|
||||||
|
.back-link { display: block; margin-top: 22px; color: var(--accent); text-decoration: none; font-size: 14px; }
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app-body focus-page">
|
<body>
|
||||||
<div class="focus-card card">
|
<div class="box">
|
||||||
<?php if ($logoUrl): ?>
|
<?php if ($logoUrl): ?>
|
||||||
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
|
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
@ -110,11 +149,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
<label for="email"><?= __('reset_email_label') ?></label>
|
<label for="email"><?= __('reset_email_label') ?></label>
|
||||||
<input type="email" id="email" name="email" required autofocus placeholder="name@example.com">
|
<input type="email" id="email" name="email" required autofocus placeholder="name@example.com">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-lg btn-block"><?= __('reset_send_btn') ?></button>
|
<button type="submit" class="btn"><?= __('reset_send_btn') ?></button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="auth-links"><a href="login.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('reset_back_login') ?></a></div>
|
<a href="login.php" class="back-link"><?= __('reset_back_login') ?></a>
|
||||||
</div>
|
</div>
|
||||||
<script src="assets/global.js"></script>
|
<script src="assets/global.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
|
|
||||||
Require all denied
|
|
||||||
|
|
@ -5,7 +5,9 @@ require_once __DIR__ . '/Crypto.php';
|
||||||
|
|
||||||
class Auth {
|
class Auth {
|
||||||
private $db;
|
private $db;
|
||||||
|
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
|
||||||
|
private $sessionUser = false;
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
try {
|
try {
|
||||||
$this->db = Database::getInstance();
|
$this->db = Database::getInstance();
|
||||||
|
|
@ -18,6 +20,9 @@ class Auth {
|
||||||
ini_set('session.cookie_httponly', 1);
|
ini_set('session.cookie_httponly', 1);
|
||||||
ini_set('session.use_strict_mode', 1);
|
ini_set('session.use_strict_mode', 1);
|
||||||
ini_set('session.cookie_samesite', 'Lax');
|
ini_set('session.cookie_samesite', 'Lax');
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
|
ini_set('session.cookie_secure', 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Opt-in: Sessions in der DB ablegen (für "überall abmelden" / Skalierung)
|
// Opt-in: Sessions in der DB ablegen (für "überall abmelden" / Skalierung)
|
||||||
try {
|
try {
|
||||||
|
|
@ -243,6 +248,8 @@ class Auth {
|
||||||
|
|
||||||
private function recordLoginAttempt($ip, $email) {
|
private function recordLoginAttempt($ip, $email) {
|
||||||
try {
|
try {
|
||||||
|
// Alte Eintraege aufraeumen, damit die Tabelle nicht unbegrenzt waechst
|
||||||
|
$this->db->query("DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
|
||||||
$this->db->query(
|
$this->db->query(
|
||||||
"INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)",
|
"INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)",
|
||||||
[$ip, $email]
|
[$ip, $email]
|
||||||
|
|
@ -309,6 +316,12 @@ class Auth {
|
||||||
|
|
||||||
// Session setzen
|
// Session setzen
|
||||||
private function setUserSession($user) {
|
private function setUserSession($user) {
|
||||||
|
// Session-ID nach erfolgreichem Login rotieren (verhindert Session-Fixation)
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sessionUser = false; // User-Cache invalidieren
|
||||||
$_SESSION['user_id'] = $user['id'];
|
$_SESSION['user_id'] = $user['id'];
|
||||||
$_SESSION['user_email'] = $user['email'];
|
$_SESSION['user_email'] = $user['email'];
|
||||||
$_SESSION['user_name'] = $user['name'];
|
$_SESSION['user_name'] = $user['name'];
|
||||||
|
|
@ -331,6 +344,7 @@ class Auth {
|
||||||
|
|
||||||
// Ausloggen
|
// Ausloggen
|
||||||
public function logout() {
|
public function logout() {
|
||||||
|
$this->sessionUser = null;
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
|
|
||||||
if (isset($_COOKIE[session_name()])) {
|
if (isset($_COOKIE[session_name()])) {
|
||||||
|
|
@ -340,6 +354,25 @@ class Auth {
|
||||||
session_destroy();
|
session_destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt den Session-User einmal pro Request aus der DB. Dadurch wirken
|
||||||
|
* Rechteaenderungen (Admin entzogen, Konto deaktiviert/geloescht) sofort
|
||||||
|
* und nicht erst nach Ablauf der Session.
|
||||||
|
*/
|
||||||
|
private function loadSessionUser() {
|
||||||
|
if ($this->sessionUser === false) {
|
||||||
|
$this->sessionUser = null;
|
||||||
|
if (isset($_SESSION['user_id'])) {
|
||||||
|
$user = $this->db->fetchOne(
|
||||||
|
"SELECT * FROM users WHERE id = ? AND is_active = 1",
|
||||||
|
[$_SESSION['user_id']]
|
||||||
|
);
|
||||||
|
$this->sessionUser = $user ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->sessionUser;
|
||||||
|
}
|
||||||
|
|
||||||
// Prüfen ob eingeloggt
|
// Prüfen ob eingeloggt
|
||||||
public function isLoggedIn() {
|
public function isLoggedIn() {
|
||||||
if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) {
|
if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) {
|
||||||
|
|
@ -354,24 +387,32 @@ class Auth {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deaktivierte/geloeschte Konten sofort aussperren
|
||||||
|
if ($this->loadSessionUser() === null) {
|
||||||
|
$this->logout();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen ob Admin
|
// Prüfen ob Admin (live aus der DB, nicht aus dem Session-Cache)
|
||||||
public function isAdmin() {
|
public function isAdmin() {
|
||||||
return $this->isLoggedIn() && isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true;
|
if (!$this->isLoggedIn()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$user = $this->loadSessionUser();
|
||||||
|
$isAdmin = $user !== null && (bool)$user['is_admin'];
|
||||||
|
$_SESSION['is_admin'] = $isAdmin;
|
||||||
|
return $isAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aktuellen Benutzer abrufen
|
// Aktuellen Benutzer abrufen
|
||||||
public function getCurrentUser() {
|
public function getCurrentUser() {
|
||||||
if (!$this->isLoggedIn()) {
|
if (!$this->isLoggedIn()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return $this->loadSessionUser();
|
||||||
return $this->db->fetchOne(
|
|
||||||
"SELECT * FROM users WHERE id = ?",
|
|
||||||
[$_SESSION['user_id']]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen ob Benutzer Zugriff auf Site hat
|
// Prüfen ob Benutzer Zugriff auf Site hat
|
||||||
|
|
|
||||||
|
|
@ -114,4 +114,9 @@ class Crypto {
|
||||||
public static function isEncrypted($value) {
|
public static function isEncrypted($value) {
|
||||||
return is_string($value) && strpos($value, self::PREFIX) === 0;
|
return is_string($value) && strpos($value, self::PREFIX) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prueft, ob ein gueltiger APP_KEY konfiguriert ist (fuer Admin-Warnhinweis). */
|
||||||
|
public static function hasKey() {
|
||||||
|
return self::key() !== null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
includes/Helpers.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Kleine Shared-Helper:
|
||||||
|
* - Session-Flash-Messages fuer das PRG-Pattern (Redirect nach POST,
|
||||||
|
* Erfolgsmeldung ueberlebt den Redirect, F5 wiederholt keine Aktion).
|
||||||
|
* - IP-basiertes Request-Throttling ueber die Tabelle request_throttle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function flashSet($message, $type = 'success') {
|
||||||
|
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array|null ['type' => ..., 'message' => ...] oder null */
|
||||||
|
function flashGet() {
|
||||||
|
$flash = $_SESSION['flash'] ?? null;
|
||||||
|
unset($_SESSION['flash']);
|
||||||
|
return $flash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaehlt eine Aktion fuer die aktuelle IP und prueft das Limit.
|
||||||
|
*
|
||||||
|
* @param Database $db
|
||||||
|
* @param string $action Logischer Name, z.B. 'voucher_create'
|
||||||
|
* @param int $maxWeight Erlaubte Summe im Zeitfenster
|
||||||
|
* @param int $windowMinutes Zeitfenster in Minuten
|
||||||
|
* @param int $weight Gewicht dieser Anfrage (z.B. Bulk-Anzahl)
|
||||||
|
* @return bool|null true = limitiert, false = erlaubt (und gezaehlt),
|
||||||
|
* null = Tabelle fehlt (Aufrufer entscheidet ueber Fallback)
|
||||||
|
*/
|
||||||
|
function throttleHit($db, $action, $maxWeight, $windowMinutes, $weight = 1) {
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
try {
|
||||||
|
$db->query("DELETE FROM request_throttle WHERE requested_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
|
||||||
|
$row = $db->fetchOne(
|
||||||
|
"SELECT COALESCE(SUM(weight), 0) AS cnt FROM request_throttle
|
||||||
|
WHERE action = ? AND ip_address = ?
|
||||||
|
AND requested_at > DATE_SUB(NOW(), INTERVAL " . (int)$windowMinutes . " MINUTE)",
|
||||||
|
[$action, $ip]
|
||||||
|
);
|
||||||
|
if ((int)$row['cnt'] + $weight > $maxWeight) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$db->query(
|
||||||
|
"INSERT INTO request_throttle (ip_address, action, weight) VALUES (?, ?, ?)",
|
||||||
|
[$ip, $action, $weight]
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Tabelle existiert noch nicht (Migration 0002 nicht gelaufen)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Öffentliche Display-Seiten ("Kiosk").
|
|
||||||
*
|
|
||||||
* Ein Kiosk gehört zu genau einer Site und ist über einen geheimen Link
|
|
||||||
* erreichbar. Gäste holen sich darüber mit einem Klick einen Zugangscode –
|
|
||||||
* ohne Anmeldung, aber begrenzt durch Tageslimit und Wartezeit.
|
|
||||||
*/
|
|
||||||
class Kiosk
|
|
||||||
{
|
|
||||||
/** Vorgaben für neue Kiosk-Seiten. */
|
|
||||||
public const DEFAULT_DAILY_LIMIT = 100;
|
|
||||||
public const DEFAULT_COOLDOWN = 20; // Sekunden zwischen zwei Codes
|
|
||||||
public const DEFAULT_DISPLAY_SECONDS = 90; // Anzeigedauer des Codes
|
|
||||||
|
|
||||||
/** Unrat-freier Zufallstoken für den öffentlichen Link. */
|
|
||||||
public static function newToken(): string
|
|
||||||
{
|
|
||||||
return bin2hex(random_bytes(16));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Token aus einer Anfrage säubern (Länge und Zeichen fest vorgegeben). */
|
|
||||||
public static function sanitizeToken(?string $token): string
|
|
||||||
{
|
|
||||||
$token = strtolower(trim((string)$token));
|
|
||||||
|
|
||||||
return preg_match('/^[0-9a-f]{32}$/', $token) ? $token : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kiosk samt Site und Profil laden. Liefert null, wenn der Token nicht
|
|
||||||
* passt, der Kiosk deaktiviert ist oder die Site nicht mehr aktiv ist.
|
|
||||||
*/
|
|
||||||
public static function findByToken($db, string $token): ?array
|
|
||||||
{
|
|
||||||
$token = self::sanitizeToken($token);
|
|
||||||
if ($token === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $db->fetchOne(
|
|
||||||
"SELECT k.*, s.name AS site_name, s.is_active AS site_active,
|
|
||||||
t.name AS template_name, t.max_uses AS tpl_max_uses, t.expire_minutes AS tpl_expire_minutes,
|
|
||||||
t.qos_rate_max_down, t.qos_rate_max_up, t.qos_usage_quota
|
|
||||||
FROM kiosks k
|
|
||||||
INNER JOIN sites s ON s.id = k.site_id
|
|
||||||
LEFT JOIN voucher_templates t ON t.id = k.template_id
|
|
||||||
WHERE k.token = ? AND k.is_active = 1",
|
|
||||||
[$token]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$row || (int)$row['site_active'] !== 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $row;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wie viele Codes hat dieser Kiosk heute schon ausgegeben? */
|
|
||||||
public static function usedToday($db, int $kioskId): int
|
|
||||||
{
|
|
||||||
$row = $db->fetchOne(
|
|
||||||
"SELECT COUNT(*) AS c FROM vouchers WHERE kiosk_id = ? AND DATE(created_at) = CURDATE()",
|
|
||||||
[$kioskId]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (int)($row['c'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Darf gerade ein Code geholt werden?
|
|
||||||
*
|
|
||||||
* @return array{allowed:bool,reason:string,wait:int}
|
|
||||||
* reason: '' | 'cooldown' | 'daily_limit'
|
|
||||||
*/
|
|
||||||
public static function checkLimits($db, array $kiosk): array
|
|
||||||
{
|
|
||||||
$cooldown = max(0, (int)$kiosk['cooldown_seconds']);
|
|
||||||
if ($cooldown > 0 && !empty($kiosk['last_used_at'])) {
|
|
||||||
$elapsed = time() - strtotime((string)$kiosk['last_used_at']);
|
|
||||||
if ($elapsed >= 0 && $elapsed < $cooldown) {
|
|
||||||
return ['allowed' => false, 'reason' => 'cooldown', 'wait' => $cooldown - $elapsed];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$limit = max(0, (int)$kiosk['daily_limit']);
|
|
||||||
if ($limit > 0 && self::usedToday($db, (int)$kiosk['id']) >= $limit) {
|
|
||||||
return ['allowed' => false, 'reason' => 'daily_limit', 'wait' => 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['allowed' => true, 'reason' => '', 'wait' => 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nach erfolgreicher Ausgabe den Zeitstempel fortschreiben. */
|
|
||||||
public static function markUsed($db, int $kioskId): void
|
|
||||||
{
|
|
||||||
$db->execute("UPDATE kiosks SET last_used_at = NOW() WHERE id = ?", [$kioskId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Voucher-Eckdaten eines Kiosks: entweder aus dem verknüpften Profil
|
|
||||||
* oder aus den globalen Standardwerten.
|
|
||||||
*/
|
|
||||||
public static function voucherSettings($db, array $kiosk): array
|
|
||||||
{
|
|
||||||
$maxUses = (int)($kiosk['tpl_max_uses'] ?? 0);
|
|
||||||
$expire = (int)($kiosk['tpl_expire_minutes'] ?? 0);
|
|
||||||
|
|
||||||
if ($maxUses < 1) {
|
|
||||||
$maxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
|
|
||||||
}
|
|
||||||
if ($expire < 1) {
|
|
||||||
$expire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'max_uses' => $maxUses,
|
|
||||||
'expire_minutes' => $expire,
|
|
||||||
'qos' => [
|
|
||||||
'down' => max(0, (int)($kiosk['qos_rate_max_down'] ?? 0)),
|
|
||||||
'up' => max(0, (int)($kiosk['qos_rate_max_up'] ?? 0)),
|
|
||||||
'quota_mb' => max(0, (int)($kiosk['qos_usage_quota'] ?? 0)),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gestaltung einer Display-Seite: eigene Werte, sonst die des Systems.
|
|
||||||
*
|
|
||||||
* @return array{logo:string,background:string,overlay:float,accent:string,card:string}
|
|
||||||
*/
|
|
||||||
public static function appearance($db, array $kiosk): array
|
|
||||||
{
|
|
||||||
$accent = strtolower(trim((string)($kiosk['accent_color'] ?? '')));
|
|
||||||
if (!preg_match('/^#[0-9a-f]{6}$/', $accent)) {
|
|
||||||
$accent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$overlay = (int)($kiosk['bg_overlay'] ?? 45);
|
|
||||||
$overlay = max(0, min(90, $overlay));
|
|
||||||
|
|
||||||
$logo = trim((string)($kiosk['logo_url'] ?? ''));
|
|
||||||
if ($logo === '' && $db) {
|
|
||||||
$logo = (string)$db->getSetting('logo_url', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'logo' => $logo,
|
|
||||||
'background' => trim((string)($kiosk['background_url'] ?? '')),
|
|
||||||
'overlay' => (float)$overlay / 100, // immer float, auch bei 0
|
|
||||||
'accent' => $accent,
|
|
||||||
'card' => ($kiosk['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Öffentliche Adresse eines Kiosks. */
|
|
||||||
public static function publicUrl(string $token, string $baseUrl = ''): string
|
|
||||||
{
|
|
||||||
if ($baseUrl === '') {
|
|
||||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
||||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
|
||||||
$path = dirname($_SERVER['SCRIPT_NAME'] ?? '/', 2);
|
|
||||||
$path = $path === '/' || $path === '\\' ? '' : $path;
|
|
||||||
$baseUrl = $protocol . '://' . $host . $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rtrim($baseUrl, '/') . '/kiosk.php?k=' . $token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,339 +1,275 @@
|
||||||
<?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 $smtpVerifySsl;
|
||||||
private $fromName;
|
private $fromEmail;
|
||||||
|
private $fromName;
|
||||||
public function __construct() {
|
|
||||||
$this->db = Database::getInstance();
|
public function __construct() {
|
||||||
$this->loadSettings();
|
$this->db = Database::getInstance();
|
||||||
}
|
$this->loadSettings();
|
||||||
|
}
|
||||||
private function loadSettings() {
|
|
||||||
$this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1';
|
private function loadSettings() {
|
||||||
$this->smtpHost = $this->db->getSetting('smtp_host', '');
|
$this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1';
|
||||||
$this->smtpPort = (int)$this->db->getSetting('smtp_port', '587');
|
$this->smtpHost = $this->db->getSetting('smtp_host', '');
|
||||||
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
|
$this->smtpPort = (int)$this->db->getSetting('smtp_port', '587');
|
||||||
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
|
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
|
||||||
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
|
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
|
||||||
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']);
|
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
|
||||||
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
|
$this->smtpVerifySsl = $this->db->getSetting('smtp_verify_ssl', '0') === '1';
|
||||||
}
|
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost'));
|
||||||
|
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
|
||||||
public function sendRaw($to, $subject, $plainBody) {
|
}
|
||||||
return $this->send($to, $subject, $plainBody, false);
|
|
||||||
}
|
public function sendRaw($to, $subject, $plainBody) {
|
||||||
|
return $this->send($to, $subject, $plainBody, false);
|
||||||
public function send($to, $subject, $body, $isHtml = false) {
|
}
|
||||||
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
|
|
||||||
$attempts = 2;
|
public function send($to, $subject, $body, $isHtml = false) {
|
||||||
for ($i = 1; $i <= $attempts; $i++) {
|
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
|
||||||
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
$attempts = 2;
|
||||||
$ok = $this->sendWithPhpMail($to, $subject, $body);
|
for ($i = 1; $i <= $attempts; $i++) {
|
||||||
} else {
|
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
||||||
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
$ok = $this->sendWithPhpMail($to, $subject, $body);
|
||||||
}
|
} else {
|
||||||
if ($ok) {
|
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
||||||
return true;
|
}
|
||||||
}
|
if ($ok) {
|
||||||
if ($i < $attempts) {
|
return true;
|
||||||
usleep(500000); // 0,5s vor erneutem Versuch
|
}
|
||||||
}
|
if ($i < $attempts) {
|
||||||
}
|
usleep(500000); // 0,5s vor erneutem Versuch
|
||||||
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
|
}
|
||||||
return false;
|
}
|
||||||
}
|
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
|
||||||
|
return false;
|
||||||
private function sendWithPhpMail($to, $subject, $body) {
|
}
|
||||||
$headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
|
||||||
$headers .= "Reply-To: {$this->fromEmail}\r\n";
|
private function sendWithPhpMail($to, $subject, $body) {
|
||||||
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
$headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
||||||
|
$headers .= "Reply-To: {$this->fromEmail}\r\n";
|
||||||
return mail($to, $subject, $body, $headers);
|
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
}
|
|
||||||
|
return mail($to, $subject, $body, $headers);
|
||||||
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
|
}
|
||||||
try {
|
|
||||||
// Verbindung aufbauen
|
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
|
||||||
$socket = $this->connectToSmtp();
|
try {
|
||||||
|
// Hostname auch im CLI-Kontext (Cron) verfuegbar
|
||||||
// EHLO
|
$heloHost = $_SERVER['HTTP_HOST'] ?? (gethostname() ?: 'localhost');
|
||||||
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
|
||||||
|
// Verbindung aufbauen
|
||||||
// STARTTLS wenn nötig
|
$socket = $this->connectToSmtp();
|
||||||
if ($this->smtpEncryption === 'tls') {
|
|
||||||
$this->smtpCommand($socket, "STARTTLS");
|
// EHLO
|
||||||
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
|
$this->smtpCommand($socket, "EHLO " . $heloHost);
|
||||||
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
|
||||||
}
|
// STARTTLS wenn nötig
|
||||||
|
if ($this->smtpEncryption === 'tls') {
|
||||||
// AUTH LOGIN
|
$this->smtpCommand($socket, "STARTTLS");
|
||||||
$this->smtpCommand($socket, "AUTH LOGIN");
|
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
|
||||||
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
|
$this->smtpCommand($socket, "EHLO " . $heloHost);
|
||||||
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
|
}
|
||||||
|
|
||||||
// MAIL FROM
|
// AUTH LOGIN – nur wenn Zugangsdaten konfiguriert sind
|
||||||
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
|
// (Server ohne Auth lehnen ein leeres AUTH LOGIN sonst ab)
|
||||||
|
if ($this->smtpUsername !== '') {
|
||||||
// RCPT TO
|
$this->smtpCommand($socket, "AUTH LOGIN");
|
||||||
$this->smtpCommand($socket, "RCPT TO:<{$to}>");
|
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
|
||||||
|
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
|
||||||
// DATA
|
}
|
||||||
$this->smtpCommand($socket, "DATA");
|
|
||||||
|
// MAIL FROM
|
||||||
// Headers
|
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
|
||||||
$message = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
|
||||||
$message .= "To: {$to}\r\n";
|
// RCPT TO
|
||||||
$message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
|
$this->smtpCommand($socket, "RCPT TO:<{$to}>");
|
||||||
$message .= "MIME-Version: 1.0\r\n";
|
|
||||||
|
// DATA
|
||||||
if ($isHtml) {
|
$this->smtpCommand($socket, "DATA");
|
||||||
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
|
|
||||||
} else {
|
// Headers
|
||||||
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
$message = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
||||||
}
|
$message .= "To: {$to}\r\n";
|
||||||
|
$message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
|
||||||
$message .= "\r\n";
|
$message .= "MIME-Version: 1.0\r\n";
|
||||||
|
|
||||||
// Body - bei Plain Text Zeilenumbrüche konvertieren
|
if ($isHtml) {
|
||||||
if (!$isHtml) {
|
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||||
$body = nl2br($body, false); // Für Plain Text
|
} else {
|
||||||
$body = str_replace('<br>', "\r\n", $body);
|
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
$message .= $body;
|
$message .= "\r\n";
|
||||||
$message .= "\r\n.\r\n";
|
|
||||||
|
// Zeilenumbrueche auf CRLF normalisieren (der fruehere
|
||||||
fwrite($socket, $message);
|
// nl2br/str_replace-Umweg hat Umbrueche verdoppelt)
|
||||||
$response = fgets($socket);
|
$body = preg_replace("/\r\n|\r|\n/", "\r\n", $body);
|
||||||
|
// SMTP-Dot-Stuffing: Zeilen, die mit '.' beginnen, wuerden sonst
|
||||||
// QUIT
|
// die DATA-Phase vorzeitig beenden (RFC 5321, 4.5.2)
|
||||||
$this->smtpCommand($socket, "QUIT");
|
$body = preg_replace('/^\./m', '..', $body);
|
||||||
fclose($socket);
|
|
||||||
|
$message .= $body;
|
||||||
return strpos($response, '250') === 0;
|
$message .= "\r\n.\r\n";
|
||||||
|
|
||||||
} catch (Exception $e) {
|
fwrite($socket, $message);
|
||||||
error_log("SMTP Error: " . $e->getMessage());
|
$response = fgets($socket);
|
||||||
return false;
|
|
||||||
}
|
// QUIT
|
||||||
}
|
$this->smtpCommand($socket, "QUIT");
|
||||||
|
fclose($socket);
|
||||||
private function connectToSmtp() {
|
|
||||||
$context = stream_context_create([
|
return strpos($response, '250') === 0;
|
||||||
'ssl' => [
|
|
||||||
'verify_peer' => false,
|
} catch (Exception $e) {
|
||||||
'verify_peer_name' => false,
|
error_log("SMTP Error: " . $e->getMessage());
|
||||||
'allow_self_signed' => true
|
return false;
|
||||||
]
|
}
|
||||||
]);
|
}
|
||||||
|
|
||||||
if ($this->smtpEncryption === 'ssl') {
|
private function connectToSmtp() {
|
||||||
$host = 'ssl://' . $this->smtpHost;
|
// Zertifikatspruefung optional aktivierbar (Setting smtp_verify_ssl)
|
||||||
} else {
|
$context = stream_context_create([
|
||||||
$host = $this->smtpHost;
|
'ssl' => [
|
||||||
}
|
'verify_peer' => $this->smtpVerifySsl,
|
||||||
|
'verify_peer_name' => $this->smtpVerifySsl,
|
||||||
$socket = stream_socket_client(
|
'allow_self_signed' => !$this->smtpVerifySsl
|
||||||
$host . ':' . $this->smtpPort,
|
]
|
||||||
$errno,
|
]);
|
||||||
$errstr,
|
|
||||||
30,
|
if ($this->smtpEncryption === 'ssl') {
|
||||||
STREAM_CLIENT_CONNECT,
|
$host = 'ssl://' . $this->smtpHost;
|
||||||
$context
|
} else {
|
||||||
);
|
$host = $this->smtpHost;
|
||||||
|
}
|
||||||
if (!$socket) {
|
|
||||||
throw new Exception("SMTP Connection failed: $errstr ($errno)");
|
$socket = stream_socket_client(
|
||||||
}
|
$host . ':' . $this->smtpPort,
|
||||||
|
$errno,
|
||||||
// Willkommensnachricht lesen
|
$errstr,
|
||||||
fgets($socket);
|
30,
|
||||||
|
STREAM_CLIENT_CONNECT,
|
||||||
return $socket;
|
$context
|
||||||
}
|
);
|
||||||
|
|
||||||
private function smtpCommand($socket, $command) {
|
if (!$socket) {
|
||||||
fwrite($socket, $command . "\r\n");
|
throw new Exception("SMTP Connection failed: $errstr ($errno)");
|
||||||
$response = fgets($socket);
|
}
|
||||||
|
|
||||||
// Prüfen auf Fehler (4xx oder 5xx)
|
// Willkommensnachricht lesen
|
||||||
if (preg_match('/^[45]/', $response)) {
|
fgets($socket);
|
||||||
throw new Exception("SMTP Error: $response");
|
|
||||||
}
|
return $socket;
|
||||||
|
}
|
||||||
return $response;
|
|
||||||
}
|
private function smtpCommand($socket, $command) {
|
||||||
|
fwrite($socket, $command . "\r\n");
|
||||||
// Vordefinierte E-Mail-Templates
|
$response = fgets($socket);
|
||||||
/**
|
|
||||||
* Legt den Nachrichtentext in ein schlichtes, markentreues HTML-Gerüst.
|
// Prüfen auf Fehler (4xx oder 5xx)
|
||||||
* Bewusst Tabellen + Inline-Styles: nur so rendern Outlook & Co. zuverlässig.
|
if (preg_match('/^[45]/', $response)) {
|
||||||
*/
|
throw new Exception("SMTP Error: $response");
|
||||||
private function brandedHtml(string $title, string $contentHtml, string $footerNote = ''): string
|
}
|
||||||
{
|
|
||||||
$accent = $this->db->getSetting('brand_gradient_from', '') ?: '#5b5bd6';
|
return $response;
|
||||||
$accent2 = $this->db->getSetting('brand_gradient_to', '') ?: '#8b5cf6';
|
}
|
||||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent)) { $accent = '#5b5bd6'; }
|
|
||||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent2)) { $accent2 = '#8b5cf6'; }
|
// Vordefinierte E-Mail-Templates
|
||||||
|
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
|
||||||
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
|
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
$year = date('Y');
|
$instructionHeader = $this->db->getSetting('instruction_header', '');
|
||||||
$footer = $footerNote !== '' ? '<div style="margin-top:8px;">' . htmlspecialchars($footerNote, ENT_QUOTES, 'UTF-8') . '</div>' : '';
|
$instructionText = $this->db->getSetting('instruction_text', '');
|
||||||
|
|
||||||
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
|
// System-URL aus Einstellungen oder automatisch erkennen
|
||||||
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
|
$systemUrl = $this->db->getSetting('system_url', '');
|
||||||
. '<title>' . $safeTitle . '</title></head>'
|
if (empty($systemUrl)) {
|
||||||
. '<body style="margin:0;padding:0;background:#f6f7f9;">'
|
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||||
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f7f9;padding:28px 12px;">'
|
$host = $_SERVER['HTTP_HOST'];
|
||||||
. '<tr><td align="center">'
|
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
. '<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;">'
|
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||||
. '<tr><td style="background:' . $accent . ';background-image:linear-gradient(135deg,' . $accent . ' 0%,' . $accent2 . ' 100%);padding:22px 26px;">'
|
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
||||||
. '<div style="color:#ffffff;font-size:16px;font-weight:600;letter-spacing:-0.01em;">' . $safeTitle . '</div>'
|
}
|
||||||
. '</td></tr>'
|
|
||||||
. '<tr><td style="padding:26px;color:#101625;font-size:15px;line-height:1.6;">' . $contentHtml . '</td></tr>'
|
// Template aus Datenbank laden
|
||||||
. '<tr><td style="padding:16px 26px;background:#f8f9fb;border-top:1px solid #e5e8ee;color:#6b7280;font-size:12px;">'
|
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang');
|
||||||
. '© ' . $year . ' ' . $safeTitle . $footer
|
$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}");
|
||||||
. '</td></tr>'
|
|
||||||
. '</table></td></tr></table></body></html>';
|
// Anleitung formatieren
|
||||||
}
|
$instructions = '';
|
||||||
|
if ($instructionText) {
|
||||||
/**
|
$instructions = $instructionHeader . "\n" . $instructionText;
|
||||||
* Voucher-Code als hervorgehobene Karte für die E-Mail.
|
}
|
||||||
*/
|
|
||||||
private function voucherCardHtml(string $code, string $siteName, $maxUses): string
|
// Platzhalter ersetzen
|
||||||
{
|
$placeholders = [
|
||||||
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
'{VOUCHER_CODE}' => $voucherCode,
|
||||||
. 'style="margin:18px 0;background:#f8f9fb;border:1px solid #e5e8ee;border-radius:12px;">'
|
'{SITE_NAME}' => $siteName,
|
||||||
. '<tr><td align="center" style="padding:22px;">'
|
'{MAX_USES}' => $maxUses,
|
||||||
. '<div style="font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">'
|
'{APP_TITLE}' => $appTitle,
|
||||||
. htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '</div>'
|
'{INSTRUCTIONS}' => $instructions,
|
||||||
. '<div style="margin:10px 0;font-family:Consolas,Menlo,monospace;font-size:28px;font-weight:700;letter-spacing:.12em;color:#101625;">'
|
'{SYSTEM_URL}' => $systemUrl
|
||||||
. htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</div>'
|
];
|
||||||
. '<div style="font-size:13px;color:#525c6e;">'
|
|
||||||
. htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' '
|
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
||||||
. htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '</div>'
|
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
|
||||||
. '</td></tr></table>';
|
|
||||||
}
|
// HTML oder Plain Text prüfen
|
||||||
|
$isHtml = strip_tags($body) !== $body;
|
||||||
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
|
|
||||||
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
return $this->send($to, $subject, $body, $isHtml);
|
||||||
$instructionHeader = $this->db->getSetting('instruction_header', '');
|
}
|
||||||
$instructionText = $this->db->getSetting('instruction_text', '');
|
|
||||||
|
public function sendTestEmail($to) {
|
||||||
// System-URL aus Einstellungen oder automatisch erkennen
|
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
$systemUrl = $this->db->getSetting('system_url', '');
|
$subject = '[Test] E-Mail-Konfiguration – ' . $appTitle;
|
||||||
if (empty($systemUrl)) {
|
$body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet.";
|
||||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
return $this->send($to, $subject, $body, false);
|
||||||
$host = $_SERVER['HTTP_HOST'];
|
}
|
||||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
|
||||||
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
public function sendUserNotification($to, $userName, $changes) {
|
||||||
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
}
|
|
||||||
|
// System-URL aus Einstellungen oder automatisch erkennen
|
||||||
// Template aus Datenbank laden
|
$systemUrl = $this->db->getSetting('system_url', '');
|
||||||
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang');
|
if (empty($systemUrl)) {
|
||||||
$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}");
|
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'];
|
||||||
// Anleitung formatieren
|
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
$instructions = '';
|
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||||
if ($instructionText) {
|
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
||||||
$instructions = $instructionHeader . "\n" . $instructionText;
|
}
|
||||||
}
|
|
||||||
|
// Template aus Datenbank laden
|
||||||
// Platzhalter ersetzen
|
$subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert');
|
||||||
$placeholders = [
|
$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}");
|
||||||
'{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses),
|
|
||||||
'{VOUCHER_CODE}' => $voucherCode,
|
// Änderungen formatieren
|
||||||
'{SITE_NAME}' => $siteName,
|
$changesText = '';
|
||||||
'{MAX_USES}' => $maxUses,
|
foreach ($changes as $change) {
|
||||||
'{APP_TITLE}' => $appTitle,
|
$changesText .= "• $change\n";
|
||||||
'{INSTRUCTIONS}' => $instructions,
|
}
|
||||||
'{SYSTEM_URL}' => $systemUrl
|
|
||||||
];
|
// Platzhalter ersetzen
|
||||||
|
$placeholders = [
|
||||||
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
'{USER_NAME}' => $userName,
|
||||||
|
'{CHANGES}' => $changesText,
|
||||||
// Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln,
|
'{APP_TITLE}' => $appTitle,
|
||||||
// sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren.
|
'{SYSTEM_URL}' => $systemUrl
|
||||||
$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);
|
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
||||||
|
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
|
||||||
if ($isHtml) {
|
|
||||||
$body = $this->brandedHtml($appTitle, $body);
|
// HTML oder Plain Text prüfen
|
||||||
}
|
$isHtml = strip_tags($body) !== $body;
|
||||||
|
|
||||||
return $this->send($to, $subject, $body, $isHtml);
|
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
|
|
@ -1,259 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Gemeinsame Bausteine fuer den Seitenkopf.
|
|
||||||
*
|
|
||||||
* - liefert versionierte Asset-URLs (Cache-Busting nach Updates)
|
|
||||||
* - bindet die lokal ausgelieferten Assets ein (keine externen CDNs)
|
|
||||||
* - erzeugt die Branding-Overrides aus den Einstellungen
|
|
||||||
*
|
|
||||||
* Alle Methoden funktionieren auch ohne Datenbank ($db = null), damit der
|
|
||||||
* Installer dieselbe Optik nutzen kann.
|
|
||||||
*/
|
|
||||||
class Ui
|
|
||||||
{
|
|
||||||
/** Standardwerte des Design-Systems (siehe assets/global.css). */
|
|
||||||
public const DEFAULT_ACCENT = '#5b5bd6';
|
|
||||||
public const DEFAULT_ACCENT_DARK = '#8b8bf5';
|
|
||||||
public const DEFAULT_GRADIENT_FROM = '#5b5bd6';
|
|
||||||
public const DEFAULT_GRADIENT_TO = '#8b5cf6';
|
|
||||||
public const DEFAULT_RADIUS = 14;
|
|
||||||
|
|
||||||
/** Projektwurzel im Dateisystem. */
|
|
||||||
private static function root(): string
|
|
||||||
{
|
|
||||||
return dirname(__DIR__);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL eines Projekt-Assets inkl. Versionsstempel.
|
|
||||||
* $base ist der Pfad zur Projektwurzel ('' im Root, '../' in /admin).
|
|
||||||
*/
|
|
||||||
public static function asset(string $path, string $base = ''): string
|
|
||||||
{
|
|
||||||
$file = self::root() . '/' . ltrim($path, '/');
|
|
||||||
$version = is_file($file) ? (string)filemtime($file) : '0';
|
|
||||||
|
|
||||||
return $base . $path . '?v=' . $version;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** <script src> mit Versionsstempel. */
|
|
||||||
public static function script(string $path, string $base = '', bool $defer = false): string
|
|
||||||
{
|
|
||||||
return '<script src="' . htmlspecialchars(self::asset($path, $base)) . '"'
|
|
||||||
. ($defer ? ' defer' : '') . '></script>';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Theme-Bootstrap: gespeicherte Auswahl, sonst Systemeinstellung.
|
|
||||||
* Muss im <head> stehen, damit nichts hell aufblitzt.
|
|
||||||
*/
|
|
||||||
public static function themeScript(): string
|
|
||||||
{
|
|
||||||
return '<script>(function(){'
|
|
||||||
. 'var s=localStorage.getItem("theme");'
|
|
||||||
. 'var t=s||(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");'
|
|
||||||
. 'document.documentElement.setAttribute("data-theme",t);'
|
|
||||||
. '})();</script>';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gueltige Hex-Farbe oder Fallback. */
|
|
||||||
private static function color(?string $value, string $fallback): string
|
|
||||||
{
|
|
||||||
$value = trim((string)$value);
|
|
||||||
|
|
||||||
return preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtolower($value) : $fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSS-Overrides fuer die Markenfarben. Gibt einen leeren String zurueck,
|
|
||||||
* wenn nichts vom Standard abweicht.
|
|
||||||
*/
|
|
||||||
public static function brandingStyle($db = null): string
|
|
||||||
{
|
|
||||||
if (!$db) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$accent = self::color($db->getSetting('brand_accent', ''), self::DEFAULT_ACCENT);
|
|
||||||
$accentDark = self::color($db->getSetting('brand_accent_dark', ''), self::DEFAULT_ACCENT_DARK);
|
|
||||||
$from = self::color($db->getSetting('brand_gradient_from', ''), self::DEFAULT_GRADIENT_FROM);
|
|
||||||
$to = self::color($db->getSetting('brand_gradient_to', ''), self::DEFAULT_GRADIENT_TO);
|
|
||||||
$radius = (int)$db->getSetting('brand_radius', (string)self::DEFAULT_RADIUS);
|
|
||||||
$radius = max(0, min(28, $radius));
|
|
||||||
|
|
||||||
$isDefault = $accent === self::DEFAULT_ACCENT
|
|
||||||
&& $accentDark === self::DEFAULT_ACCENT_DARK
|
|
||||||
&& $from === self::DEFAULT_GRADIENT_FROM
|
|
||||||
&& $to === self::DEFAULT_GRADIENT_TO
|
|
||||||
&& $radius === self::DEFAULT_RADIUS;
|
|
||||||
|
|
||||||
if ($isDefault) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Abgeleitete Töne über color-mix – so genügt eine einzige Grundfarbe.
|
|
||||||
return '<style>'
|
|
||||||
. ':root{'
|
|
||||||
. "--accent:{$accent};"
|
|
||||||
. "--accent-hover:color-mix(in srgb, {$accent} 84%, #000);"
|
|
||||||
. "--accent-soft:color-mix(in srgb, {$accent} 12%, #fff);"
|
|
||||||
. "--accent-border:color-mix(in srgb, {$accent} 32%, #fff);"
|
|
||||||
. "--accent-2:{$to};"
|
|
||||||
. "--input-focus:{$accent};"
|
|
||||||
. "--ring:0 0 0 4px color-mix(in srgb, {$accent} 22%, transparent);"
|
|
||||||
. "--brand-gradient:linear-gradient(135deg, {$from} 0%, {$to} 100%);"
|
|
||||||
. "--r-lg:{$radius}px;"
|
|
||||||
. "--r-xl:" . ($radius + 6) . 'px;'
|
|
||||||
. '}'
|
|
||||||
. '[data-theme="dark"]{'
|
|
||||||
. "--accent:{$accentDark};"
|
|
||||||
. "--accent-hover:color-mix(in srgb, {$accentDark} 80%, #fff);"
|
|
||||||
. "--accent-soft:color-mix(in srgb, {$accentDark} 20%, #0a0c11);"
|
|
||||||
. "--accent-border:color-mix(in srgb, {$accentDark} 42%, #0a0c11);"
|
|
||||||
. "--input-focus:{$accentDark};"
|
|
||||||
. "--ring:0 0 0 4px color-mix(in srgb, {$accentDark} 26%, transparent);"
|
|
||||||
. '}'
|
|
||||||
. '</style>';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bildfeld mit Vorschau, Upload und URL-Eingabe.
|
|
||||||
* Wird von den Einstellungen und der Kiosk-Verwaltung genutzt.
|
|
||||||
*/
|
|
||||||
public static function imageField(
|
|
||||||
string $name,
|
|
||||||
string $label,
|
|
||||||
?string $value,
|
|
||||||
string $hint = '',
|
|
||||||
string $accept = 'image/*',
|
|
||||||
string $base = '../'
|
|
||||||
): string {
|
|
||||||
$value = (string)$value;
|
|
||||||
$preview = self::mediaUrl($value, $base);
|
|
||||||
$esc = static fn ($text) => htmlspecialchars((string)$text, ENT_QUOTES);
|
|
||||||
$t = static fn ($key, $fallback) => function_exists('__') ? __($key) : $fallback;
|
|
||||||
|
|
||||||
$thumb = $preview !== ''
|
|
||||||
? '<img src="' . $esc($preview) . '" alt="">'
|
|
||||||
: '<i class="fas fa-image" aria-hidden="true"></i>';
|
|
||||||
|
|
||||||
$remove = $value !== ''
|
|
||||||
? '<label class="chk"><input type="checkbox" name="' . $esc($name) . '_remove" value="1"> '
|
|
||||||
. $esc($t('settings_image_remove', 'Bild entfernen')) . '</label>'
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return '<div class="form-group">'
|
|
||||||
. '<label>' . $esc($label) . '</label>'
|
|
||||||
. '<div class="image-field">'
|
|
||||||
. '<div class="image-preview">' . $thumb . '</div>'
|
|
||||||
. '<div class="image-field-controls">'
|
|
||||||
. '<input type="file" name="' . $esc($name) . '_file" accept="' . $esc($accept) . '">'
|
|
||||||
. '<input type="text" name="' . $esc($name) . '" value="' . $esc($value) . '" '
|
|
||||||
. 'placeholder="' . $esc($t('settings_image_url_placeholder', 'https://… oder Datei hochladen')) . '">'
|
|
||||||
. $remove
|
|
||||||
. '</div></div>'
|
|
||||||
. ($hint !== '' ? '<div class="help-text">' . $esc($hint) . '</div>' : '')
|
|
||||||
. '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Version aus der Datei VERSION im Projektstamm.
|
|
||||||
* Damit tragen Oberfläche und Release-Paket dieselbe Nummer.
|
|
||||||
*/
|
|
||||||
public static function version(): string
|
|
||||||
{
|
|
||||||
static $version = null;
|
|
||||||
if ($version === null) {
|
|
||||||
$file = self::root() . '/VERSION';
|
|
||||||
$version = is_file($file) ? trim((string)file_get_contents($file)) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $version;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Entwicklerhinweis – bewusst an einer Stelle gepflegt. */
|
|
||||||
public const CREDIT_NAME = 'Loheide.eu';
|
|
||||||
public const CREDIT_URL = 'https://loheide.eu';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dezenter Hinweis auf den Entwickler, wie er im Seitenfuß erscheint.
|
|
||||||
*/
|
|
||||||
public static function credit(bool $withVersion = false): string
|
|
||||||
{
|
|
||||||
$label = function_exists('__') ? __('credit_by') : 'Entwickelt von';
|
|
||||||
|
|
||||||
$prefix = '';
|
|
||||||
if ($withVersion && self::version() !== '') {
|
|
||||||
$prefix = 'v' . htmlspecialchars(self::version()) . ' · ';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '<p class="app-credit">' . $prefix . htmlspecialchars($label) . ' '
|
|
||||||
. '<a href="' . self::CREDIT_URL . '" target="_blank" rel="noopener">'
|
|
||||||
. self::CREDIT_NAME . '</a></p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard-Druckvorlage (wird nur verwendet, solange keine eigene
|
|
||||||
* Vorlage gespeichert ist). {QR_CODE} fuellt der Browser.
|
|
||||||
*/
|
|
||||||
public static function defaultPrintTemplate(): string
|
|
||||||
{
|
|
||||||
$validUntil = function_exists('__') ? __('print_valid_until') : 'Gültig bis';
|
|
||||||
$devices = function_exists('__') ? __('print_devices') : 'Geräte';
|
|
||||||
|
|
||||||
return '<div style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
|
||||||
. 'max-width:420px;margin:0 auto;padding:26px;border:1px dashed #9aa1ae;border-radius:14px;text-align:center;">'
|
|
||||||
. '<div style="font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">{APP_TITLE}</div>'
|
|
||||||
. '<div style="margin:6px 0 18px;font-size:15px;color:#101625;">{SITE_NAME}</div>'
|
|
||||||
. '{QR_CODE}'
|
|
||||||
. '<div style="margin:18px 0 6px;font-family:Consolas,Menlo,monospace;font-size:30px;font-weight:700;letter-spacing:.14em;color:#101625;">{VOUCHER_CODE}</div>'
|
|
||||||
. '<div style="font-size:13px;color:#525c6e;">' . $validUntil . ' {EXPIRY_DATE} {EXPIRY_TIME} · {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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,12 +10,15 @@ class UniFiController {
|
||||||
private $csrfToken = null;
|
private $csrfToken = null;
|
||||||
private $sessionCookie = null;
|
private $sessionCookie = null;
|
||||||
private $loggedIn = false;
|
private $loggedIn = false;
|
||||||
|
/** SSL-Zertifikat pruefen? Default aus, da UnifFi-Controller meist self-signed sind. */
|
||||||
|
private $sslVerify = false;
|
||||||
|
|
||||||
public function __construct($controllerUrl, $username, $password, $siteId) {
|
public function __construct($controllerUrl, $username, $password, $siteId, $sslVerify = false) {
|
||||||
$this->controllerUrl = rtrim($controllerUrl, '/');
|
$this->controllerUrl = rtrim($controllerUrl, '/');
|
||||||
$this->username = $username;
|
$this->username = $username;
|
||||||
$this->password = $password;
|
$this->password = $password;
|
||||||
$this->siteId = $siteId;
|
$this->siteId = $siteId;
|
||||||
|
$this->sslVerify = (bool)$sslVerify;
|
||||||
$this->cookieFile = tempnam(sys_get_temp_dir(), 'UNIFI_');
|
$this->cookieFile = tempnam(sys_get_temp_dir(), 'UNIFI_');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +44,8 @@ class UniFiController {
|
||||||
'password' => $this->password
|
'password' => $this->password
|
||||||
]),
|
]),
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_SSL_VERIFYPEER => false,
|
CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
|
||||||
CURLOPT_COOKIEJAR => $this->cookieFile,
|
CURLOPT_COOKIEJAR => $this->cookieFile,
|
||||||
CURLOPT_COOKIEFILE => $this->cookieFile,
|
CURLOPT_COOKIEFILE => $this->cookieFile,
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
|
@ -116,7 +120,8 @@ class UniFiController {
|
||||||
$options = [
|
$options = [
|
||||||
CURLOPT_URL => $url,
|
CURLOPT_URL => $url,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_SSL_VERIFYPEER => false,
|
CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
CURLOPT_CONNECTTIMEOUT => 5,
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
CURLOPT_HTTPHEADER => $headers
|
CURLOPT_HTTPHEADER => $headers
|
||||||
|
|
@ -155,13 +160,31 @@ class UniFiController {
|
||||||
return json_decode($response, true);
|
return json_decode($response, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voucher erstellen
|
// Einzelnen Voucher erstellen
|
||||||
// $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
// $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
||||||
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) {
|
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) {
|
||||||
|
$vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1, $options);
|
||||||
|
return $vouchers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt $count Voucher in EINEM API-Call (UniFi 'n'-Parameter) statt
|
||||||
|
* pro Voucher Login + Full-Fetch auszufuehren.
|
||||||
|
*
|
||||||
|
* Matching: Die create-voucher-Antwort liefert die create_time der neuen
|
||||||
|
* Voucher; darueber (plus note) werden exakt die soeben erstellten Codes
|
||||||
|
* identifiziert. Der fruehere Fallback "global neuester Voucher" konnte
|
||||||
|
* bei parallelen Erstellungen fremde Codes liefern und wurde entfernt.
|
||||||
|
*
|
||||||
|
* @param array $options Optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
||||||
|
* @return array Liste von ['code','formatted_code','unifi_id','create_time']
|
||||||
|
*/
|
||||||
|
public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1, $options = []) {
|
||||||
|
$count = max(1, (int)$count);
|
||||||
$data = [
|
$data = [
|
||||||
'cmd' => 'create-voucher',
|
'cmd' => 'create-voucher',
|
||||||
'expire' => (int)$expireMinutes,
|
'expire' => (int)$expireMinutes,
|
||||||
'n' => 1,
|
'n' => $count,
|
||||||
'note' => $voucherName,
|
'note' => $voucherName,
|
||||||
'quota' => (int)$maxUses
|
'quota' => (int)$maxUses
|
||||||
];
|
];
|
||||||
|
|
@ -182,51 +205,51 @@ class UniFiController {
|
||||||
if (!isset($response['data'][0]['create_time'])) {
|
if (!isset($response['data'][0]['create_time'])) {
|
||||||
throw new Exception("Voucher konnte nicht erstellt werden");
|
throw new Exception("Voucher konnte nicht erstellt werden");
|
||||||
}
|
}
|
||||||
|
$createTime = $response['data'][0]['create_time'];
|
||||||
// Voucher-Code abrufen. WICHTIG: getVouchers() liefert die Voucher
|
|
||||||
// unsortiert zurueck – ein blindes reset() kann bei parallelen
|
|
||||||
// Erstellungen den falschen (fremden) Code liefern. Daher gezielt
|
|
||||||
// nach dem soeben erstellten Voucher suchen: gleiche note + neueste
|
|
||||||
// create_time.
|
|
||||||
$vouchers = $this->getVouchers();
|
|
||||||
|
|
||||||
if (empty($vouchers)) {
|
$all = $this->getVouchers();
|
||||||
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestVoucher = null;
|
// Exakte Treffer: gleiche note UND die vom Controller gemeldete create_time
|
||||||
foreach ($vouchers as $voucher) {
|
$matches = [];
|
||||||
// Nur Voucher mit passender Notiz beruecksichtigen
|
foreach ($all as $voucher) {
|
||||||
if (($voucher['note'] ?? null) !== $voucherName) {
|
if (($voucher['note'] ?? null) === $voucherName
|
||||||
continue;
|
&& ($voucher['create_time'] ?? null) == $createTime) {
|
||||||
}
|
$matches[] = $voucher;
|
||||||
if ($latestVoucher === null
|
|
||||||
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
|
|
||||||
$latestVoucher = $voucher;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: falls keine note-Uebereinstimmung (z.B. Sonderzeichen),
|
// Fallback: nur note matchen (falls der Controller create_time leicht
|
||||||
// den global neuesten Voucher nehmen.
|
// abweichend meldet), neueste zuerst, auf $count begrenzen.
|
||||||
if ($latestVoucher === null) {
|
if (empty($matches)) {
|
||||||
foreach ($vouchers as $voucher) {
|
foreach ($all as $voucher) {
|
||||||
if ($latestVoucher === null
|
if (($voucher['note'] ?? null) === $voucherName) {
|
||||||
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
|
$matches[] = $voucher;
|
||||||
$latestVoucher = $voucher;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
usort($matches, function ($a, $b) {
|
||||||
|
return ($b['create_time'] ?? 0) <=> ($a['create_time'] ?? 0);
|
||||||
|
});
|
||||||
|
$matches = array_slice($matches, 0, $count);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($latestVoucher === null || empty($latestVoucher['code'])) {
|
$result = [];
|
||||||
|
foreach ($matches as $voucher) {
|
||||||
|
if (empty($voucher['code'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result[] = [
|
||||||
|
'code' => $voucher['code'],
|
||||||
|
'formatted_code' => $this->formatVoucherCode($voucher['code']),
|
||||||
|
'unifi_id' => $voucher['_id'] ?? null,
|
||||||
|
'create_time' => $voucher['create_time'] ?? null
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($result)) {
|
||||||
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
|
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return $result;
|
||||||
'code' => $latestVoucher['code'],
|
|
||||||
'formatted_code' => $this->formatVoucherCode($latestVoucher['code']),
|
|
||||||
'unifi_id' => $latestVoucher['_id'] ?? null,
|
|
||||||
'create_time' => $latestVoucher['create_time'] ?? null
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle Voucher abrufen
|
// Alle Voucher abrufen
|
||||||
|
|
@ -320,9 +343,9 @@ class UniFiController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verbindung testen
|
// Verbindung testen
|
||||||
public static function testConnection($controllerUrl, $username, $password, $siteId) {
|
public static function testConnection($controllerUrl, $username, $password, $siteId, $sslVerify = false) {
|
||||||
try {
|
try {
|
||||||
$controller = new self($controllerUrl, $username, $password, $siteId);
|
$controller = new self($controllerUrl, $username, $password, $siteId, $sslVerify);
|
||||||
$controller->login();
|
$controller->login();
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|
@ -351,17 +374,26 @@ class UniFiController {
|
||||||
// Alle aktuellen UniFi-IDs sammeln
|
// Alle aktuellen UniFi-IDs sammeln
|
||||||
$unifiIds = [];
|
$unifiIds = [];
|
||||||
|
|
||||||
|
// Bestehende Voucher der Site einmal als Map laden statt pro Voucher
|
||||||
|
// ein SELECT auszufuehren (halbiert die Query-Anzahl bei grossen Syncs)
|
||||||
|
$existingRows = $db->fetchAll(
|
||||||
|
"SELECT id, unifi_voucher_id FROM vouchers WHERE site_id = ? AND unifi_voucher_id IS NOT NULL",
|
||||||
|
[$dbSiteId]
|
||||||
|
);
|
||||||
|
$existingMap = [];
|
||||||
|
foreach ($existingRows as $row) {
|
||||||
|
$existingMap[$row['unifi_voucher_id']] = $row['id'];
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($vouchers as $voucher) {
|
foreach ($vouchers as $voucher) {
|
||||||
$unifiIds[] = $voucher['_id'];
|
$unifiIds[] = $voucher['_id'];
|
||||||
|
|
||||||
// Status zählen
|
// Status zählen
|
||||||
$stats[$voucher['status']]++;
|
$stats[$voucher['status']]++;
|
||||||
|
|
||||||
// Prüfen ob Voucher bereits existiert
|
$existing = isset($existingMap[$voucher['_id']])
|
||||||
$existing = $db->fetchOne(
|
? ['id' => $existingMap[$voucher['_id']]]
|
||||||
"SELECT id, status, used_count FROM vouchers WHERE unifi_voucher_id = ? AND site_id = ?",
|
: null;
|
||||||
[$voucher['_id'], $dbSiteId]
|
|
||||||
);
|
|
||||||
|
|
||||||
$expiresAt = date('Y-m-d H:i:s', $voucher['expire_time']);
|
$expiresAt = date('Y-m-d H:i:s', $voucher['expire_time']);
|
||||||
$createdAt = date('Y-m-d H:i:s', $voucher['create_time']);
|
$createdAt = date('Y-m-d H:i:s', $voucher['create_time']);
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Datei-Uploads fuer Branding-Bilder (Logo, Favicon, Hintergrund).
|
|
||||||
*
|
|
||||||
* Bewusst eng gefasst: nur Bilder, kleine Groesse, zufaelliger Dateiname,
|
|
||||||
* Ablage in uploads/ (dort ist die PHP-Ausfuehrung per .htaccess gesperrt).
|
|
||||||
* SVG-Dateien werden vor dem Speichern von aktiven Inhalten befreit.
|
|
||||||
*/
|
|
||||||
class Upload
|
|
||||||
{
|
|
||||||
public const MAX_BYTES = 3145728; // 3 MB
|
|
||||||
|
|
||||||
/** Erlaubte Endungen je Einsatzzweck. */
|
|
||||||
private const ALLOWED = [
|
|
||||||
'image' => ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'],
|
|
||||||
'favicon' => ['ico', 'png', 'svg'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uebersetzte Meldung – faellt auf Deutsch zurueck, wenn die Klasse
|
|
||||||
* ausserhalb einer Seite mit geladener I18n verwendet wird.
|
|
||||||
*/
|
|
||||||
private static function msg(string $key, string $fallback): string
|
|
||||||
{
|
|
||||||
return function_exists('__') ? __($key) : $fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function dir(): string
|
|
||||||
{
|
|
||||||
return dirname(__DIR__) . '/uploads';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legt das Upload-Verzeichnis inkl. Schutzdatei an. */
|
|
||||||
public static function ensureDir(): bool
|
|
||||||
{
|
|
||||||
$dir = self::dir();
|
|
||||||
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$htaccess = $dir . '/.htaccess';
|
|
||||||
if (!file_exists($htaccess)) {
|
|
||||||
@file_put_contents($htaccess, "php_flag engine off\nOptions -ExecCGI\n<FilesMatch \"\\.(php|phtml|phar)$\">\n Require all denied\n</FilesMatch>\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_writable($dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ist der Pfad eine von uns gespeicherte Datei? */
|
|
||||||
public static function isLocal(string $path): bool
|
|
||||||
{
|
|
||||||
return $path !== '' && strncmp($path, 'uploads/', 8) === 0 && strpos($path, '..') === false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loescht eine zuvor hochgeladene Datei (externe URLs bleiben unberuehrt). */
|
|
||||||
public static function delete(string $path): void
|
|
||||||
{
|
|
||||||
if (!self::isLocal($path)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$file = dirname(__DIR__) . '/' . $path;
|
|
||||||
if (is_file($file)) {
|
|
||||||
@unlink($file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nimmt einen Upload entgegen und gibt den relativen Pfad zurueck.
|
|
||||||
*
|
|
||||||
* @param array $file Eintrag aus $_FILES
|
|
||||||
* @param string $kind 'image' oder 'favicon'
|
|
||||||
* @throws RuntimeException bei ungueltigen Dateien
|
|
||||||
*/
|
|
||||||
public static function store(array $file, string $kind = 'image'): string
|
|
||||||
{
|
|
||||||
if (!isset($file['error']) || $file['error'] === UPLOAD_ERR_NO_FILE) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
||||||
throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.'));
|
|
||||||
}
|
|
||||||
if (!is_uploaded_file($file['tmp_name'])) {
|
|
||||||
throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.'));
|
|
||||||
}
|
|
||||||
if ($file['size'] > self::MAX_BYTES) {
|
|
||||||
throw new RuntimeException(self::msg('upload_error_size', 'Die Datei ist zu groß (maximal 3 MB).'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowed = self::ALLOWED[$kind] ?? self::ALLOWED['image'];
|
|
||||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
||||||
if ($ext === 'jpeg') {
|
|
||||||
$ext = 'jpg';
|
|
||||||
}
|
|
||||||
if (!in_array($ext, $allowed, true)) {
|
|
||||||
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = (string)file_get_contents($file['tmp_name']);
|
|
||||||
|
|
||||||
if ($ext === 'svg') {
|
|
||||||
$data = self::sanitizeSvg($data);
|
|
||||||
} elseif ($ext !== 'ico') {
|
|
||||||
// Raster: muss als Bild lesbar sein
|
|
||||||
if (@getimagesize($file['tmp_name']) === false) {
|
|
||||||
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!self::ensureDir()) {
|
|
||||||
throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = bin2hex(random_bytes(8)) . '.' . $ext;
|
|
||||||
$dest = self::dir() . '/' . $name;
|
|
||||||
if (file_put_contents($dest, $data) === false) {
|
|
||||||
throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.'));
|
|
||||||
}
|
|
||||||
@chmod($dest, 0644);
|
|
||||||
|
|
||||||
return 'uploads/' . $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Neuer Wert eines Bildfeldes aus dem Formular.
|
|
||||||
*
|
|
||||||
* Reihenfolge: hochgeladene Datei schlaegt alles, danach der
|
|
||||||
* Entfernen-Schalter, sonst gilt das URL-Feld. Wird eine zuvor
|
|
||||||
* hochgeladene Datei ersetzt oder entfernt, verschwindet sie auch
|
|
||||||
* von der Platte.
|
|
||||||
*
|
|
||||||
* @param string $name Feldname (erwartet <name>, <name>_file, <name>_remove)
|
|
||||||
* @param string $current bisher gespeicherter Wert
|
|
||||||
* @param string $kind 'image' oder 'favicon'
|
|
||||||
*/
|
|
||||||
public static function resolveField(string $name, string $current, string $kind = 'image'): string
|
|
||||||
{
|
|
||||||
$uploaded = self::store($_FILES[$name . '_file'] ?? [], $kind);
|
|
||||||
if ($uploaded !== '') {
|
|
||||||
self::delete($current);
|
|
||||||
return $uploaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($_POST[$name . '_remove'])) {
|
|
||||||
self::delete($current);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = trim((string)($_POST[$name] ?? ''));
|
|
||||||
if ($value !== $current && self::isLocal($current) && !self::isLocal($value)) {
|
|
||||||
self::delete($current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entfernt aktive Inhalte aus SVG-Dateien (Skripte, Event-Handler,
|
|
||||||
* externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke.
|
|
||||||
*/
|
|
||||||
private static function sanitizeSvg(string $svg): string
|
|
||||||
{
|
|
||||||
if (stripos($svg, '<svg') === false) {
|
|
||||||
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg);
|
|
||||||
$svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*/?>#i', '', $svg);
|
|
||||||
$svg = preg_replace('#\son[a-z]+\s*=\s*"[^"]*"#i', '', $svg);
|
|
||||||
$svg = preg_replace("#\son[a-z]+\s*=\s*'[^']*'#i", '', $svg);
|
|
||||||
$svg = preg_replace('#(href|xlink:href)\s*=\s*([\'"])\s*(javascript|data):[^\'"]*\2#i', '', $svg);
|
|
||||||
$svg = preg_replace('#<!ENTITY[^>]*>#i', '', $svg);
|
|
||||||
|
|
||||||
return (string)$svg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Erstellt Voucher im UniFi-Controller und schreibt sie in die Datenbank.
|
|
||||||
*
|
|
||||||
* Bis hierher lag diese Logik doppelt in index.php und der REST-API; mit der
|
|
||||||
* Kiosk-Seite waere sie ein drittes Mal noetig gewesen.
|
|
||||||
*/
|
|
||||||
require_once __DIR__ . '/UniFiController.php';
|
|
||||||
require_once __DIR__ . '/Crypto.php';
|
|
||||||
|
|
||||||
class VoucherService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array $site Zeile aus `sites`
|
|
||||||
* @param array $qos ['down' => kbit, 'up' => kbit, 'quota_mb' => MB]
|
|
||||||
* @param int|null $userId angemeldeter Benutzer, sonst null
|
|
||||||
* @param int|null $kioskId Herkunft, falls ueber eine Display-Seite geholt
|
|
||||||
*
|
|
||||||
* @return array{code:string,site_name:string,max_uses:int,expire_min:int,expiry_date:string,expiry_time:string}
|
|
||||||
* @throws Exception wenn der Controller keinen gueltigen Voucher liefert
|
|
||||||
*/
|
|
||||||
public static function create(
|
|
||||||
$db,
|
|
||||||
array $site,
|
|
||||||
string $voucherName,
|
|
||||||
int $maxUses,
|
|
||||||
int $expireMinutes,
|
|
||||||
?int $userId = null,
|
|
||||||
array $qos = [],
|
|
||||||
?int $kioskId = null
|
|
||||||
): array {
|
|
||||||
$fullName = date('Y-m-d') . '_' . $voucherName;
|
|
||||||
|
|
||||||
$controller = new UniFiController(
|
|
||||||
$site['unifi_controller_url'],
|
|
||||||
$site['unifi_username'],
|
|
||||||
Crypto::decrypt($site['unifi_password']),
|
|
||||||
$site['site_id']
|
|
||||||
);
|
|
||||||
|
|
||||||
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
|
|
||||||
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
|
|
||||||
throw new Exception(function_exists('__') ? __('error_voucher_invalid') : 'Ungueltige Antwort des Controllers');
|
|
||||||
}
|
|
||||||
|
|
||||||
$db->execute(
|
|
||||||
"INSERT INTO vouchers (site_id, user_id, kiosk_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
[$site['id'], $userId, $kioskId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
|
|
||||||
);
|
|
||||||
|
|
||||||
$expiryTs = time() + ($expireMinutes * 60);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'code' => $voucher['formatted_code'],
|
|
||||||
'site_name' => $site['name'],
|
|
||||||
'max_uses' => $maxUses,
|
|
||||||
'expire_min' => $expireMinutes,
|
|
||||||
'expiry_date' => date('d.m.Y', $expiryTs),
|
|
||||||
'expiry_time' => date('H:i', $expiryTs),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,122 +4,142 @@
|
||||||
* Expects $currentPage (string), $appTitle (string), $auth, $db to be set before include.
|
* Expects $currentPage (string), $appTitle (string), $auth, $db to be set before include.
|
||||||
* Expects I18n to be initialized.
|
* Expects I18n to be initialized.
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/Ui.php';
|
|
||||||
|
|
||||||
$currentPage = $currentPage ?? '';
|
$currentPage = $currentPage ?? '';
|
||||||
|
$faviconUrl = isset($db) ? $db->getSetting('favicon_url', '') : '';
|
||||||
$currentUser = isset($auth) ? $auth->getCurrentUser() : null;
|
$currentUser = isset($auth) ? $auth->getCurrentUser() : null;
|
||||||
$lang = I18n::getLanguage();
|
$lang = I18n::getLanguage();
|
||||||
$base = $adminBase ?? ''; // Prefix bis zum admin/-Ordner
|
|
||||||
$rootBase = $base === '' ? '../' : ''; // Prefix bis zum Projekt-Root
|
|
||||||
|
|
||||||
/** Navigation als Datenstruktur – Reihenfolge = Darstellung. */
|
|
||||||
$navGroups = [
|
|
||||||
'nav_group_overview' => [
|
|
||||||
['dashboard', 'index.php', 'fa-chart-pie', 'nav_dashboard'],
|
|
||||||
['reports', 'reports.php', 'fa-chart-line', 'nav_reports'],
|
|
||||||
['audit_log', 'audit_log.php', 'fa-clock-rotate-left','nav_audit_log'],
|
|
||||||
],
|
|
||||||
'nav_group_manage' => [
|
|
||||||
['vouchers', 'vouchers.php', 'fa-ticket', 'nav_vouchers'],
|
|
||||||
['templates', 'templates.php', 'fa-layer-group', 'nav_templates'],
|
|
||||||
['import', 'import.php', 'fa-file-arrow-up', 'nav_import'],
|
|
||||||
['kiosks', 'kiosks.php', 'fa-display', 'nav_kiosks'],
|
|
||||||
['sites', 'sites.php', 'fa-location-dot', 'nav_sites'],
|
|
||||||
['users', 'users.php', 'fa-users', 'nav_users'],
|
|
||||||
],
|
|
||||||
'nav_group_system' => [
|
|
||||||
['settings', 'settings.php', 'fa-sliders', 'nav_settings'],
|
|
||||||
['integrations', 'integrations.php', 'fa-plug', 'nav_integrations'],
|
|
||||||
['api_keys', 'api_keys.php', 'fa-key', 'nav_api_keys'],
|
|
||||||
['security', 'security.php', 'fa-shield-halved','nav_security'],
|
|
||||||
['backup', 'backup.php', 'fa-database', 'nav_backup'],
|
|
||||||
['update', 'update.php', 'fa-rotate', 'nav_update'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Aktuellen Seitentitel für die Breadcrumb finden. */
|
|
||||||
$currentLabel = __('nav_dashboard');
|
|
||||||
foreach ($navGroups as $items) {
|
|
||||||
foreach ($items as $item) {
|
|
||||||
if ($item[0] === $currentPage) { $currentLabel = __($item[3]); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?>
|
?>
|
||||||
<?= Ui::head($db ?? null, $rootBase) ?>
|
<?php if ($faviconUrl): ?>
|
||||||
|
<link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($faviconUrl) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="<?= $adminBase ?? '../' ?>assets/global.css">
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const t = localStorage.getItem('theme') || 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Base admin layout using CSS variables */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: var(--bg-body); color: var(--text-primary); }
|
||||||
|
.header { background: var(--bg-header); border-bottom: 1px solid var(--border-color); padding: 0 30px; height: 70px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 150; box-shadow: 0 2px 10px var(--shadow); }
|
||||||
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.header-title { font-size: 20px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.sidebar { position: fixed; left: 0; top: 70px; bottom: 0; width: 260px; background: var(--bg-sidebar); border-right: 1px solid var(--border-color); padding: 20px 0; overflow-y: auto; z-index: 100; }
|
||||||
|
.sidebar-nav { list-style: none; }
|
||||||
|
.sidebar-nav li { margin-bottom: 2px; }
|
||||||
|
.sidebar-nav a { display: flex; align-items: center; gap: 12px; padding: 11px 25px; color: var(--text-secondary); text-decoration: none; transition: all 0.2s; font-size: 14px; border-radius: 0 8px 8px 0; margin-right: 12px; }
|
||||||
|
.sidebar-nav a:hover, .sidebar-nav a.active { background: var(--bg-hover); color: var(--accent); }
|
||||||
|
.sidebar-nav i { width: 18px; text-align: center; font-size: 15px; }
|
||||||
|
.sidebar-section { padding: 16px 25px 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
|
||||||
|
.main-content { margin-left: 260px; padding: 30px; min-height: calc(100vh - 70px); }
|
||||||
|
.user-menu { display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: var(--bg-hover); border-radius: 10px; }
|
||||||
|
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 14px; flex-shrink: 0; }
|
||||||
|
.user-name { font-weight: 500; font-size: 13px; color: var(--text-primary); }
|
||||||
|
.btn { padding: 8px 16px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all 0.2s; font-size: 13px; }
|
||||||
|
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
||||||
|
.btn-secondary:hover { background: var(--border-color); color: var(--text-primary); }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<a class="skip-link" href="#main-content"><?= __('a11y_skip') ?></a>
|
<!-- Sidebar overlay for mobile -->
|
||||||
<div class="sidebar-overlay" onclick="closeMobileSidebar()"></div>
|
<div class="sidebar-overlay" onclick="closeMobileSidebar()"></div>
|
||||||
|
|
||||||
<aside class="sidebar" id="adminSidebar" aria-label="<?= __('nav_administration') ?>">
|
<div class="header">
|
||||||
<a href="<?= $rootBase ?>index.php" class="sidebar-brand">
|
|
||||||
<span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
|
||||||
<span class="brand-text">
|
|
||||||
<span class="brand-name"><?= htmlspecialchars($appTitle ?? 'Voucher Tool') ?></span>
|
|
||||||
<span class="brand-sub"><?= __('nav_administration') ?></span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
<?php foreach ($navGroups as $groupKey => $items): ?>
|
|
||||||
<div class="sidebar-section"><?= __($groupKey) ?></div>
|
|
||||||
<ul class="sidebar-nav">
|
|
||||||
<?php foreach ($items as [$key, $href, $icon, $labelKey]): ?>
|
|
||||||
<li>
|
|
||||||
<a href="<?= $base . $href ?>" class="<?= $currentPage === $key ? 'active' : '' ?>"
|
|
||||||
<?= $currentPage === $key ? 'aria-current="page"' : '' ?>>
|
|
||||||
<i class="fas <?= $icon ?>" aria-hidden="true"></i> <?= __($labelKey) ?>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="sidebar-foot">
|
|
||||||
<?php if ($currentUser): ?>
|
|
||||||
<div class="user-menu" style="width:100%;justify-content:flex-start;">
|
|
||||||
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
|
|
||||||
<div style="min-width:0;flex:1;">
|
|
||||||
<div class="user-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?= htmlspecialchars($currentUser['name']) ?></div>
|
|
||||||
<div class="user-role"><?= htmlspecialchars($currentUser['role'] ?? '') ?></div>
|
|
||||||
</div>
|
|
||||||
<a href="<?= $rootBase ?>logout.php" class="icon-btn" title="<?= __('btn_logout') ?>" aria-label="<?= __('btn_logout') ?>" style="width:30px;height:30px;font-size:12px;">
|
|
||||||
<i class="fas fa-arrow-right-from-bracket" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?= Ui::credit(true) ?>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<header class="topbar">
|
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" aria-label="<?= __('a11y_menu') ?>" title="<?= __('a11y_menu') ?>">
|
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" title="Menu">
|
||||||
<i class="fas fa-bars" aria-hidden="true"></i>
|
<i class="fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="breadcrumb">
|
<div class="header-title">
|
||||||
<span><?= __('nav_administration') ?></span>
|
<i class="fas fa-shield-alt" style="color: var(--accent);"></i>
|
||||||
<span class="sep">/</span>
|
<?= __('nav_administration') ?>
|
||||||
<span class="current"><?= htmlspecialchars($currentLabel) ?></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
|
<!-- Language switcher -->
|
||||||
|
<div class="lang-switcher">
|
||||||
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
||||||
<button class="lang-btn <?= $lang === $code ? 'active' : '' ?>"
|
<button class="lang-btn <?= $lang === $code ? 'active' : '' ?>"
|
||||||
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
|
<!-- Dark mode toggle -->
|
||||||
<i class="fas fa-moon" aria-hidden="true"></i>
|
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">🌙</button>
|
||||||
</button>
|
<!-- Back link -->
|
||||||
<a href="<?= $rootBase ?>index.php" class="btn btn-secondary">
|
<a href="<?= $adminBase ?? '../' ?>index.php" class="btn btn-secondary">
|
||||||
<i class="fas fa-arrow-left" aria-hidden="true"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span class="hide-mobile"><?= __('nav_back') ?></span>
|
<span class="hide-mobile"><?= __('nav_back') ?></span>
|
||||||
</a>
|
</a>
|
||||||
|
<!-- User menu -->
|
||||||
|
<?php if ($currentUser): ?>
|
||||||
|
<div class="user-menu">
|
||||||
|
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
|
||||||
|
<div class="user-name hide-mobile"><?= htmlspecialchars($currentUser['name']) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<main class="main-content" id="main-content">
|
<div class="sidebar" id="adminSidebar">
|
||||||
|
<nav>
|
||||||
|
<div class="sidebar-section">Main</div>
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>index.php" class="<?= $currentPage === 'dashboard' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-home"></i> <?= __('nav_dashboard') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>sites.php" class="<?= $currentPage === 'sites' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-map-marker-alt"></i> <?= __('nav_sites') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>users.php" class="<?= $currentPage === 'users' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-users"></i> <?= __('nav_users') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>vouchers.php" class="<?= $currentPage === 'vouchers' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-ticket-alt"></i> <?= __('nav_vouchers') ?>
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
<div class="sidebar-section" style="margin-top: 10px;">Tools</div>
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>templates.php" class="<?= $currentPage === 'templates' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-layer-group"></i> <?= __('nav_templates') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>audit_log.php" class="<?= $currentPage === 'audit_log' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-history"></i> <?= __('nav_audit_log') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>reports.php" class="<?= $currentPage === 'reports' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-chart-line"></i> <?= __('nav_reports') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>import.php" class="<?= $currentPage === 'import' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-file-import"></i> <?= __('nav_import') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>api_keys.php" class="<?= $currentPage === 'api_keys' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-key"></i> <?= __('nav_api_keys') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>security.php" class="<?= $currentPage === 'security' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-user-shield"></i> <?= __('nav_security') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>integrations.php" class="<?= $currentPage === 'integrations' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-plug"></i> <?= __('nav_integrations') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>backup.php" class="<?= $currentPage === 'backup' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-database"></i> <?= __('nav_backup') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<style>
|
||||||
|
.hide-mobile { }
|
||||||
|
@media(max-width:600px) { .hide-mobile { display: none; } }
|
||||||
|
</style>
|
||||||
|
|
|
||||||