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

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
|
@ -47,6 +47,11 @@
|
|||
- 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
|
||||
- 🎨 **Einheitliches Design-System** – ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark)
|
||||
- 🏷️ **Login-Seite individualisierbar** – Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf
|
||||
- 🖌️ **Eigene Markenfarben** – Akzentfarbe, Verlauf und Eckenradius wirken auf die gesamte Oberfläche
|
||||
- ⬆️ **Bild-Upload** für Logo, Favicon und Login-Hintergrund (kein externes Hosting nötig)
|
||||
- 🔒 **Keine externen CDNs** – Schrift, Icons, Diagramme und Editor werden lokal ausgeliefert (DSGVO, Offline-Netze)
|
||||
- ♿ **Barrierearm** – Kontraste nach WCAG AA, Sprungmarke, aria-Beschriftungen, `prefers-reduced-motion`
|
||||
- 📱 **Mobil nutzbar** – Tabellen werden auf schmalen Geräten zu Karten
|
||||
- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert
|
||||
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
|
||||
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
|
||||
|
|
@ -92,6 +97,11 @@
|
|||
<img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%">
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/settings-branding.png" alt="Markenfarben einstellen" width="48%">
|
||||
<img src="docs/screenshots/mobile-vouchers.png" alt="Ansicht auf dem Smartphone" width="22%">
|
||||
</div>
|
||||
|
||||
### REST-API, 2FA & Integrationen
|
||||
|
||||
<div align="center">
|
||||
|
|
@ -249,21 +259,44 @@ gemeinsames Stylesheet: **`assets/global.css`**.
|
|||
- **Dark Mode** ausschließlich über Tokens – keine `!important`-Overrides mehr
|
||||
- **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback
|
||||
|
||||
Eigenes Branding lässt sich meist mit wenigen Zeilen umsetzen – z. B. in einer
|
||||
eigenen CSS-Datei oder direkt in `assets/global.css`:
|
||||
### 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) */
|
||||
--accent-hover: #0d5f59;
|
||||
--accent-soft: #e6f4f2; /* Flächen für aktive Zustände */
|
||||
--brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%);
|
||||
--r-lg: 14px; /* Eckenradius für Cards */
|
||||
}
|
||||
```
|
||||
|
||||
Logo und Favicon werden nicht über CSS, sondern unter
|
||||
**Administration → Einstellungen → Allgemein** gesetzt.
|
||||
### 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
|
||||
|
||||
|
|
@ -303,13 +336,30 @@ Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
|
|||
| **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout |
|
||||
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
|
||||
|
||||
Empfohlene zusätzliche Härtung am Server:
|
||||
Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern
|
||||
(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
|
||||
`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal
|
||||
liegen, erlaubt die CSP nur noch die eigene Herkunft – externe Verbindungen
|
||||
bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie
|
||||
`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene
|
||||
`.htaccess`-Dateien.
|
||||
|
||||
```apache
|
||||
# .htaccess – sensible Dateien sperren (wird vom Installer erzeugt)
|
||||
<FilesMatch "^(config\.php|database\.sql|install\.php|test\.php|m365_debug\.php|.*\.md)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
> **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst
|
||||
> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image
|
||||
> erledigt das bereits.
|
||||
|
||||
Für **Nginx** entspricht das:
|
||||
|
||||
```nginx
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always;
|
||||
|
||||
location ~ ^/(includes|tools|tests)/ { deny all; }
|
||||
location ~ ^/updater/(storage|migrations)/ { deny all; }
|
||||
location ~ ^/(config\.php|database\.sql)$ { deny all; }
|
||||
location ^~ /uploads/ { location ~ \.php$ { deny all; } }
|
||||
```
|
||||
|
||||
```sql
|
||||
|
|
@ -432,6 +482,40 @@ Das Schema wird beim ersten Start automatisch in MariaDB geladen; danach den
|
|||
Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV
|
||||
setzen (`DB_*`, `APP_KEY`).
|
||||
|
||||
Hochgeladene Logos und Hintergründe liegen im Volume `uploads` und überstehen
|
||||
damit ein Image-Update. Bei eigener Apache-/Nginx-Installation muss `uploads/`
|
||||
für den Webserver beschreibbar sein:
|
||||
|
||||
```bash
|
||||
chown -R www-data:www-data uploads updater/storage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Entwicklung
|
||||
|
||||
Für Screenshots und einen schnellen Durchlauf aller Seiten gibt es eine
|
||||
Demo-Instanz **ohne Datenbank** – `Database` und `Auth` werden durch Stubs mit
|
||||
festen Beispieldaten ersetzt:
|
||||
|
||||
```bash
|
||||
python3 tools/demo/build.py /tmp/uvt-demo
|
||||
php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
|
||||
|
||||
# Bilder in docs/screenshots neu erzeugen (benötigt headless Chromium)
|
||||
CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py
|
||||
```
|
||||
|
||||
Details und die verfügbaren Demo-Zustände: [`tools/README.md`](tools/README.md).
|
||||
|
||||
Tests und statische Analyse:
|
||||
|
||||
```bash
|
||||
composer install
|
||||
vendor/bin/phpunit
|
||||
vendor/bin/phpstan analyse
|
||||
```
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- [x] Voucher-Templates (vordefinierte Laufzeiten)
|
||||
|
|
@ -447,11 +531,14 @@ setzen (`DB_*`, `APP_KEY`).
|
|||
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
|
||||
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
|
||||
- [x] Gemeinsames Design-System für Frontend, Login und Backend
|
||||
- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite)
|
||||
- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
|
||||
- [x] Vollständige englische Übersetzung des Admin-Bereichs
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Version 2.5.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
||||
**Version 2.6.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) {
|
|||
);
|
||||
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt");
|
||||
$newKey = $k['plain'];
|
||||
$success = 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!';
|
||||
$success = __('api_created_once');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,14 +46,14 @@ if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken(
|
|||
$row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]);
|
||||
if ($row) {
|
||||
$db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]);
|
||||
$success = 'Status aktualisiert.';
|
||||
$success = __('api_status_updated');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
||||
$db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]);
|
||||
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht');
|
||||
$success = 'API-Schlüssel gelöscht.';
|
||||
$success = __('api_deleted');
|
||||
}
|
||||
|
||||
$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC");
|
||||
|
|
@ -66,12 +66,12 @@ $adminBase = '';
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API-Schlüssel – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<title><?= __('api_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">API-Schlüssel</h1>
|
||||
<p class="page-subtitle">Zugänge für externe Systeme – mit Scope und Rate-Limit.</p>
|
||||
<h1 class="page-title"><?= __('api_title') ?></h1>
|
||||
<p class="page-subtitle"><?= __('api_subtitle') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -80,55 +80,55 @@ $adminBase = '';
|
|||
|
||||
<?php if ($newKey): ?>
|
||||
<div class="card">
|
||||
<h2>Neuer Schlüssel</h2>
|
||||
<p class="muted">Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.</p>
|
||||
<h2><?= __('api_new_key') ?></h2>
|
||||
<p class="muted"><?= __('api_new_key_hint') ?></p>
|
||||
<div class="keybox"><?= htmlspecialchars($newKey) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>Neuen API-Schlüssel erstellen</h2>
|
||||
<h2><?= __('api_create_title') ?></h2>
|
||||
<form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<div style="flex:2;min-width:200px;">
|
||||
<label class="muted" style="display:block;margin-bottom:6px;">Bezeichnung</label>
|
||||
<input class="input" type="text" name="name" placeholder="z.B. Buchungssystem, Terminal Foyer" required>
|
||||
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_name') ?></label>
|
||||
<input class="input" type="text" name="name" placeholder="<?= __('api_name_placeholder') ?>" required>
|
||||
</div>
|
||||
<div style="flex:1;min-width:130px;">
|
||||
<label class="muted" style="display:block;margin-bottom:6px;">Berechtigung</label>
|
||||
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_scope') ?></label>
|
||||
<select class="input" name="scope">
|
||||
<option value="write">Lesen + Erstellen</option>
|
||||
<option value="read">Nur Lesen</option>
|
||||
<option value="write"><?= __('api_scope_write') ?></option>
|
||||
<option value="read"><?= __('api_scope_read') ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1;min-width:120px;">
|
||||
<label class="muted" style="display:block;margin-bottom:6px;">Limit (Anfr./min)</label>
|
||||
<input class="input" type="number" name="rate_limit" min="0" value="0" title="0 = unbegrenzt">
|
||||
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_limit') ?></label>
|
||||
<input class="input" type="number" name="rate_limit" min="0" value="0" title="<?= __('api_limit_title') ?>">
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" name="create_key">Erstellen</button>
|
||||
<button class="btn btn-primary" type="submit" name="create_key"><?= __('btn_create') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Vorhandene Schlüssel</h2>
|
||||
<h2><?= __('api_existing') ?></h2>
|
||||
<?php if (empty($keys)): ?>
|
||||
<p class="muted">Noch keine API-Schlüssel angelegt.</p>
|
||||
<p class="muted"><?= __('api_none') ?></p>
|
||||
<?php else: ?>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<tr><th>Name</th><th>Präfix</th><th>Scope</th><th>Limit</th><th>Status</th><th>Zuletzt genutzt</th><th>Erstellt von</th><th></th></tr>
|
||||
<table class="table-stack">
|
||||
<tr><th><?= __('label_name') ?></th><th><?= __('api_col_prefix') ?></th><th><?= __('api_col_scope') ?></th><th><?= __('api_col_limit') ?></th><th><?= __('label_status') ?></th><th><?= __('api_col_last_used') ?></th><th><?= __('api_col_created_by') ?></th><th></th></tr>
|
||||
<?php foreach ($keys as $k): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($k['name']) ?></td>
|
||||
<td><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
|
||||
<td><?= ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?></td>
|
||||
<td><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
|
||||
<td><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? 'aktiv' : 'gesperrt' ?></span></td>
|
||||
<td class="muted"><?= $k['last_used_at'] ? date('d.m.Y H:i', strtotime($k['last_used_at'])) : '–' ?></td>
|
||||
<td class="muted"><?= htmlspecialchars($k['creator'] ?? '–') ?></td>
|
||||
<td data-label="<?= __('label_name') ?>"><?= htmlspecialchars($k['name']) ?></td>
|
||||
<td data-label="<?= __('api_col_prefix') ?>"><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
|
||||
<td data-label="<?= __('api_col_scope') ?>"><?= ($k['scope'] ?? 'write') === 'read' ? __('api_scope_read_short') : __('api_scope_write_short') ?></td>
|
||||
<td data-label="<?= __('api_col_limit') ?>"><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
|
||||
<td data-label="<?= __('label_status') ?>"><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? __('api_state_active') : __('api_state_blocked') ?></span></td>
|
||||
<td class="muted" data-label="<?= __('api_col_last_used') ?>"><?= $k['last_used_at'] ? date('d.m.Y H:i', strtotime($k['last_used_at'])) : '–' ?></td>
|
||||
<td class="muted" data-label="<?= __('api_col_created_by') ?>"><?= htmlspecialchars($k['creator'] ?? '–') ?></td>
|
||||
<td style="text-align:right;white-space:nowrap;">
|
||||
<a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? 'Sperren' : 'Aktivieren' ?></a>
|
||||
<a class="a-link" style="color:var(--danger);" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('Schlüssel löschen?');">Löschen</a>
|
||||
<a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? __('api_action_block') : __('api_action_unblock') ?></a>
|
||||
<a class="a-link" style="color:var(--danger);" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('<?= __('api_delete_confirm') ?>');"><?= __('btn_delete') ?></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
|
@ -138,8 +138,8 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Verwendung</h2>
|
||||
<p class="muted" style="margin-bottom:10px;">Authentifizierung per Header <code>Authorization: Bearer <key></code> oder <code>X-API-Key: <key></code>.</p>
|
||||
<h2><?= __('api_usage') ?></h2>
|
||||
<p class="muted" style="margin-bottom:10px;"><?= __('api_usage_hint') ?> <code>Authorization: Bearer <key></code> oder <code>X-API-Key: <key></code>.</p>
|
||||
<pre class="keybox" style="color:#cdd3e0;white-space:pre-wrap;"># Voucher erstellen
|
||||
curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
|
||||
-H "Authorization: Bearer uvt_…" \
|
||||
|
|
@ -148,7 +148,7 @@ curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
|
|||
|
||||
# Sites auflisten
|
||||
curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"</pre>
|
||||
<p class="muted" style="margin-top:12px;">OpenAPI-Spezifikation (Import in Postman/Swagger): <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p>
|
||||
<p class="muted" style="margin-top:12px;"><?= __('api_openapi') ?> <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -45,23 +45,14 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY
|
|||
$currentPage = 'audit_log';
|
||||
$adminBase = '';
|
||||
|
||||
$actionLabels = [
|
||||
'voucher_created' => 'Voucher erstellt',
|
||||
'voucher_bulk' => 'Bulk Voucher',
|
||||
'user_login' => 'Login',
|
||||
'user_logout' => 'Logout',
|
||||
'user_created' => 'Benutzer erstellt',
|
||||
'user_updated' => 'Benutzer geändert',
|
||||
'user_deleted' => 'Benutzer gelöscht',
|
||||
'site_added' => 'Site hinzugefügt',
|
||||
'site_updated' => 'Site geändert',
|
||||
'site_deleted' => 'Site gelöscht',
|
||||
'settings_saved' => 'Einstellungen gespeichert',
|
||||
'password_reset' => 'Passwort-Reset',
|
||||
'template_created' => 'Profil erstellt',
|
||||
'template_updated' => 'Profil geändert',
|
||||
'template_deleted' => 'Profil gelöscht',
|
||||
];
|
||||
// Aktionsnamen uebersetzt anzeigen; unbekannte Aktionen bleiben technisch.
|
||||
$actionLabels = [];
|
||||
foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created',
|
||||
'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted',
|
||||
'settings_saved', 'password_reset', 'template_created', 'template_updated',
|
||||
'template_deleted'] as $action) {
|
||||
$actionLabels[$action] = __('audit_action_' . $action);
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?= I18n::getLanguage() ?>">
|
||||
|
|
@ -80,7 +71,7 @@ $actionLabels = [
|
|||
|
||||
<!-- Filter -->
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<div class="card-header"><span class="card-title"><i class="fas fa-filter"></i> <?= __('audit_filter') ?></span></div>
|
||||
<div class="card-header"><span class="card-title"><i class="fas fa-filter" aria-hidden="true"></i> <?= __('audit_filter') ?></span></div>
|
||||
<div style="padding: 20px 25px;">
|
||||
<form method="get" class="filter-bar">
|
||||
<div>
|
||||
|
|
@ -97,7 +88,7 @@ $actionLabels = [
|
|||
<div>
|
||||
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:5px;"><?= __('audit_user') ?></label>
|
||||
<select name="user_id">
|
||||
<option value="">Alle Benutzer</option>
|
||||
<option value=""><?= __('audit_all_users') ?></option>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<option value="<?= $u['id'] ?>" <?= (string)$filterUser === (string)$u['id'] ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($u['name']) ?>
|
||||
|
|
@ -105,8 +96,8 @@ $actionLabels = [
|
|||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search"></i> Filtern</button>
|
||||
<a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times"></i> Zurücksetzen</a>
|
||||
<button type="submit" class="btn btn-primary btn-small"><i class="fas fa-search" aria-hidden="true"></i> Filtern</button>
|
||||
<a href="audit_log.php" class="btn btn-secondary btn-small"><i class="fas fa-times" aria-hidden="true"></i> Zurücksetzen</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -114,15 +105,15 @@ $actionLabels = [
|
|||
<!-- Log-Tabelle -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title"><i class="fas fa-history"></i> <?= __('audit_title') ?></span>
|
||||
<span class="card-title"><i class="fas fa-history" aria-hidden="true"></i> <?= __('audit_title') ?></span>
|
||||
<span style="font-size:13px;color:var(--text-muted);"><?= number_format($total) ?> Einträge</span>
|
||||
</div>
|
||||
<?php if (empty($logs)): ?>
|
||||
<div class="empty-state"><i class="fas fa-history"></i><p><?= __('audit_none') ?></p></div>
|
||||
<div class="empty-state"><i class="fas fa-history" aria-hidden="true"></i><p><?= __('audit_none') ?></p></div>
|
||||
<?php else: ?>
|
||||
<div style="overflow-x:auto;">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<table class="table table-stack">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= __('audit_time') ?></th>
|
||||
|
|
@ -136,34 +127,34 @@ $actionLabels = [
|
|||
<tbody>
|
||||
<?php foreach ($logs as $log): ?>
|
||||
<tr>
|
||||
<td style="white-space:nowrap;color:var(--text-muted);">
|
||||
<td data-label="<?= __('audit_time') ?>" style="white-space:nowrap;color:var(--text-muted);">
|
||||
<?= date('d.m.Y', strtotime($log['created_at'])) ?><br>
|
||||
<small><?= date('H:i:s', strtotime($log['created_at'])) ?></small>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="<?= __('audit_action') ?>">
|
||||
<span class="action-chip">
|
||||
<?= htmlspecialchars($actionLabels[$log['action']] ?? $log['action']) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="<?= __('audit_user') ?>">
|
||||
<?php if ($log['user_name']): ?>
|
||||
<strong style="font-size:13px;"><?= htmlspecialchars($log['user_name']) ?></strong><br>
|
||||
<small style="color:var(--text-muted);"><?= htmlspecialchars($log['user_email'] ?? '') ?></small>
|
||||
<?php else: ?>
|
||||
<em style="color:var(--text-muted);">System/Anonym</em>
|
||||
<em style="color:var(--text-muted);"><?= __('audit_system_anon') ?></em>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="color:var(--text-secondary);">
|
||||
<td data-label="<?= __('audit_entity') ?>" style="color:var(--text-secondary);">
|
||||
<?php if ($log['entity_type']): ?>
|
||||
<code style="font-size:11px;"><?= htmlspecialchars($log['entity_type']) ?>:<?= htmlspecialchars($log['entity_id'] ?? '') ?></code>
|
||||
<?php else: ?>
|
||||
<span style="color:var(--text-muted);">-</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="details-cell" title="<?= htmlspecialchars($log['details'] ?? '') ?>">
|
||||
<td class="details-cell" data-label="<?= __('audit_details') ?>" title="<?= htmlspecialchars($log['details'] ?? '') ?>">
|
||||
<?= htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, '…')) ?>
|
||||
</td>
|
||||
<td class="ip-cell"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
||||
<td class="ip-cell" data-label="<?= __('audit_ip') ?>"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
|
@ -173,18 +164,18 @@ $actionLabels = [
|
|||
|
||||
<?php if ($pages > 1): ?>
|
||||
<div class="pagination">
|
||||
<span class="page-info">Seite <?= $page ?> von <?= $pages ?> (<?= $total ?> Einträge)</span>
|
||||
<span class="page-info"><?= str_replace(['{page}', '{pages}', '{total}'], [(string)$page, (string)$pages, number_format((int)$total, 0, ',', '.')], __('audit_page_info')) ?></span>
|
||||
<div class="page-btns">
|
||||
<?php
|
||||
$baseUrl = '?' . http_build_query(array_filter(['action' => $filterAction, 'user_id' => $filterUser]));
|
||||
if ($page > 1): ?>
|
||||
<a href="<?= $baseUrl ?>&page=<?= $page - 1 ?>" class="page-btn"><i class="fas fa-chevron-left"></i></a>
|
||||
<a href="<?= $baseUrl ?>&page=<?= $page - 1 ?>" class="page-btn"><i class="fas fa-chevron-left" aria-hidden="true"></i></a>
|
||||
<?php endif;
|
||||
for ($p = max(1, $page - 2); $p <= min($pages, $page + 2); $p++): ?>
|
||||
<a href="<?= $baseUrl ?>&page=<?= $p ?>" class="page-btn <?= $p === $page ? 'active' : '' ?>"><?= $p ?></a>
|
||||
<?php endfor;
|
||||
if ($page < $pages): ?>
|
||||
<a href="<?= $baseUrl ?>&page=<?= $page + 1 ?>" class="page-btn"><i class="fas fa-chevron-right"></i></a>
|
||||
<a href="<?= $baseUrl ?>&page=<?= $page + 1 ?>" class="page-btn"><i class="fas fa-chevron-right" aria-hidden="true"></i></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,12 +43,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
|
|||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
$error = __('error_csrf');
|
||||
} elseif (empty($_FILES['backup']['tmp_name'])) {
|
||||
$error = 'Bitte eine Backup-Datei auswählen.';
|
||||
$error = __('backup_choose_file');
|
||||
} else {
|
||||
$raw = file_get_contents($_FILES['backup']['tmp_name']);
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') {
|
||||
$error = 'Ungültige oder fremde Backup-Datei.';
|
||||
$error = __('backup_invalid_file');
|
||||
} else {
|
||||
$importSites = isset($_POST['import_sites']);
|
||||
$importTemplates = isset($_POST['import_templates']);
|
||||
|
|
@ -95,7 +95,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
|
|||
}
|
||||
}
|
||||
$auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert');
|
||||
$success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile.";
|
||||
$success = str_replace(['{settings}', '{sites}', '{templates}'],
|
||||
[(string)$counts['settings'], (string)$counts['sites'], (string)$counts['templates']],
|
||||
__('backup_imported'));
|
||||
} catch (Exception $e) {
|
||||
$error = 'Import-Fehler: ' . $e->getMessage();
|
||||
}
|
||||
|
|
@ -112,12 +114,12 @@ $adminBase = '';
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Backup & Restore – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<title><?= __('backup_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Backup & Restore</h1>
|
||||
<p class="page-subtitle">Konfiguration und Daten sichern oder wiederherstellen.</p>
|
||||
<h1 class="page-title"><?= __('backup_title') ?></h1>
|
||||
<p class="page-subtitle"><?= __('backup_subtitle') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -125,21 +127,21 @@ $adminBase = '';
|
|||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>Export</h2>
|
||||
<p class="muted">Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem <code>APP_KEY</code> dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.</p>
|
||||
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>">Konfiguration exportieren</a>
|
||||
<h2><?= __('backup_export') ?></h2>
|
||||
<p class="muted"><?= __('backup_export_hint') ?> <code>APP_KEY</code> <?= __('backup_export_hint2') ?></p>
|
||||
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>"><?= __('backup_export_btn') ?></a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Import / Restore</h2>
|
||||
<p class="muted">Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.</p>
|
||||
<h2><?= __('backup_import') ?></h2>
|
||||
<p class="muted"><?= __('backup_import_hint') ?></p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<input type="file" name="backup" accept="application/json,.json" required><br>
|
||||
<label class="chk"><input type="checkbox" name="import_settings" checked> Einstellungen</label>
|
||||
<label class="chk"><input type="checkbox" name="import_sites" checked> Sites</label>
|
||||
<label class="chk"><input type="checkbox" name="import_templates" checked> Voucher-Profile</label>
|
||||
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('Import jetzt durchführen?');">Importieren</button>
|
||||
<label class="chk"><input type="checkbox" name="import_settings" checked> <?= __('backup_opt_settings') ?></label>
|
||||
<label class="chk"><input type="checkbox" name="import_sites" checked> <?= __('backup_opt_sites') ?></label>
|
||||
<label class="chk"><input type="checkbox" name="import_templates" checked> <?= __('backup_opt_templates') ?></label>
|
||||
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('<?= __('backup_import_confirm') ?>');"><?= __('import_submit') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['do_import'])) {
|
|||
Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null);
|
||||
$auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert");
|
||||
}
|
||||
$success = "$created Voucher erstellt.";
|
||||
$success = str_replace('{count}', (string)$created, __('import_created'));
|
||||
} catch (Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
|
@ -95,8 +95,8 @@ $adminBase = '';
|
|||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Voucher-Import</h1>
|
||||
<p class="page-subtitle">Mehrere Vouchers auf einmal aus einer CSV-Liste erstellen.</p>
|
||||
<h1 class="page-title"><?= __('import_title') ?></h1>
|
||||
<p class="page-subtitle"><?= __('import_subtitle') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -104,27 +104,27 @@ $adminBase = '';
|
|||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>Mehrere Voucher erstellen</h2>
|
||||
<p class="muted">Eine Zeile pro Voucher: <code>Name,MaxGeräte,Minuten</code> – MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:<br>
|
||||
<h2><?= __('import_card_title') ?></h2>
|
||||
<p class="muted"><?= __('import_format_hint') ?> <code>Name,MaxGeräte,Minuten</code> <?= __('import_format_hint2') ?><br>
|
||||
<code>Gast Müller,1,480</code> · <code>Konferenzraum A,5,240</code> · <code>Tagespass</code></p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<label>Standort</label>
|
||||
<label><?= __('import_site') ?></label>
|
||||
<select class="input" name="site_id" required>
|
||||
<?php foreach ($sites as $s): ?><option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
<label>CSV-Datei (optional)</label>
|
||||
<label><?= __('import_file') ?></label>
|
||||
<input class="input" type="file" name="csv" accept=".csv,text/csv">
|
||||
<label>… oder direkt einfügen</label>
|
||||
<label><?= __('import_paste') ?></label>
|
||||
<textarea name="csv_text" placeholder="Gast Müller,1,480 Konferenzraum A,5,240"></textarea>
|
||||
<button class="btn" type="submit" name="do_import" style="margin-top:14px;" onclick="return confirm('Import jetzt starten?');">Importieren</button>
|
||||
<button class="btn" type="submit" name="do_import" style="margin-top:14px;" onclick="return confirm('<?= __('import_confirm') ?>');"><?= __('import_submit') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($results)): ?>
|
||||
<div class="card">
|
||||
<h2>Ergebnis</h2>
|
||||
<table><tr><th>Name</th><th>Code / Fehler</th><th>Status</th></tr>
|
||||
<h2><?= __('import_result') ?></h2>
|
||||
<table><tr><th><?= __('label_name') ?></th><th><?= __('import_col_code') ?></th><th><?= __('label_status') ?></th></tr>
|
||||
<?php foreach ($results as $r): ?>
|
||||
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><code><?= htmlspecialchars($r['code']) ?></code></td><td><?= $r['ok'] ? '✅' : '❌' ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ ini_set('log_errors', 1);
|
|||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/../includes/Database.php';
|
||||
require_once __DIR__ . '/../includes/Auth.php';
|
||||
require_once __DIR__ . '/../includes/Ui.php';
|
||||
require_once __DIR__ . '/../includes/UniFiController.php';
|
||||
require_once __DIR__ . '/../includes/I18n.php';
|
||||
|
||||
|
|
@ -90,7 +91,7 @@ $currentPage = 'dashboard';
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= __('dashboard_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
|
||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||
|
||||
<div class="page-header">
|
||||
|
|
@ -104,7 +105,7 @@ $currentPage = 'dashboard';
|
|||
<span id="liveStatus">DB</span>
|
||||
</span>
|
||||
<button onclick="refreshData('live')" class="btn btn-secondary btn-sm" id="refreshBtn">
|
||||
<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>
|
||||
<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -113,21 +114,21 @@ $currentPage = 'dashboard';
|
|||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<div class="stat-card-title"><?= __('dashboard_active_sites') ?></div>
|
||||
<div class="stat-card-icon info"><i class="fas fa-location-dot"></i></div>
|
||||
<div class="stat-card-icon info"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
|
||||
</div>
|
||||
<div class="stat-card-value"><?= $stats['total_sites'] ?></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<div class="stat-card-title"><?= __('dashboard_users') ?></div>
|
||||
<div class="stat-card-icon accent"><i class="fas fa-users"></i></div>
|
||||
<div class="stat-card-icon accent"><i class="fas fa-users" aria-hidden="true"></i></div>
|
||||
</div>
|
||||
<div class="stat-card-value"><?= $stats['total_users'] ?></div>
|
||||
</div>
|
||||
<div class="stat-card live">
|
||||
<div class="stat-card-header">
|
||||
<div class="stat-card-title"><?= __('dashboard_valid') ?></div>
|
||||
<div class="stat-card-icon success"><i class="fas fa-circle-check"></i></div>
|
||||
<div class="stat-card-icon success"><i class="fas fa-circle-check" aria-hidden="true"></i></div>
|
||||
</div>
|
||||
<div class="stat-card-value valid" id="liveValid"><?= (int)($voucherStats['valid']??0) ?></div>
|
||||
<div class="stat-card-sub" id="subValid"><?= $lastCronSync ? date('H:i', strtotime($lastCronSync)) : __('never') ?></div>
|
||||
|
|
@ -135,21 +136,21 @@ $currentPage = 'dashboard';
|
|||
<div class="stat-card live">
|
||||
<div class="stat-card-header">
|
||||
<div class="stat-card-title"><?= __('dashboard_used') ?></div>
|
||||
<div class="stat-card-icon warning"><i class="fas fa-user-check"></i></div>
|
||||
<div class="stat-card-icon warning"><i class="fas fa-user-check" aria-hidden="true"></i></div>
|
||||
</div>
|
||||
<div class="stat-card-value used" id="liveUsed"><?= (int)($voucherStats['used']??0) ?></div>
|
||||
</div>
|
||||
<div class="stat-card live">
|
||||
<div class="stat-card-header">
|
||||
<div class="stat-card-title"><?= __('dashboard_expired') ?></div>
|
||||
<div class="stat-card-icon danger"><i class="fas fa-circle-xmark"></i></div>
|
||||
<div class="stat-card-icon danger"><i class="fas fa-circle-xmark" aria-hidden="true"></i></div>
|
||||
</div>
|
||||
<div class="stat-card-value expired" id="liveExpired"><?= (int)($voucherStats['expired']??0) ?></div>
|
||||
</div>
|
||||
<div class="stat-card live">
|
||||
<div class="stat-card-header">
|
||||
<div class="stat-card-title"><?= __('dashboard_total') ?></div>
|
||||
<div class="stat-card-icon"><i class="fas fa-ticket"></i></div>
|
||||
<div class="stat-card-icon"><i class="fas fa-ticket" aria-hidden="true"></i></div>
|
||||
</div>
|
||||
<div class="stat-card-value" id="liveTotal"><?= (int)($voucherStats['total']??0) ?></div>
|
||||
</div>
|
||||
|
|
@ -182,9 +183,9 @@ $currentPage = 'dashboard';
|
|||
<?php endforeach; ?>
|
||||
<?php if (empty($sites)): ?>
|
||||
<div class="empty-state" style="grid-column:1/-1;">
|
||||
<div class="empty-icon"><i class="fas fa-location-dot"></i></div>
|
||||
<div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
|
||||
<p><?= __('dashboard_no_data') ?></p>
|
||||
<a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus"></i> <?= __('sites_add') ?></a>
|
||||
<a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?></a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
|
@ -203,7 +204,7 @@ $currentPage = 'dashboard';
|
|||
<div class="card-header"><h2 class="card-title"><?= __('dashboard_top_users') ?></h2></div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($topUsers)): ?>
|
||||
<div class="empty-state"><i class="fas fa-users"></i><p><?= __('dashboard_no_data') ?></p></div>
|
||||
<div class="empty-state"><i class="fas fa-users" aria-hidden="true"></i><p><?= __('dashboard_no_data') ?></p></div>
|
||||
<?php else: ?>
|
||||
<ul class="top-users-list">
|
||||
<?php foreach ($topUsers as $u): ?>
|
||||
|
|
@ -224,21 +225,21 @@ $currentPage = 'dashboard';
|
|||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title"><?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-arrow-up-right-from-square"></i> Live</a></div>
|
||||
<div class="card-header"><h2 class="card-title"><?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> Live</a></div>
|
||||
<div class="card-body" style="padding:0;">
|
||||
<?php if (empty($recentVouchers)): ?>
|
||||
<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('dashboard_no_vouchers') ?></p></div>
|
||||
<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= __('dashboard_no_vouchers') ?></p></div>
|
||||
<?php else: ?>
|
||||
<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>
|
||||
<tbody>
|
||||
<?php foreach ($recentVouchers as $v): ?>
|
||||
<tr>
|
||||
<td style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
|
||||
<td><code><?= htmlspecialchars($v['voucher_code']) ?></code></td>
|
||||
<td><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
|
||||
<td>
|
||||
<td data-label="<?= __('label_created') ?>" style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
|
||||
<td data-label="<?= __('label_code') ?>"><code><?= htmlspecialchars($v['voucher_code']) ?></code></td>
|
||||
<td data-label="<?= __('label_site') ?>"><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
|
||||
<td data-label="<?= __('label_status') ?>">
|
||||
<?php $st=$v['status']??'valid'; ?>
|
||||
<span class="badge badge-<?= $st==='valid'?'success':($st==='used'?'warning':'danger') ?>"><?= __('status_'.$st) ?></span>
|
||||
</td>
|
||||
|
|
@ -254,7 +255,7 @@ $currentPage = 'dashboard';
|
|||
|
||||
</main>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||
<script src="../assets/global.js"></script>
|
||||
<script>
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
|
|
@ -301,7 +302,7 @@ async function refreshData(mode='db') {
|
|||
liveBadge.className = 'live-badge loading';
|
||||
liveStatus.textContent = '...';
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>';
|
||||
try {
|
||||
const url = `index.php?ajax_stats=1${mode==='live'?'&sync=1':''}`;
|
||||
const result = await fetch(url).then(r=>r.json());
|
||||
|
|
@ -339,7 +340,7 @@ async function refreshData(mode='db') {
|
|||
}
|
||||
isLoading = false;
|
||||
refreshBtn.disabled = false;
|
||||
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>';
|
||||
refreshBtn.innerHTML = '<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -49,13 +49,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
|
|||
$db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0)));
|
||||
$db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30)));
|
||||
$auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert');
|
||||
$success = 'Einstellungen gespeichert.';
|
||||
$success = __('settings_saved');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
|
||||
Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']);
|
||||
$success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).';
|
||||
$success = __('int_webhook_test_sent');
|
||||
}
|
||||
|
||||
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
|
||||
|
|
@ -92,12 +92,12 @@ $adminBase = '';
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Integration & Wartung – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<title><?= __('int_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Integration & Wartung</h1>
|
||||
<p class="page-subtitle">SSO, Webhooks, SMS-Versand und Aufbewahrungsfristen.</p>
|
||||
<h1 class="page-title"><?= __('int_title') ?></h1>
|
||||
<p class="page-subtitle"><?= __('int_subtitle') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -108,64 +108,64 @@ $adminBase = '';
|
|||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
|
||||
<div class="card">
|
||||
<h2>Sicherheitsrichtlinie</h2>
|
||||
<p class="muted">Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.</p>
|
||||
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label>
|
||||
<label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</label>
|
||||
<h2><?= __('int_security') ?></h2>
|
||||
<p class="muted"><?= __('int_security_hint') ?></p>
|
||||
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> <?= __('int_enforce_2fa') ?></label>
|
||||
<label><?= __('int_daily_limit') ?></label>
|
||||
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
|
||||
<label>Session-Speicher</label>
|
||||
<label><?= __('int_session_driver') ?></label>
|
||||
<select class="input" name="session_driver" style="max-width:340px;">
|
||||
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>>PHP-Standard (Dateien)</option>
|
||||
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>>Datenbank (ermöglicht „überall abmelden")</option>
|
||||
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>><?= __('int_session_php') ?></option>
|
||||
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>><?= __('int_session_db') ?></option>
|
||||
</select>
|
||||
<label>Captcha im öffentlichen Modus</label>
|
||||
<label><?= __('int_captcha') ?></label>
|
||||
<select class="input" name="captcha_mode" style="max-width:340px;">
|
||||
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option>
|
||||
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>>Rechenaufgabe (ohne externen Dienst)</option>
|
||||
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>><?= __('int_captcha_off') ?></option>
|
||||
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>><?= __('int_captcha_math') ?></option>
|
||||
<option value="hcaptcha" <?= $captchaMode==='hcaptcha'?'selected':'' ?>>hCaptcha</option>
|
||||
</select>
|
||||
<div class="row3" style="margin-top:10px;">
|
||||
<div><label>hCaptcha Site-Key</label><input class="input" type="text" name="captcha_site_key" value="<?= htmlspecialchars($captchaSiteKey) ?>"></div>
|
||||
<div><label>hCaptcha Secret<?= $captchaSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
||||
<div><label>hCaptcha Secret<?= $captchaSecretSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? __('int_secret_placeholder') : '' ?>"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Reverse-Proxy</h2>
|
||||
<p class="muted">IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus <code>X-Forwarded-For</code> für Rate-Limit & Audit verwendet.</p>
|
||||
<h2><?= __('int_proxy') ?></h2>
|
||||
<p class="muted"><?= __('int_proxy_hint') ?> <code>X-Forwarded-For</code> <?= __('int_proxy_hint2') ?></p>
|
||||
<input class="input" type="text" name="trusted_proxy" value="<?= htmlspecialchars($trustedProxy) ?>" placeholder="z.B. 10.0.0.1, 172.18.0.1">
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Webhook-Benachrichtigungen</h2>
|
||||
<p class="muted">Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.</p>
|
||||
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> Webhook aktiv</label>
|
||||
<label>Webhook-URL</label>
|
||||
<h2><?= __('int_webhook') ?></h2>
|
||||
<p class="muted"><?= __('int_webhook_hint') ?></p>
|
||||
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> <?= __('int_webhook_active') ?></label>
|
||||
<label><?= __('int_webhook_url') ?></label>
|
||||
<input class="input" type="url" name="webhook_url" value="<?= htmlspecialchars($webhookUrl) ?>" placeholder="https://hooks.slack.com/services/…">
|
||||
<div style="margin-top:12px;">
|
||||
<a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>">Test senden</a>
|
||||
<a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>"><?= __('int_webhook_test') ?></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>SMS-Versand (Twilio)</h2>
|
||||
<p class="muted">Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.</p>
|
||||
<label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> SMS-Versand aktiv</label>
|
||||
<h2><?= __('int_sms') ?></h2>
|
||||
<p class="muted"><?= __('int_sms_hint') ?></p>
|
||||
<label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> <?= __('int_sms_active') ?></label>
|
||||
<div class="row3" style="margin-top:10px;">
|
||||
<div><label>Account SID</label><input class="input" type="text" name="twilio_sid" value="<?= htmlspecialchars($twilioSid) ?>"></div>
|
||||
<div><label>Auth Token<?= $twilioTokenSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
||||
<div><label>Absender (From)</label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div>
|
||||
<div><label>Auth Token<?= $twilioTokenSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? __('int_secret_placeholder') : '' ?>"></div>
|
||||
<div><label><?= __('int_sms_from') ?></label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Single Sign-On (OpenID Connect)</h2>
|
||||
<p class="muted">Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI: <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p>
|
||||
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> OIDC-Login aktiv</label>
|
||||
<h2><?= __('int_sso') ?></h2>
|
||||
<p class="muted"><?= __('int_sso_hint') ?> <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p>
|
||||
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> <?= __('int_sso_active') ?></label>
|
||||
<div class="row3" style="margin-top:10px;">
|
||||
<div><label>Button-Text</label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div>
|
||||
<div><label><?= __('int_sso_button') ?></label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div>
|
||||
<div><label>Client ID</label><input class="input" type="text" name="oidc_client_id" value="<?= htmlspecialchars($oidcClientId) ?>"></div>
|
||||
<div><label>Client Secret<?= $oidcSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
||||
<div><label>Client Secret<?= $oidcSecretSet ? __('int_secret_set') : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? __('int_secret_placeholder') : '' ?>"></div>
|
||||
</div>
|
||||
<label>Authorization Endpoint</label><input class="input" type="url" name="oidc_auth_url" value="<?= htmlspecialchars($oidcAuthUrl) ?>" placeholder="https://idp/authorize">
|
||||
<label>Token Endpoint</label><input class="input" type="url" name="oidc_token_url" value="<?= htmlspecialchars($oidcTokenUrl) ?>" placeholder="https://idp/token">
|
||||
|
|
@ -174,18 +174,18 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Datenhaltung & Cleanup (DSGVO)</h2>
|
||||
<p class="muted">Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per <code>cron_cleanup.php</code> (täglich empfohlen).
|
||||
<?php if ($lastCleanup): ?><br>Letzter Lauf: <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
|
||||
<h2><?= __('int_cleanup') ?></h2>
|
||||
<p class="muted"><?= __('int_cleanup_hint') ?> <code>cron_cleanup.php</code> <?= __('int_cleanup_hint2') ?>
|
||||
<?php if ($lastCleanup): ?><br><?= __('int_cleanup_last') ?> <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
|
||||
</p>
|
||||
<div class="row">
|
||||
<div><label>Abgelaufene Voucher</label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
|
||||
<div><label>Audit-Log</label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
|
||||
<div><label>Login-Versuche</label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div>
|
||||
<div><label><?= __('int_cleanup_expired') ?></label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
|
||||
<div><label><?= __('int_cleanup_audit') ?></label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
|
||||
<div><label><?= __('int_cleanup_logins') ?></label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" type="submit" name="save">Speichern</button>
|
||||
<button class="btn btn-primary" type="submit" name="save"><?= __('btn_save') ?></button>
|
||||
</form>
|
||||
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ ini_set('log_errors', 1);
|
|||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/../includes/Database.php';
|
||||
require_once __DIR__ . '/../includes/Auth.php';
|
||||
require_once __DIR__ . '/../includes/Ui.php';
|
||||
require_once __DIR__ . '/../includes/I18n.php';
|
||||
|
||||
$auth = new Auth();
|
||||
|
|
@ -95,46 +96,46 @@ $adminBase = '';
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reporting – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<title><?= __('rep_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
|
||||
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Reporting</h1>
|
||||
<p class="page-subtitle">Auswertungen nach Zeitraum, Site und Benutzer.</p>
|
||||
<h1 class="page-title"><?= __('rep_title') ?></h1>
|
||||
<p class="page-subtitle"><?= __('rep_subtitle') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar no-print">
|
||||
<form method="get" style="display:flex;gap:8px;align-items:center;">
|
||||
<label style="margin:0;">Zeitraum</label>
|
||||
<label style="margin:0;"><?= __('rep_period') ?></label>
|
||||
<select class="input" name="days" onchange="this.form.submit()">
|
||||
<?php foreach ([7,30,90,365] as $d): ?>
|
||||
<option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> Tage</option>
|
||||
<option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> <?= __('rep_days') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
<a class="btn btn-secondary" href="?export=daily&days=<?= $days ?>"><i class="fas fa-download"></i> CSV (täglich)</a>
|
||||
<a class="btn btn-secondary" href="?export=per_site"><i class="fas fa-download"></i> CSV (pro Site)</a>
|
||||
<a class="btn btn-secondary" href="?export=per_user"><i class="fas fa-download"></i> CSV (pro Nutzer)</a>
|
||||
<button class="btn btn-secondary" onclick="window.print()"><i class="fas fa-print"></i> Drucken/PDF</button>
|
||||
<a class="btn btn-secondary" href="?export=daily&days=<?= $days ?>"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_daily') ?></a>
|
||||
<a class="btn btn-secondary" href="?export=per_site"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_site') ?></a>
|
||||
<a class="btn btn-secondary" href="?export=per_user"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_user') ?></a>
|
||||
<button class="btn btn-secondary" onclick="window.print()"><i class="fas fa-print" aria-hidden="true"></i> <?= __('rep_print') ?></button>
|
||||
</div>
|
||||
|
||||
<div class="grid4">
|
||||
<div class="stat"><div class="n"><?= (int)$totals['total'] ?></div><div class="l">Vouchers gesamt</div></div>
|
||||
<div class="stat"><div class="n"><?= (int)$totals['valid'] ?></div><div class="l">Gültig</div></div>
|
||||
<div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l">Verwendet</div></div>
|
||||
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l">In <?= $days ?> Tagen erstellt</div></div>
|
||||
<div class="stat"><div class="n"><?= (int)$totals['total'] ?></div><div class="l"><?= __('rep_total') ?></div></div>
|
||||
<div class="stat"><div class="n"><?= (int)$totals['valid'] ?></div><div class="l"><?= __('status_valid') ?></div></div>
|
||||
<div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l"><?= __('status_used') ?></div></div>
|
||||
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l"><?= str_replace('{days}', (string)$days, __('rep_in_period')) ?></div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Erstellte Voucher (<?= $days ?> Tage)</h2>
|
||||
<h2><?= str_replace('{days}', (string)$days, __('rep_chart_title')) ?></h2>
|
||||
<canvas id="chart" height="90"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Pro Site</h2>
|
||||
<table><tr><th>Site</th><th>Gesamt</th><th>Gültig</th><th>Verwendet</th><th>Abgelaufen</th></tr>
|
||||
<h2><?= __('rep_per_site') ?></h2>
|
||||
<table><tr><th><?= __('label_site') ?></th><th><?= __('label_total') ?></th><th><?= __('status_valid') ?></th><th><?= __('status_used') ?></th><th><?= __('status_expired') ?></th></tr>
|
||||
<?php foreach ($perSite as $r): ?>
|
||||
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><?= (int)$r['total'] ?></td><td><?= (int)$r['valid'] ?></td><td><?= (int)$r['used'] ?></td><td><?= (int)$r['expired'] ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
|
|
@ -142,12 +143,12 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Top-Nutzer</h2>
|
||||
<table><tr><th>Benutzer</th><th>Voucher erstellt</th></tr>
|
||||
<h2><?= __('rep_top_users') ?></h2>
|
||||
<table><tr><th><?= __('label_user') ?></th><th><?= __('rep_col_created') ?></th></tr>
|
||||
<?php foreach ($perUser as $r): ?>
|
||||
<tr><td><?= htmlspecialchars($r['name'] ?? '–') ?></td><td><?= (int)$r['c'] ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);">Keine Daten</td></tr><?php endif; ?>
|
||||
<?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);"><?= __('rep_no_data') ?></td></tr><?php endif; ?>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ ini_set('log_errors', 1);
|
|||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/../includes/Database.php';
|
||||
require_once __DIR__ . '/../includes/Auth.php';
|
||||
require_once __DIR__ . '/../includes/I18n.php';
|
||||
require_once __DIR__ . '/../includes/Ui.php';
|
||||
|
||||
I18n::init();
|
||||
|
||||
$auth = new Auth();
|
||||
$auth->requireLogin();
|
||||
|
|
@ -24,20 +28,20 @@ $setupRequired = isset($_GET['setup_required']);
|
|||
// 2FA aktivieren (Code bestaetigen)
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
|
||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
$error = 'Ungültiges Sicherheits-Token';
|
||||
$error = __('sec_token_invalid');
|
||||
} else {
|
||||
$secret = $_SESSION['totp_setup_secret'] ?? '';
|
||||
$code = trim($_POST['code'] ?? '');
|
||||
if ($secret === '') {
|
||||
$error = 'Setup abgelaufen, bitte erneut starten.';
|
||||
$error = __('sec_setup_expired');
|
||||
} elseif (!Totp::verify($secret, $code)) {
|
||||
$error = 'Code ungültig. Bitte erneut versuchen.';
|
||||
$error = __('sec_code_invalid');
|
||||
} else {
|
||||
$backupCodes = $auth->enableTotp($user['id'], $secret);
|
||||
unset($_SESSION['totp_setup_secret']);
|
||||
$totpEnabled = true;
|
||||
$user = $auth->getCurrentUser();
|
||||
$success = 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!';
|
||||
$success = __('sec_enabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,32 +49,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
|
|||
// Überall abmelden (andere Sessions beenden)
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) {
|
||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
$error = 'Ungültiges Sicherheits-Token';
|
||||
$error = __('sec_token_invalid');
|
||||
} else {
|
||||
$auth->logoutOtherSessions();
|
||||
$success = 'Alle anderen Sitzungen wurden beendet.';
|
||||
$success = __('sec_sessions_closed');
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery-Codes neu erzeugen
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) {
|
||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
$error = 'Ungültiges Sicherheits-Token';
|
||||
$error = __('sec_token_invalid');
|
||||
} elseif (!empty($user['totp_enabled'])) {
|
||||
$backupCodes = $auth->regenerateBackupCodes($user['id']);
|
||||
$user = $auth->getCurrentUser();
|
||||
$success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.';
|
||||
$success = __('sec_codes_new');
|
||||
}
|
||||
}
|
||||
|
||||
// 2FA deaktivieren
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) {
|
||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
$error = 'Ungültiges Sicherheits-Token';
|
||||
$error = __('sec_token_invalid');
|
||||
} else {
|
||||
$auth->disableTotp($user['id']);
|
||||
$totpEnabled = false;
|
||||
$success = 'Zwei-Faktor-Authentifizierung wurde deaktiviert.';
|
||||
$success = __('sec_disabled');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,41 +91,36 @@ $dbSessions = $db->getSetting('session_driver', 'php') === 'db';
|
|||
$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="<?= I18n::getLanguage() ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Zwei-Faktor-Authentifizierung – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<title><?= __('sec_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<?php if (!$totpEnabled && $hasPassword): ?>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="../assets/global.css">
|
||||
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
||||
<?= Ui::head($db, '../') ?>
|
||||
</head>
|
||||
<body class="app-body focus-page">
|
||||
<div class="focus-card card">
|
||||
<div class="focus-head">
|
||||
<span class="focus-icon"><i class="fas fa-shield-halved"></i></span>
|
||||
<span class="focus-icon"><i class="fas fa-shield-halved" aria-hidden="true"></i></span>
|
||||
<div>
|
||||
<h1>Zwei-Faktor-Authentifizierung</h1>
|
||||
<p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p>
|
||||
<h1><?= __('sec_title') ?></h1>
|
||||
<p class="sub"><?= __('sec_account') ?> <?= htmlspecialchars($user['email']) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($setupRequired && !$totpEnabled): ?>
|
||||
<div class="alert alert-error">Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.</div>
|
||||
<div class="alert alert-error"><?= __('sec_required_hint') ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||
|
||||
<?php if (!empty($backupCodes)): ?>
|
||||
<div class="codes-box">
|
||||
<strong><i class="fas fa-key"></i> Recovery-Codes</strong>
|
||||
<p>Bewahren Sie diese sicher auf. Jeder Code funktioniert <em>einmal</em>, falls Sie keinen Zugriff auf Ihre App haben.</p>
|
||||
<strong><i class="fas fa-key" aria-hidden="true"></i> <?= __('sec_recovery_codes') ?></strong>
|
||||
<p><?= __('sec_recovery_hint') ?></p>
|
||||
<div class="codes">
|
||||
<?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?>
|
||||
</div>
|
||||
|
|
@ -129,34 +128,34 @@ $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
|
|||
<?php endif; ?>
|
||||
|
||||
<?php if (!$hasPassword): ?>
|
||||
<div class="status off"><i class="fas fa-circle-minus"></i> Nicht verfügbar</div>
|
||||
<p class="sub">Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.</p>
|
||||
<div class="status off"><i class="fas fa-circle-minus" aria-hidden="true"></i> <?= __('sec_unavailable') ?></div>
|
||||
<p class="sub"><?= __('sec_m365_hint') ?></p>
|
||||
<?php elseif ($totpEnabled): ?>
|
||||
<div class="status on"><i class="fas fa-circle-check"></i> Aktiv</div>
|
||||
<p class="sub">Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.<br>
|
||||
Verbleibende Recovery-Codes: <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
|
||||
<div class="status on"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('sec_active') ?></div>
|
||||
<p class="sub"><?= __('sec_active_hint') ?><br>
|
||||
<?= __('sec_codes_left') ?> <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
|
||||
<form method="post" style="margin-bottom:10px;">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<button type="submit" name="regen_codes" class="btn btn-secondary btn-lg btn-block">Recovery-Codes neu erzeugen</button>
|
||||
<button type="submit" name="regen_codes" class="btn btn-secondary btn-lg btn-block"><?= __('sec_regen_codes') ?></button>
|
||||
</form>
|
||||
<form method="post" onsubmit="return confirm('2FA wirklich deaktivieren?');">
|
||||
<form method="post" onsubmit="return confirm('<?= __('sec_disable_confirm') ?>');">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<button type="submit" name="disable_totp" class="btn btn-danger btn-lg btn-block">2FA deaktivieren</button>
|
||||
<button type="submit" name="disable_totp" class="btn btn-danger btn-lg btn-block"><?= __('sec_disable') ?></button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<div class="status off"><i class="fas fa-circle-minus"></i> Inaktiv</div>
|
||||
<div class="status off"><i class="fas fa-circle-minus" aria-hidden="true"></i> <?= __('sec_inactive') ?></div>
|
||||
<ol>
|
||||
<li>Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)</li>
|
||||
<li>QR-Code scannen <em>oder</em> Secret manuell eingeben</li>
|
||||
<li>Den angezeigten 6-stelligen Code unten eingeben</li>
|
||||
<li><?= __('sec_step_1') ?></li>
|
||||
<li><?= __('sec_step_2') ?></li>
|
||||
<li><?= __('sec_step_3') ?></li>
|
||||
</ol>
|
||||
<div class="qr"><div id="qrcode"></div></div>
|
||||
<div class="secret"><?= htmlspecialchars($setupSecret) ?></div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<label for="code">6-stelliger Code</label>
|
||||
<label for="code"><?= __('sec_code_label') ?></label>
|
||||
<input type="text" id="code" name="code" class="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required placeholder="123456">
|
||||
<button type="submit" name="enable_totp" class="btn btn-primary btn-lg btn-block" style="margin-top:14px;">2FA aktivieren</button>
|
||||
<button type="submit" name="enable_totp" class="btn btn-primary btn-lg btn-block" style="margin-top:14px;"><?= __('sec_enable') ?></button>
|
||||
</form>
|
||||
<script>
|
||||
new QRCode(document.getElementById('qrcode'), {
|
||||
|
|
@ -169,14 +168,14 @@ $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
|
|||
|
||||
<?php if ($dbSessions): ?>
|
||||
<hr>
|
||||
<p class="sub">Aktive Sitzungen: <strong><?= (int)$activeSessions ?></strong></p>
|
||||
<form method="post" onsubmit="return confirm('Alle anderen Sitzungen abmelden?');">
|
||||
<p class="sub"><?= __('sec_sessions') ?> <strong><?= (int)$activeSessions ?></strong></p>
|
||||
<form method="post" onsubmit="return confirm('<?= __('sec_logout_others_confirm') ?>');">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<button type="submit" name="logout_others" class="btn btn-secondary btn-lg btn-block">Auf allen anderen Geräten abmelden</button>
|
||||
<button type="submit" name="logout_others" class="btn btn-secondary btn-lg btn-block"><?= __('sec_logout_others') ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="auth-links"><a class="back-link" href="../index.php"><i class="fas fa-arrow-left"></i> Zurück</a></div>
|
||||
<div class="auth-links"><a class="back-link" href="../index.php"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('nav_back') ?></a></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ require_once __DIR__ . '/../includes/Database.php';
|
|||
require_once __DIR__ . '/../includes/Auth.php';
|
||||
require_once __DIR__ . '/../includes/Mailer.php';
|
||||
require_once __DIR__ . '/../includes/I18n.php';
|
||||
require_once __DIR__ . '/../includes/Ui.php';
|
||||
require_once __DIR__ . '/../includes/Upload.php';
|
||||
|
||||
$auth = new Auth();
|
||||
$auth->requireAdmin();
|
||||
|
|
@ -40,6 +42,33 @@ if (isset($_POST['ajax_smtp_test'])) {
|
|||
$error = '';
|
||||
$success = '';
|
||||
|
||||
/**
|
||||
* Liefert den neuen Wert eines Bildfeldes: Upload schlaegt URL, und ein
|
||||
* gesetzter Entfernen-Schalter loescht die bisherige Datei.
|
||||
*/
|
||||
function resolveImageField(string $name, Database $db, string $kind): string
|
||||
{
|
||||
$current = (string)$db->getSetting($name, '');
|
||||
|
||||
$uploaded = Upload::store($_FILES[$name . '_file'] ?? [], $kind);
|
||||
if ($uploaded !== '') {
|
||||
Upload::delete($current);
|
||||
return $uploaded;
|
||||
}
|
||||
|
||||
if (!empty($_POST[$name . '_remove'])) {
|
||||
Upload::delete($current);
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($_POST[$name] ?? '');
|
||||
if ($value !== $current && Upload::isLocal($current) && !Upload::isLocal($value)) {
|
||||
Upload::delete($current);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Einstellungen speichern
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
||||
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
|
|
@ -51,22 +80,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
|||
|
||||
if ($formType === 'general') {
|
||||
$settings['app_title'] = trim($_POST['app_title'] ?? '');
|
||||
$settings['logo_url'] = trim($_POST['logo_url'] ?? '');
|
||||
$settings['favicon_url'] = trim($_POST['favicon_url'] ?? '');
|
||||
$settings['logo_url'] = resolveImageField('logo_url', $db, 'image');
|
||||
$settings['favicon_url'] = resolveImageField('favicon_url', $db, 'favicon');
|
||||
$settings['instruction_header'] = trim($_POST['instruction_header'] ?? '');
|
||||
$settings['instruction_text'] = $_POST['instruction_text'] ?? '';
|
||||
$settings['public_access'] = isset($_POST['public_access']) ? '1' : '0';
|
||||
}
|
||||
|
||||
if ($formType === 'branding') {
|
||||
foreach (['brand_accent', 'brand_accent_dark', 'brand_gradient_from', 'brand_gradient_to'] as $key) {
|
||||
$value = strtolower(trim($_POST[$key] ?? ''));
|
||||
$settings[$key] = preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : '';
|
||||
}
|
||||
$radius = (int)($_POST['brand_radius'] ?? Ui::DEFAULT_RADIUS);
|
||||
$settings['brand_radius'] = (string)max(0, min(28, $radius));
|
||||
}
|
||||
|
||||
if ($formType === 'login') {
|
||||
$settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0';
|
||||
$settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? '');
|
||||
$settings['login_logo_url'] = trim($_POST['login_logo_url'] ?? '');
|
||||
$settings['login_logo_url'] = resolveImageField('login_logo_url', $db, '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'] = trim($_POST['login_bg_image'] ?? '');
|
||||
$settings['login_bg_image'] = resolveImageField('login_bg_image', $db, '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);
|
||||
|
|
@ -113,7 +151,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
|||
}
|
||||
|
||||
if ($formType === 'system') {
|
||||
$settings['tinymce_api_key'] = trim($_POST['tinymce_api_key'] ?? '');
|
||||
$settings['print_template'] = $_POST['print_template'] ?? '';
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +159,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
|||
}
|
||||
|
||||
$success = __('settings_saved');
|
||||
} catch (RuntimeException $e) {
|
||||
$error = $e->getMessage();
|
||||
} catch (Exception $e) {
|
||||
$error = 'Fehler: ' . $e->getMessage();
|
||||
}
|
||||
|
|
@ -200,11 +239,15 @@ $cs = [
|
|||
'smtp_from_name' => $db->getSetting('smtp_from_name', ''),
|
||||
'system_url' => $db->getSetting('system_url', $autoDetectedUrl),
|
||||
'email_voucher_subject' => $db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'),
|
||||
'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nIhr Code: {VOUCHER_CODE}\n\nGültigkeit: 8h\nGeräte: {MAX_USES}\nSite: {SITE_NAME}"),
|
||||
'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\n<strong>Maximale Geräte:</strong> {MAX_USES}<br>\n<strong>Standort:</strong> {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}"),
|
||||
'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'),
|
||||
'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"),
|
||||
'tinymce_api_key' => $db->getSetting('tinymce_api_key', ''),
|
||||
'print_template' => $db->getSetting('print_template', '<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>'),
|
||||
'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()),
|
||||
'brand_accent' => $db->getSetting('brand_accent', '') ?: Ui::DEFAULT_ACCENT,
|
||||
'brand_accent_dark' => $db->getSetting('brand_accent_dark', '') ?: Ui::DEFAULT_ACCENT_DARK,
|
||||
'brand_gradient_from' => $db->getSetting('brand_gradient_from', '') ?: Ui::DEFAULT_GRADIENT_FROM,
|
||||
'brand_gradient_to' => $db->getSetting('brand_gradient_to', '') ?: Ui::DEFAULT_GRADIENT_TO,
|
||||
'brand_radius' => $db->getSetting('brand_radius', (string)Ui::DEFAULT_RADIUS),
|
||||
'login_panel_enabled' => $db->getSetting('login_panel_enabled', '1'),
|
||||
'login_brand_name' => $db->getSetting('login_brand_name', ''),
|
||||
'login_logo_url' => $db->getSetting('login_logo_url', ''),
|
||||
|
|
@ -220,6 +263,41 @@ $cs = [
|
|||
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
|
||||
];
|
||||
|
||||
/**
|
||||
* Bildfeld: Vorschau, Upload, alternativ URL – plus Entfernen-Schalter.
|
||||
*/
|
||||
function imageField(string $name, string $label, ?string $value, string $hint = '', string $accept = 'image/*'): void
|
||||
{
|
||||
$value = (string)$value;
|
||||
$preview = Ui::mediaUrl($value, '../');
|
||||
?>
|
||||
<div class="form-group">
|
||||
<label><?= htmlspecialchars($label) ?></label>
|
||||
<div class="image-field">
|
||||
<div class="image-preview">
|
||||
<?php if ($preview !== ''): ?>
|
||||
<img src="<?= htmlspecialchars($preview) ?>" alt="">
|
||||
<?php else: ?>
|
||||
<i class="fas fa-image" aria-hidden="true"></i>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="image-field-controls">
|
||||
<input type="file" name="<?= $name ?>_file" accept="<?= htmlspecialchars($accept) ?>">
|
||||
<input type="text" name="<?= $name ?>" value="<?= htmlspecialchars($value) ?>"
|
||||
placeholder="<?= htmlspecialchars(__('settings_image_url_placeholder')) ?>">
|
||||
<?php if ($value !== ''): ?>
|
||||
<label class="chk"><input type="checkbox" name="<?= $name ?>_remove" value="1"> <?= __('settings_image_remove') ?></label>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($hint !== ''): ?><div class="help-text"><?= htmlspecialchars($hint) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
// Aktiver Tab per ?tab=… (Deep-Link, funktioniert auch ohne JavaScript)
|
||||
$activeTab = preg_replace('/[^a-z_]/', '', (string)($_GET['tab'] ?? 'general')) ?: 'general';
|
||||
|
||||
$currentPage = 'settings';
|
||||
$adminBase = '';
|
||||
?>
|
||||
|
|
@ -230,13 +308,9 @@ $adminBase = '';
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
|
||||
|
||||
<?php if (!empty($cs['tinymce_api_key'])): ?>
|
||||
<script src="https://cdn.tiny.cloud/1/<?= htmlspecialchars($cs['tinymce_api_key']) ?>/tinymce/6/tinymce.min.js"></script>
|
||||
<?php else: ?>
|
||||
<!-- Ohne API-Key die GPL-Variante von cdnjs laden: gleiche Funktionen, kein Hinweis-Banner. -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.3/tinymce.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script>window.TINYMCE_BASE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.3';</script>
|
||||
<?php endif; ?>
|
||||
<!-- TinyMCE wird lokal ausgeliefert (GPL-Variante) – keine externen Aufrufe. -->
|
||||
<?= Ui::script('assets/vendor/tinymce/tinymce.min.js', '../') ?>
|
||||
<script>window.TINYMCE_BASE_URL = '../assets/vendor/tinymce';</script>
|
||||
|
||||
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||
|
||||
|
|
@ -248,48 +322,49 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i><span><?= htmlspecialchars($error) ?></span></div>
|
||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i><span><?= htmlspecialchars($error) ?></span></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success"><i class="fas fa-check-circle"></i><span><?= htmlspecialchars($success) ?></span></div>
|
||||
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i><span><?= htmlspecialchars($success) ?></span></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="tab-container">
|
||||
<div class="tab-navigation" id="tabNav">
|
||||
<button class="tab-button active" data-tab="general"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></button>
|
||||
<button class="tab-button" data-tab="defaults"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></button>
|
||||
<button class="tab-button" data-tab="login"><i class="fas fa-right-to-bracket"></i> <?= __('settings_tab_login') ?></button>
|
||||
<button class="tab-button" data-tab="cron"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></button>
|
||||
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft"></i> <?= __('settings_tab_m365') ?></button>
|
||||
<button class="tab-button" data-tab="smtp"><i class="fas fa-envelope"></i> <?= __('settings_tab_smtp') ?></button>
|
||||
<button class="tab-button" data-tab="templates_email"><i class="fas fa-file-alt"></i> <?= __('settings_tab_templates_email') ?></button>
|
||||
<button class="tab-button" data-tab="system"><i class="fas fa-cogs"></i> <?= __('settings_tab_system') ?></button>
|
||||
<button class="tab-button" data-tab="password"><i class="fas fa-key"></i> <?= __('settings_tab_password') ?></button>
|
||||
<button class="tab-button<?= $activeTab === 'general' ? ' active' : '' ?>" data-tab="general"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></button>
|
||||
<button class="tab-button<?= $activeTab === 'defaults' ? ' active' : '' ?>" data-tab="defaults"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></button>
|
||||
<button class="tab-button<?= $activeTab === 'branding' ? ' active' : '' ?>" data-tab="branding"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></button>
|
||||
<button class="tab-button<?= $activeTab === 'login' ? ' active' : '' ?>" data-tab="login"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></button>
|
||||
<button class="tab-button<?= $activeTab === 'cron' ? ' active' : '' ?>" data-tab="cron"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></button>
|
||||
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft" aria-hidden="true"></i> <?= __('settings_tab_m365') ?></button>
|
||||
<button class="tab-button<?= $activeTab === 'smtp' ? ' active' : '' ?>" data-tab="smtp"><i class="fas fa-envelope" aria-hidden="true"></i> <?= __('settings_tab_smtp') ?></button>
|
||||
<button class="tab-button<?= $activeTab === 'templates_email' ? ' active' : '' ?>" data-tab="templates_email"><i class="fas fa-file-alt" aria-hidden="true"></i> <?= __('settings_tab_templates_email') ?></button>
|
||||
<button class="tab-button<?= $activeTab === 'system' ? ' active' : '' ?>" data-tab="system"><i class="fas fa-cogs" aria-hidden="true"></i> <?= __('settings_tab_system') ?></button>
|
||||
<button class="tab-button<?= $activeTab === 'password' ? ' active' : '' ?>" data-tab="password"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button>
|
||||
</div>
|
||||
|
||||
<!-- Allgemein -->
|
||||
<div id="tab-general" class="tab-content active">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></h2>
|
||||
<form method="post">
|
||||
<div id="tab-general" class="tab-content<?= $activeTab === 'general' ? ' active' : '' ?>">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
<input type="hidden" name="form_type" value="general">
|
||||
<div class="form-group"><label><?= __('settings_app_title') ?></label><input type="text" name="app_title" value="<?= htmlspecialchars($cs['app_title']) ?>" required></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label><?= __('settings_logo_url') ?></label><input type="url" name="logo_url" value="<?= htmlspecialchars($cs['logo_url']) ?>" placeholder="https://example.com/logo.png"></div>
|
||||
<div class="form-group"><label><?= __('settings_favicon_url') ?></label><input type="url" name="favicon_url" value="<?= htmlspecialchars($cs['favicon_url']) ?>" placeholder="https://example.com/favicon.ico"><div class="help-text"><?= __('settings_favicon_hint') ?></div></div>
|
||||
<?php imageField('logo_url', __('settings_logo_url'), $cs['logo_url'], __('settings_upload_hint')); ?>
|
||||
<?php imageField('favicon_url', __('settings_favicon_url'), $cs['favicon_url'], __('settings_favicon_hint'), 'image/x-icon,image/png,image/svg+xml'); ?>
|
||||
</div>
|
||||
<hr class="section-divider">
|
||||
<div class="form-group"><label><?= __('settings_instr_header') ?></label><input type="text" name="instruction_header" value="<?= htmlspecialchars($cs['instruction_header']) ?>"></div>
|
||||
<div class="form-group"><label><?= __('settings_instr_text') ?></label><textarea name="instruction_text" class="tinymce-editor"><?= htmlspecialchars($cs['instruction_text']) ?></textarea></div>
|
||||
<div class="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="public_access" id="public_access" <?= $cs['public_access'] == '1' ? 'checked' : '' ?>><label for="public_access" style="margin:0;"><?= __('settings_public_access') ?></label></div>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Voucher-Standards -->
|
||||
<div id="tab-defaults" class="tab-content">
|
||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></h2>
|
||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;">Diese Werte werden als Vorgabe im Voucher-Formular verwendet.</p>
|
||||
<div id="tab-defaults" class="tab-content<?= $activeTab === 'defaults' ? ' active' : '' ?>">
|
||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></h2>
|
||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_defaults_hint') ?></p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
<input type="hidden" name="form_type" value="defaults">
|
||||
|
|
@ -311,22 +386,91 @@ $adminBase = '';
|
|||
</div>
|
||||
</div>
|
||||
<div class="info-box" style="margin-top: 10px;">
|
||||
<h4><i class="fas fa-info-circle"></i> Gültigkeits-Referenz</h4>
|
||||
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> Gültigkeits-Referenz</h4>
|
||||
<p>
|
||||
60 Min = 1 Stunde | 480 Min = 8 Stunden |
|
||||
1440 Min = 1 Tag | 10080 Min = 1 Woche |
|
||||
43200 Min = 30 Tage
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top: 16px;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top: 16px;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Design & Branding -->
|
||||
<div id="tab-branding" class="tab-content<?= $activeTab === 'branding' ? ' active' : '' ?>">
|
||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></h2>
|
||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_branding_intro') ?></p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
<input type="hidden" name="form_type" value="branding">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label><?= __('settings_brand_accent') ?></label>
|
||||
<div class="color-field">
|
||||
<input type="color" class="color-swatch" data-target="brand_accent" value="<?= htmlspecialchars($cs['brand_accent']) ?>">
|
||||
<input type="text" id="brand_accent" name="brand_accent" value="<?= htmlspecialchars($cs['brand_accent']) ?>" placeholder="<?= Ui::DEFAULT_ACCENT ?>">
|
||||
</div>
|
||||
<div class="help-text"><?= __('settings_brand_accent_hint') ?></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><?= __('settings_brand_accent_dark') ?></label>
|
||||
<div class="color-field">
|
||||
<input type="color" class="color-swatch" data-target="brand_accent_dark" value="<?= htmlspecialchars($cs['brand_accent_dark']) ?>">
|
||||
<input type="text" id="brand_accent_dark" name="brand_accent_dark" value="<?= htmlspecialchars($cs['brand_accent_dark']) ?>" placeholder="<?= Ui::DEFAULT_ACCENT_DARK ?>">
|
||||
</div>
|
||||
<div class="help-text"><?= __('settings_brand_accent_dark_hint') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label><?= __('settings_brand_gradient_from') ?></label>
|
||||
<div class="color-field">
|
||||
<input type="color" class="color-swatch" data-target="brand_gradient_from" value="<?= htmlspecialchars($cs['brand_gradient_from']) ?>">
|
||||
<input type="text" id="brand_gradient_from" name="brand_gradient_from" value="<?= htmlspecialchars($cs['brand_gradient_from']) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><?= __('settings_brand_gradient_to') ?></label>
|
||||
<div class="color-field">
|
||||
<input type="color" class="color-swatch" data-target="brand_gradient_to" value="<?= htmlspecialchars($cs['brand_gradient_to']) ?>">
|
||||
<input type="text" id="brand_gradient_to" name="brand_gradient_to" value="<?= htmlspecialchars($cs['brand_gradient_to']) ?>">
|
||||
</div>
|
||||
<div class="help-text"><?= __('settings_brand_gradient_hint') ?></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><?= __('settings_brand_radius') ?></label>
|
||||
<select id="brand_radius" name="brand_radius">
|
||||
<?php foreach ([6 => __('settings_brand_radius_sharp'), 14 => __('settings_brand_radius_default'), 20 => __('settings_brand_radius_round')] as $value => $label): ?>
|
||||
<option value="<?= $value ?>" <?= (int)$cs['brand_radius'] === $value ? 'selected' : '' ?>><?= htmlspecialchars($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="brandPreview" style="margin-top: 8px;">
|
||||
<h3><?= __('settings_brand_preview') ?></h3>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:14px;">
|
||||
<span class="brand-mark" style="width:38px;height:38px;"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||||
<button type="button" class="btn btn-primary"><i class="fas fa-ticket" aria-hidden="true"></i> <?= __('voucher_create_btn') ?></button>
|
||||
<button type="button" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||
<span class="badge badge-success"><?= __('status_valid') ?></span>
|
||||
<span class="chip"><?= __('nav_vouchers') ?></span>
|
||||
<a href="#" onclick="return false;"><?= __('settings_brand_link') ?></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top:16px;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Login-Seite -->
|
||||
<div id="tab-login" class="tab-content">
|
||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-right-to-bracket"></i> <?= __('settings_tab_login') ?></h2>
|
||||
<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">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
<input type="hidden" name="form_type" value="login">
|
||||
|
||||
|
|
@ -341,11 +485,7 @@ $adminBase = '';
|
|||
<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>
|
||||
<div class="form-group">
|
||||
<label><?= __('settings_login_logo') ?></label>
|
||||
<input type="url" name="login_logo_url" value="<?= htmlspecialchars($cs['login_logo_url']) ?>" placeholder="https://example.com/logo.svg">
|
||||
<div class="help-text"><?= __('settings_login_logo_hint') ?></div>
|
||||
</div>
|
||||
<?php imageField('login_logo_url', __('settings_login_logo'), $cs['login_logo_url'], __('settings_login_logo_hint')); ?>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
|
|
@ -370,11 +510,7 @@ $adminBase = '';
|
|||
|
||||
<hr class="section-divider">
|
||||
|
||||
<div class="form-group">
|
||||
<label><?= __('settings_login_bg_image') ?></label>
|
||||
<input type="url" name="login_bg_image" value="<?= htmlspecialchars($cs['login_bg_image']) ?>" placeholder="https://example.com/empfang.jpg">
|
||||
<div class="help-text"><?= __('settings_login_bg_image_hint') ?></div>
|
||||
</div>
|
||||
<?php 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>
|
||||
|
|
@ -400,34 +536,34 @@ $adminBase = '';
|
|||
</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"></i> <?= __('btn_save') ?></button>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||
<a href="../login.php?preview=1" target="_blank" rel="noopener" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-up-right-from-square"></i> <?= __('settings_login_preview') ?>
|
||||
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> <?= __('settings_login_preview') ?>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Cron-Sync -->
|
||||
<div id="tab-cron" class="tab-content">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></h2>
|
||||
<div id="tab-cron" class="tab-content<?= $activeTab === 'cron' ? ' active' : '' ?>">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></h2>
|
||||
<div class="info-box">
|
||||
<h4><i class="fas fa-info-circle"></i> Was macht der Cron-Job?</h4>
|
||||
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> <?= __('settings_cron_what') ?></h4>
|
||||
<p>Der Cron-Job synchronisiert automatisch alle Voucher von Ihren UniFi Controllern in die lokale Datenbank.</p>
|
||||
</div>
|
||||
<hr class="section-divider">
|
||||
<?php if (empty($cs['cron_token'])): ?>
|
||||
<p style="color: var(--text-muted); margin-bottom: 15px;"><i class="fas fa-exclamation-triangle" style="color: var(--warning);"></i> Kein Token konfiguriert.</p>
|
||||
<p style="color: var(--text-muted); margin-bottom: 15px;"><i class="fas fa-exclamation-triangle" style="color: var(--warning);" aria-hidden="true"></i> Kein Token konfiguriert.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
<button type="submit" name="generate_cron_token" class="btn btn-primary"><i class="fas fa-key"></i> Token generieren</button>
|
||||
<button type="submit" name="generate_cron_token" class="btn btn-primary"><i class="fas fa-key" aria-hidden="true"></i> Token generieren</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<div class="token-box" style="margin-bottom: 15px;"><?= htmlspecialchars($cs['cron_token']) ?></div>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
|
||||
<button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy"></i> Kopieren</button>
|
||||
<form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync"></i> Neu generieren</button></form>
|
||||
<form method="post" style="display:inline;" onsubmit="return confirm('Token wirklich löschen?');"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="delete_cron_token" class="btn btn-secondary" style="color: var(--danger);"><i class="fas fa-trash"></i> Löschen</button></form>
|
||||
<button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy" aria-hidden="true"></i> Kopieren</button>
|
||||
<form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync" aria-hidden="true"></i> Neu generieren</button></form>
|
||||
<form method="post" style="display:inline;" onsubmit="return confirm('<?= __('js_confirm_delete_token') ?>');"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="delete_cron_token" class="btn btn-secondary" style="color: var(--danger);"><i class="fas fa-trash" aria-hidden="true"></i> Löschen</button></form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
|
|
@ -437,7 +573,7 @@ $adminBase = '';
|
|||
<label>Cron-URL</label>
|
||||
<div style="display:flex;gap:10px;">
|
||||
<input type="text" id="cronUrl" value="<?= htmlspecialchars($cronUrl) ?>" readonly>
|
||||
<button onclick="copyToClipboard(document.getElementById('cronUrl').value)" class="btn btn-secondary"><i class="fas fa-copy"></i></button>
|
||||
<button onclick="copyToClipboard(document.getElementById('cronUrl').value)" class="btn btn-secondary"><i class="fas fa-copy" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="placeholder-info" style="background: var(--bg-hover); border-color: var(--border-color);">
|
||||
|
|
@ -448,7 +584,7 @@ $adminBase = '';
|
|||
</div>
|
||||
<?php if ($cs['cron_token']): ?>
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="testCronJob()" class="btn btn-primary" id="testCronBtn"><i class="fas fa-play"></i> Jetzt ausführen</button>
|
||||
<button onclick="testCronJob()" class="btn btn-primary" id="testCronBtn"><i class="fas fa-play" aria-hidden="true"></i> Jetzt ausführen</button>
|
||||
<span id="testCronResult" style="margin-left: 15px;"></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
|
@ -457,9 +593,9 @@ $adminBase = '';
|
|||
|
||||
<!-- M365 -->
|
||||
<div id="tab-m365" class="tab-content">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fab fa-microsoft"></i> Microsoft 365</h2>
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fab fa-microsoft" aria-hidden="true"></i> Microsoft 365</h2>
|
||||
<div class="info-box">
|
||||
<h4><i class="fas fa-info-circle"></i> Azure AD App</h4>
|
||||
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> Azure AD App</h4>
|
||||
<p>Redirect URI: <strong><?= $protocol . '://' . $host . $scriptPath ?>/m365_callback.php</strong></p>
|
||||
</div>
|
||||
<form method="post">
|
||||
|
|
@ -468,13 +604,13 @@ $adminBase = '';
|
|||
<div class="form-group"><label>Client ID</label><input type="text" name="m365_client_id" value="<?= htmlspecialchars($cs['m365_client_id']) ?>"></div>
|
||||
<div class="form-group"><label>Client Secret</label><input type="password" name="m365_client_secret" value="<?= htmlspecialchars($cs['m365_client_secret']) ?>"></div>
|
||||
<div class="form-group"><label>Tenant ID</label><input type="text" name="m365_tenant_id" value="<?= htmlspecialchars($cs['m365_tenant_id']) ?>"></div>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- SMTP -->
|
||||
<div id="tab-smtp" class="tab-content">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope"></i> SMTP</h2>
|
||||
<div id="tab-smtp" class="tab-content<?= $activeTab === 'smtp' ? ' active' : '' ?>">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope" aria-hidden="true"></i> SMTP</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
<input type="hidden" name="form_type" value="smtp">
|
||||
|
|
@ -486,60 +622,60 @@ $adminBase = '';
|
|||
<div class="form-group"><label>Verschlüsselung</label><select name="smtp_encryption"><option value="tls" <?= $cs['smtp_encryption']==='tls'?'selected':'' ?>>TLS</option><option value="ssl" <?= $cs['smtp_encryption']==='ssl'?'selected':'' ?>>SSL</option><option value="none" <?= $cs['smtp_encryption']==='none'?'selected':'' ?>>Keine</option></select></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label>Benutzername</label><input type="text" name="smtp_username" value="<?= htmlspecialchars($cs['smtp_username']) ?>"></div>
|
||||
<div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="Leer = nicht ändern"></div>
|
||||
<div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="<?= __('settings_leave_empty') ?>"></div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label>Absender E-Mail</label><input type="email" name="smtp_from_email" value="<?= htmlspecialchars($cs['smtp_from_email']) ?>"></div>
|
||||
<div class="form-group"><label>Absender Name</label><input type="text" name="smtp_from_name" value="<?= htmlspecialchars($cs['smtp_from_name']) ?>"></div>
|
||||
<div class="form-group"><label><?= __('settings_smtp_from_name') ?></label><input type="text" name="smtp_from_name" value="<?= htmlspecialchars($cs['smtp_from_name']) ?>"></div>
|
||||
</div>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||
</form>
|
||||
<hr class="section-divider">
|
||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">SMTP testen</h3>
|
||||
<div style="display:flex;gap:10px;align-items:flex-end;">
|
||||
<div style="flex:1;"><label>Test-E-Mail senden an</label><input type="email" id="smtpTestEmail" placeholder="empfaenger@example.com" style="margin-top:6px;"></div>
|
||||
<button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn"><i class="fas fa-paper-plane"></i> <?= __('btn_test') ?></button>
|
||||
<button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn"><i class="fas fa-paper-plane" aria-hidden="true"></i> <?= __('btn_test') ?></button>
|
||||
</div>
|
||||
<span id="smtpTestResult" style="display:block;margin-top:10px;font-size:13px;"></span>
|
||||
</div>
|
||||
|
||||
<!-- E-Mail Templates -->
|
||||
<div id="tab-templates_email" class="tab-content">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt"></i> E-Mail Templates</h2>
|
||||
<div id="tab-templates_email" class="tab-content<?= $activeTab === 'templates_email' ? ' active' : '' ?>">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt" aria-hidden="true"></i> E-Mail Templates</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
<input type="hidden" name="form_type" value="templates">
|
||||
<div class="form-group"><label>System-URL</label><input type="url" name="system_url" value="<?= htmlspecialchars($cs['system_url']) ?>"><div class="help-text">Auto: <code><?= $autoDetectedUrl ?></code></div></div>
|
||||
<hr class="section-divider">
|
||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">Voucher E-Mail</h3>
|
||||
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{VOUCHER_CODE}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div></div>
|
||||
<h3 style="margin-bottom:15px; color: var(--text-primary);"><?= __('settings_tpl_voucher_mail') ?></h3>
|
||||
<div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{VOUCHER_CARD}</code><code>{VOUCHER_CODE}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div><p class="help-text"><?= __('settings_card_hint') ?></p></div>
|
||||
<div class="form-group"><label>Betreff</label><input type="text" name="email_voucher_subject" value="<?= htmlspecialchars($cs['email_voucher_subject']) ?>"></div>
|
||||
<div class="form-group"><label>E-Mail Text</label><textarea name="email_voucher_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_voucher_body']) ?></textarea></div>
|
||||
<hr class="section-divider">
|
||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">Benutzer-Benachrichtigung</h3>
|
||||
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{USER_NAME}</code><code>{CHANGES}</code><code>{APP_TITLE}</code><code>{SYSTEM_URL}</code></div></div>
|
||||
<h3 style="margin-bottom:15px; color: var(--text-primary);"><?= __('settings_tpl_user_notify') ?></h3>
|
||||
<div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{USER_NAME}</code><code>{CHANGES}</code><code>{APP_TITLE}</code><code>{SYSTEM_URL}</code></div></div>
|
||||
<div class="form-group"><label>Betreff</label><input type="text" name="email_user_notification_subject" value="<?= htmlspecialchars($cs['email_user_notification_subject']) ?>"></div>
|
||||
<div class="form-group"><label>E-Mail Text</label><textarea name="email_user_notification_body" class="tinymce-editor"><?= htmlspecialchars($cs['email_user_notification_body']) ?></textarea></div>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- System -->
|
||||
<div id="tab-system" class="tab-content">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs"></i> System & Erweitert</h2>
|
||||
<div id="tab-system" class="tab-content<?= $activeTab === 'system' ? ' active' : '' ?>">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs" aria-hidden="true"></i> System & Erweitert</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
<input type="hidden" name="form_type" value="system">
|
||||
<div class="info-box">
|
||||
<h4><i class="fas fa-info-circle"></i> TinyMCE API Key</h4>
|
||||
<p>Kostenlosen API Key: <a href="https://www.tiny.cloud/auth/signup/" target="_blank" rel="noopener" style="color:#0066cc;">tiny.cloud/signup</a></p>
|
||||
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> WYSIWYG-Editor</h4>
|
||||
<p><?= __('settings_editor_hint') ?> <code>assets/vendor/</code> <?= __('settings_editor_hint2') ?></p>
|
||||
</div>
|
||||
<div class="form-group"><label>TinyMCE API Key</label><input type="text" name="tinymce_api_key" value="<?= htmlspecialchars($cs['tinymce_api_key']) ?>" placeholder="your-api-key-here"><div class="help-text">Für WYSIWYG-Editor in Anleitungen</div></div>
|
||||
|
||||
<hr class="section-divider">
|
||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">Druck-Template</h3>
|
||||
<div class="placeholder-info"><h4>Platzhalter:</h4><div class="placeholder-list"><code>{VOUCHER_CODE}</code><code>{EXPIRY_DATE}</code><code>{EXPIRY_TIME}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div></div>
|
||||
<div class="form-group"><label>HTML Template für Voucher-Druck</label><textarea name="print_template" class="tinymce-editor" style="min-height:250px;"><?= htmlspecialchars($cs['print_template']) ?></textarea></div>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||
<div class="placeholder-info"><h4><?= __('settings_placeholders') ?></h4><div class="placeholder-list"><code>{QR_CODE}</code><code>{VOUCHER_CODE}</code><code>{EXPIRY_DATE}</code><code>{EXPIRY_TIME}</code><code>{SITE_NAME}</code><code>{MAX_USES}</code><code>{APP_TITLE}</code><code>{INSTRUCTIONS}</code></div><p class="help-text"><?= __('settings_qr_hint') ?></p></div>
|
||||
<div class="form-group"><label><?= __('settings_print_template') ?></label><textarea name="print_template" class="tinymce-editor" style="min-height:250px;"><?= htmlspecialchars($cs['print_template']) ?></textarea></div>
|
||||
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||
</form>
|
||||
<hr class="section-divider">
|
||||
<h3 style="margin-bottom:15px; color: var(--text-primary);">System-Information</h3>
|
||||
|
|
@ -551,14 +687,14 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<!-- Passwort -->
|
||||
<div id="tab-password" class="tab-content">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key"></i> <?= __('settings_tab_password') ?></h2>
|
||||
<div id="tab-password" class="tab-content<?= $activeTab === 'password' ? ' active' : '' ?>">
|
||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></h2>
|
||||
<form method="post" style="max-width:500px;">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
<div class="form-group"><label><?= __('settings_pw_current') ?></label><input type="password" name="current_password" required></div>
|
||||
<div class="form-group"><label><?= __('settings_pw_new') ?></label><input type="password" name="new_password" required minlength="8"><div class="help-text"><?= __('settings_pw_minlength') ?></div></div>
|
||||
<div class="form-group"><label><?= __('settings_pw_confirm') ?></label><input type="password" name="confirm_password" required></div>
|
||||
<button type="submit" name="change_password" class="btn btn-primary"><i class="fas fa-lock"></i> <?= __('settings_tab_password') ?></button>
|
||||
<button type="submit" name="change_password" class="btn btn-primary"><i class="fas fa-lock" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -579,17 +715,43 @@ document.querySelectorAll('.tab-button').forEach(btn => {
|
|||
});
|
||||
});
|
||||
|
||||
// Branding-Vorschau live faerben
|
||||
function updateBrandPreview() {
|
||||
const preview = document.getElementById('brandPreview');
|
||||
if (!preview) return;
|
||||
const accent = (document.getElementById('brand_accent') || {}).value || '';
|
||||
const from = (document.getElementById('brand_gradient_from') || {}).value || '';
|
||||
const to = (document.getElementById('brand_gradient_to') || {}).value || '';
|
||||
const radius = (document.getElementById('brand_radius') || {}).value || '14';
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(accent)) {
|
||||
preview.style.setProperty('--accent', accent);
|
||||
preview.style.setProperty('--accent-hover', `color-mix(in srgb, ${accent} 84%, #000)`);
|
||||
preview.style.setProperty('--accent-soft', `color-mix(in srgb, ${accent} 12%, #fff)`);
|
||||
preview.style.setProperty('--accent-border', `color-mix(in srgb, ${accent} 32%, #fff)`);
|
||||
}
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(from) && /^#[0-9a-fA-F]{6}$/.test(to)) {
|
||||
preview.style.setProperty('--brand-gradient', `linear-gradient(135deg, ${from} 0%, ${to} 100%)`);
|
||||
}
|
||||
preview.style.setProperty('--r-lg', radius + 'px');
|
||||
}
|
||||
['brand_accent', 'brand_gradient_from', 'brand_gradient_to', 'brand_radius'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', updateBrandPreview);
|
||||
if (el) el.addEventListener('change', updateBrandPreview);
|
||||
});
|
||||
updateBrandPreview();
|
||||
|
||||
// Farbwähler und Hex-Feld synchron halten
|
||||
document.querySelectorAll('.color-swatch').forEach(swatch => {
|
||||
const field = document.getElementById(swatch.dataset.target);
|
||||
if (!field) return;
|
||||
swatch.addEventListener('input', () => { field.value = swatch.value; });
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// Restore tab from hash
|
||||
// Tab aus Anker uebernehmen (der Query-Parameter wird serverseitig gesetzt)
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
const hash = location.hash.substring(1);
|
||||
if (hash) {
|
||||
|
|
@ -603,9 +765,9 @@ async function testSmtp() {
|
|||
const email = document.getElementById('smtpTestEmail').value.trim();
|
||||
const btn = document.getElementById('smtpTestBtn');
|
||||
const result = document.getElementById('smtpTestResult');
|
||||
if (!email) { result.textContent = 'Bitte E-Mail eingeben.'; return; }
|
||||
if (!email) { result.textContent = '<?= __('js_enter_email') ?>'; return; }
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>';
|
||||
const fd = new FormData();
|
||||
fd.append('ajax_smtp_test', '1');
|
||||
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
|
||||
|
|
@ -615,26 +777,26 @@ async function testSmtp() {
|
|||
result.textContent = data.message;
|
||||
result.style.color = data.success ? 'var(--success)' : 'var(--danger)';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Testen';
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane" aria-hidden="true"></i> Testen';
|
||||
}
|
||||
|
||||
async function testCronJob() {
|
||||
const btn = document.getElementById('testCronBtn');
|
||||
const result = document.getElementById('testCronResult');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Läuft...';
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= __('js_running') ?>';
|
||||
try {
|
||||
const res = await fetch('../cron_sync.php?token=<?= htmlspecialchars($cs['cron_token']) ?>');
|
||||
const data = await res.json();
|
||||
result.innerHTML = data.success
|
||||
? `<span style="color:var(--success)"><i class="fas fa-check-circle"></i> ${data.message}</span>`
|
||||
: `<span style="color:var(--danger)"><i class="fas fa-times-circle"></i> ${data.message}</span>`;
|
||||
? `<span style="color:var(--success)"><i class="fas fa-check-circle" aria-hidden="true"></i> ${data.message}</span>`
|
||||
: `<span style="color:var(--danger)"><i class="fas fa-times-circle" aria-hidden="true"></i> ${data.message}</span>`;
|
||||
if (data.success) showToast('success', 'Cron ausgeführt', data.message);
|
||||
} catch (e) {
|
||||
result.innerHTML = `<span style="color:var(--danger)">Fehler: ${e.message}</span>`;
|
||||
result.innerHTML = `<span style="color:var(--danger)"><?= __('js_error') ?>: ${e.message}</span>`;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-play"></i> Jetzt ausführen';
|
||||
btn.innerHTML = '<i class="fas fa-play" aria-hidden="true"></i> <?= __('js_run_now') ?>';
|
||||
}
|
||||
|
||||
function initTinyMCE() {
|
||||
|
|
@ -655,6 +817,10 @@ function initTinyMCE() {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,20 +105,20 @@ $currentPage = 'sites';
|
|||
<div class="page-header">
|
||||
<h1 class="page-title"><?= __('sites_title') ?></h1>
|
||||
<button onclick="openModal()" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> <?= __('sites_add') ?>
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
|
||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
|
||||
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($sites)): ?>
|
||||
<div class="empty-card">
|
||||
<i class="fas fa-map-marker-alt" style="font-size:48px;margin-bottom:20px;opacity:.3;display:block;"></i>
|
||||
<i class="fas fa-map-marker-alt" style="font-size:48px;margin-bottom:20px;opacity:.3;display:block;" aria-hidden="true"></i>
|
||||
<p><?= __('sites_none') ?></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
|
|
@ -132,43 +132,43 @@ $currentPage = 'sites';
|
|||
</div>
|
||||
<div>
|
||||
<?php if ($site['is_active']): ?>
|
||||
<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span>
|
||||
<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_active') ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span>
|
||||
<span class="badge badge-warning"><i class="fas fa-pause" aria-hidden="true"></i> <?= __('status_inactive') ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($site['public_access']): ?>
|
||||
<span class="badge badge-info"><i class="fas fa-globe"></i> <?= __('status_public') ?></span>
|
||||
<span class="badge badge-info"><i class="fas fa-globe" aria-hidden="true"></i> <?= __('status_public') ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-info">
|
||||
<div class="site-info-item">
|
||||
<i class="fas fa-server" style="color:var(--accent);width:16px;"></i>
|
||||
<i class="fas fa-server" style="color:var(--accent);width:16px;" aria-hidden="true"></i>
|
||||
<span style="word-break:break-all;"><?= htmlspecialchars($site['unifi_controller_url']) ?></span>
|
||||
</div>
|
||||
<div class="site-info-item">
|
||||
<i class="fas fa-user" style="color:var(--accent);width:16px;"></i>
|
||||
<i class="fas fa-user" style="color:var(--accent);width:16px;" aria-hidden="true"></i>
|
||||
<span><?= htmlspecialchars($site['unifi_username']) ?></span>
|
||||
</div>
|
||||
<div class="site-info-item">
|
||||
<i class="fas fa-clock" style="color:var(--text-muted);width:16px;"></i>
|
||||
<i class="fas fa-clock" style="color:var(--text-muted);width:16px;" aria-hidden="true"></i>
|
||||
<span style="color:var(--text-muted);"><?= date('d.m.Y', strtotime($site['created_at'])) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-actions">
|
||||
<button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>)"
|
||||
class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-edit"></i> <?= __('btn_edit') ?>
|
||||
<i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?>
|
||||
</button>
|
||||
<a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||
class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>"></i>
|
||||
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i>
|
||||
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
|
||||
</a>
|
||||
<a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||
class="btn btn-danger-soft btn-sm"
|
||||
onclick="return confirm('Möchten Sie diese Site wirklich löschen?')">
|
||||
<i class="fas fa-trash"></i>
|
||||
onclick="return confirm('<?= __('js_confirm_delete_site') ?>')">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -196,7 +196,7 @@ $currentPage = 'sites';
|
|||
<div class="form-group">
|
||||
<label><?= __('sites_site_id') ?></label>
|
||||
<input type="text" name="site_id" required placeholder="z.B. default">
|
||||
<small style="color:var(--text-muted);font-size:12px;">Zu finden in der UniFi Controller URL</small>
|
||||
<small style="color:var(--text-muted);font-size:12px;"><?= __('sites_id_hint') ?></small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><?= __('sites_controller') ?></label>
|
||||
|
|
@ -219,7 +219,7 @@ $currentPage = 'sites';
|
|||
</div>
|
||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||
<button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn">
|
||||
<i class="fas fa-save"></i> <?= __('sites_add') ?>
|
||||
<i class="fas fa-save" aria-hidden="true"></i> <?= __('sites_add') ?>
|
||||
</button>
|
||||
<button type="button" onclick="closeModal('addSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||
</div>
|
||||
|
|
@ -259,7 +259,7 @@ $currentPage = 'sites';
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label><?= __('sites_password_edit') ?></label>
|
||||
<input type="password" id="edit_password" name="password" placeholder="Leer lassen = nicht ändern">
|
||||
<input type="password" id="edit_password" name="password" placeholder="<?= __('sites_pw_unchanged') ?>">
|
||||
<small style="color:var(--text-muted);font-size:12px;"><?= __('sites_password_hint') ?></small>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -269,7 +269,7 @@ $currentPage = 'sites';
|
|||
</div>
|
||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||
<button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn">
|
||||
<i class="fas fa-save"></i> <?= __('btn_save') ?>
|
||||
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
|
||||
</button>
|
||||
<button type="button" onclick="closeModal('editSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||
</div>
|
||||
|
|
@ -278,7 +278,7 @@ $currentPage = 'sites';
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||
<script src="../assets/global.js"></script>
|
||||
<script>
|
||||
function openModal() { document.getElementById('addSiteModal').classList.add('active'); }
|
||||
|
|
@ -298,12 +298,12 @@ function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAcces
|
|||
document.getElementById('addSiteForm').addEventListener('submit', function() {
|
||||
const btn = document.getElementById('addSiteSubmitBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>';
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= addslashes(__('sites_testing')) ?>';
|
||||
});
|
||||
document.getElementById('editSiteForm').addEventListener('submit', function() {
|
||||
const btn = document.getElementById('editSiteSubmitBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>';
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> <?= addslashes(__('sites_testing')) ?>';
|
||||
});
|
||||
|
||||
['addSiteModal','editSiteModal'].forEach(id => {
|
||||
|
|
|
|||
|
|
@ -107,15 +107,15 @@ $adminBase = '';
|
|||
<p class="page-subtitle"><?= __('templates_subtitle') ?></p>
|
||||
</div>
|
||||
<button onclick="openAddModal()" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> <?= __('templates_add') ?>
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('templates_add') ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
|
||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
|
||||
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
|
|
@ -125,15 +125,15 @@ $adminBase = '';
|
|||
</div>
|
||||
<?php if (empty($templates)): ?>
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
<i class="fas fa-layer-group" aria-hidden="true"></i>
|
||||
<p style="font-size: 15px; margin-bottom: 8px;"><?= __('templates_none') ?></p>
|
||||
<p style="font-size: 13px;"><?= __('templates_add_hint') ?></p>
|
||||
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top: 20px;"><i class="fas fa-plus"></i> <?= __('templates_add') ?></button>
|
||||
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top: 20px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('templates_add') ?></button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="overflow-x: auto;">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<table class="table table-stack">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= __('templates_name') ?></th>
|
||||
|
|
@ -147,11 +147,11 @@ $adminBase = '';
|
|||
<tbody>
|
||||
<?php foreach ($templates as $t): ?>
|
||||
<tr>
|
||||
<td><strong><?= htmlspecialchars($t['name']) ?></strong></td>
|
||||
<td>
|
||||
<span class="duration-badge"><i class="fas fa-mobile-alt"></i> <?= (int)$t['max_uses'] ?></span>
|
||||
<td data-label="<?= __('templates_name') ?>"><strong><?= htmlspecialchars($t['name']) ?></strong></td>
|
||||
<td data-label="<?= __('templates_devices') ?>">
|
||||
<span class="duration-badge"><i class="fas fa-mobile-alt" aria-hidden="true"></i> <?= (int)$t['max_uses'] ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="<?= __('templates_duration') ?>">
|
||||
<?php
|
||||
$m = (int)$t['expire_minutes'];
|
||||
if ($m >= 1440 && $m % 1440 === 0) {
|
||||
|
|
@ -162,22 +162,22 @@ $adminBase = '';
|
|||
$durLabel = $m . ' Min.';
|
||||
}
|
||||
?>
|
||||
<span class="duration-badge"><i class="fas fa-clock"></i> <?= $durLabel ?></span>
|
||||
<span class="duration-badge"><i class="fas fa-clock" aria-hidden="true"></i> <?= $durLabel ?></span>
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
|
||||
<td>
|
||||
<td data-label="<?= __('templates_desc') ?>" style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
|
||||
<td data-label="<?= __('label_status') ?>">
|
||||
<?php if ($t['is_active']): ?>
|
||||
<span class="badge badge-success"><?= __('status_active') ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-secondary"><?= __('status_inactive') ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="<?= __('label_actions') ?>">
|
||||
<button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)"
|
||||
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
|
||||
class="btn btn-secondary btn-small"><i class="fas fa-edit" aria-hidden="true"></i></button>
|
||||
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||
onclick="return confirm('Profil wirklich löschen?')"
|
||||
class="btn btn-danger-soft btn-small"><i class="fas fa-trash"></i></a>
|
||||
onclick="return confirm('<?= __('js_confirm_delete_template') ?>')"
|
||||
class="btn btn-danger-soft btn-small"><i class="fas fa-trash" aria-hidden="true"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
|
@ -194,7 +194,7 @@ $adminBase = '';
|
|||
<div id="addModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title"><i class="fas fa-plus-circle" style="color: var(--accent);"></i> <?= __('templates_add') ?></h2>
|
||||
<h2 class="modal-title"><i class="fas fa-plus-circle" style="color: var(--accent);" aria-hidden="true"></i> <?= __('templates_add') ?></h2>
|
||||
<button class="modal-close" onclick="closeModal('addModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
|
@ -210,17 +210,17 @@ $adminBase = '';
|
|||
<div class="form-group">
|
||||
<label><?= __('templates_duration') ?> *</label>
|
||||
<input type="number" name="expire_minutes" value="480" min="1" max="525600">
|
||||
<div class="help-text">480 = 8 Stunden</div>
|
||||
<div class="help-text"><?= __('templates_minutes_hint') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="Kurze Beschreibung für Ihr Team"></textarea></div>
|
||||
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="<?= __('templates_desc_placeholder') ?>"></textarea></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||
<div class="form-group"><label>Download (kbit/s)</label><input type="number" name="qos_rate_max_down" min="0" placeholder="0 = unbegrenzt"></div>
|
||||
<div class="form-group"><label>Upload (kbit/s)</label><input type="number" name="qos_rate_max_up" min="0" placeholder="0 = unbegrenzt"></div>
|
||||
<div class="form-group"><label>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" min="0" placeholder="0 = unbegrenzt"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||
<button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||
<button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -232,7 +232,7 @@ $adminBase = '';
|
|||
<div id="editModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title"><i class="fas fa-edit" style="color: var(--accent);"></i> <?= __('templates_edit') ?></h2>
|
||||
<h2 class="modal-title"><i class="fas fa-edit" style="color: var(--accent);" aria-hidden="true"></i> <?= __('templates_edit') ?></h2>
|
||||
<button class="modal-close" onclick="closeModal('editModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
|
@ -261,7 +261,7 @@ $adminBase = '';
|
|||
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;">
|
||||
<button type="submit" name="edit_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||
<button type="submit" name="edit_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?></button>
|
||||
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -175,26 +175,26 @@ $currentPage = 'users';
|
|||
<div class="page-header">
|
||||
<h1 class="page-title"><?= __('users_title') ?></h1>
|
||||
<button onclick="openModal()" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> <?= __('users_add') ?>
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('users_add') ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
|
||||
<div class="alert alert-error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
|
||||
<div class="alert alert-success"><i class="fas fa-check-circle" aria-hidden="true"></i> <?= htmlspecialchars($success) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title"><?= __('users_all') ?></h2></div>
|
||||
<div class="card-body" style="padding:0;">
|
||||
<?php if (empty($users)): ?>
|
||||
<div class="empty-state"><i class="fas fa-users"></i><p><?= __('users_none_found') ?></p></div>
|
||||
<div class="empty-state"><i class="fas fa-users" aria-hidden="true"></i><p><?= __('users_none_found') ?></p></div>
|
||||
<?php else: ?>
|
||||
<div style="overflow-x:auto;">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<table class="table table-stack">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= __('label_name') ?></th>
|
||||
|
|
@ -210,28 +210,28 @@ $currentPage = 'users';
|
|||
<?php foreach ($users as $user): ?>
|
||||
<?php $userSiteIds = array_column($userSiteAccess[$user['id']]??[], 'id'); ?>
|
||||
<tr>
|
||||
<td>
|
||||
<td data-label="<?= __('label_name') ?>">
|
||||
<strong><?= htmlspecialchars($user['name']) ?></strong>
|
||||
<?php if ($user['id'] == $_SESSION['user_id']): ?>
|
||||
<span class="badge badge-info"><?= __('users_you') ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($user['email']) ?></td>
|
||||
<td>
|
||||
<td data-label="<?= __('label_email') ?>"><?= htmlspecialchars($user['email']) ?></td>
|
||||
<td data-label="<?= __('label_role') ?>">
|
||||
<?php if ($user['is_admin']): ?>
|
||||
<span class="badge badge-danger"><i class="fas fa-crown"></i> <?= __('status_admin') ?></span>
|
||||
<span class="badge badge-danger"><i class="fas fa-crown" aria-hidden="true"></i> <?= __('status_admin') ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-info"><?= __('status_user') ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="<?= __('label_status') ?>">
|
||||
<?php if ($user['is_active']): ?>
|
||||
<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span>
|
||||
<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_active') ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span>
|
||||
<span class="badge badge-warning"><i class="fas fa-pause" aria-hidden="true"></i> <?= __('status_inactive') ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="<?= __('users_site_access') ?>">
|
||||
<?php if ($user['is_admin']): ?>
|
||||
<em style="color:var(--text-muted);"><?= __('users_all_sites') ?></em>
|
||||
<?php elseif (!empty($userSiteAccess[$user['id']])): ?>
|
||||
|
|
@ -242,7 +242,7 @@ $currentPage = 'users';
|
|||
<em style="color:var(--text-muted);"><?= __('users_none') ?></em>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="font-size:13px;">
|
||||
<td data-label="<?= __('users_last_login') ?>" style="font-size:13px;">
|
||||
<?php if ($user['last_login']): ?>
|
||||
<?= date('d.m.Y H:i', strtotime($user['last_login'])) ?>
|
||||
<?php else: ?>
|
||||
|
|
@ -253,31 +253,31 @@ $currentPage = 'users';
|
|||
<div class="action-btns">
|
||||
<button onclick="openEditModal(<?= $user['id'] ?>, '<?= htmlspecialchars($user['name'], ENT_QUOTES) ?>', <?= $user['is_admin'] ?>, [<?= implode(',', array_map('intval', $userSiteIds)) ?>])"
|
||||
class="btn btn-secondary btn-sm" title="<?= __('btn_edit') ?>">
|
||||
<i class="fas fa-edit"></i>
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</button>
|
||||
<?php if ($user['id'] != $_SESSION['user_id']): ?>
|
||||
<a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||
class="btn btn-secondary btn-sm" title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>">
|
||||
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>"></i>
|
||||
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>" aria-hidden="true"></i>
|
||||
</a>
|
||||
<?php if ($smtpEnabled && !empty($user['password_hash'])): ?>
|
||||
<a href="?send_reset=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||
class="btn btn-warning btn-sm" title="<?= __('users_reset_pw') ?>"
|
||||
onclick="return confirm('Passwort-Reset-Link senden an <?= htmlspecialchars($user['email'], ENT_QUOTES) ?>?')">
|
||||
<i class="fas fa-key"></i>
|
||||
<i class="fas fa-key" aria-hidden="true"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($user['totp_enabled'])): ?>
|
||||
<a href="?reset_2fa=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||
class="btn btn-secondary btn-sm" title="2FA zurücksetzen"
|
||||
onclick="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<i class="fas fa-user-shield" aria-hidden="true"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||
class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>"
|
||||
onclick="return confirm('Benutzer wirklich löschen?')">
|
||||
<i class="fas fa-trash"></i>
|
||||
onclick="return confirm('<?= __('js_confirm_delete_user') ?>')">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
|
@ -342,7 +342,7 @@ $currentPage = 'users';
|
|||
</div>
|
||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||
<button type="submit" name="add_user" class="btn btn-primary" style="flex:1;">
|
||||
<i class="fas fa-save"></i> <?= __('users_save') ?>
|
||||
<i class="fas fa-save" aria-hidden="true"></i> <?= __('users_save') ?>
|
||||
</button>
|
||||
<button type="button" onclick="closeModal('addUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||
</div>
|
||||
|
|
@ -386,7 +386,7 @@ $currentPage = 'users';
|
|||
</div>
|
||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||
<button type="submit" name="edit_user" class="btn btn-primary" style="flex:1;">
|
||||
<i class="fas fa-save"></i> <?= __('users_save_edit') ?>
|
||||
<i class="fas fa-save" aria-hidden="true"></i> <?= __('users_save_edit') ?>
|
||||
</button>
|
||||
<button type="button" onclick="closeModal('editUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||
</div>
|
||||
|
|
@ -395,7 +395,7 @@ $currentPage = 'users';
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||
<script src="../assets/global.js"></script>
|
||||
<script>
|
||||
function openModal() { document.getElementById('addUserModal').classList.add('active'); }
|
||||
|
|
|
|||
|
|
@ -143,9 +143,9 @@ $currentPage = 'vouchers';
|
|||
|
||||
<?php if (empty($sites)): ?>
|
||||
<div class="no-sites-warning">
|
||||
<i class="fas fa-triangle-exclamation" style="font-size:26px;margin-bottom:12px;display:block;"></i>
|
||||
<i class="fas fa-triangle-exclamation" style="font-size:26px;margin-bottom:12px;display:block;" aria-hidden="true"></i>
|
||||
<h3><?= __('vouchers_no_sites') ?></h3>
|
||||
<a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus"></i> <?= __('nav_sites') ?></a>
|
||||
<a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus" aria-hidden="true"></i> <?= __('nav_sites') ?></a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ $currentPage = 'vouchers';
|
|||
</select>
|
||||
<input type="search" id="searchInput" class="search-bar" placeholder="<?= __('vouchers_search') ?>" oninput="filterAndRender()" style="display:none;">
|
||||
<button id="refreshBtn" class="btn btn-secondary" onclick="loadVouchers(true)" disabled>
|
||||
<i class="fas fa-sync-alt"></i> <?= __('btn_refresh') ?>
|
||||
<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= __('btn_refresh') ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -187,12 +187,12 @@ $currentPage = 'vouchers';
|
|||
<div class="card-header">
|
||||
<h2 class="card-title" id="voucherListTitle">Vouchers</h2>
|
||||
<a id="csvExportBtn" style="display:none;" class="btn btn-secondary btn-sm" href="#">
|
||||
<i class="fas fa-download"></i> <?= __('btn_export_csv') ?>
|
||||
<i class="fas fa-download" aria-hidden="true"></i> <?= __('btn_export_csv') ?>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body" style="padding:0;">
|
||||
<div id="voucherContent">
|
||||
<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('vouchers_select_hint') ?></p></div>
|
||||
<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= __('vouchers_select_hint') ?></p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -200,10 +200,12 @@ $currentPage = 'vouchers';
|
|||
|
||||
</main>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||
<script src="../assets/global.js"></script>
|
||||
<script>
|
||||
const csrfToken = '<?= $auth->getCsrfToken() ?>';
|
||||
// Datums- und Zeitformat folgen der gewaehlten Sprache
|
||||
const LOCALE = '<?= I18n::getLanguage() === 'en' ? 'en-GB' : 'de-DE' ?>';
|
||||
let currentSiteId = null;
|
||||
let allVouchers = [];
|
||||
let currentFilter = 'all';
|
||||
|
|
@ -218,7 +220,7 @@ async function loadVouchers(syncFirst=false) {
|
|||
const searchInput = document.getElementById('searchInput');
|
||||
|
||||
if (!siteId) {
|
||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_select_hint')) ?></p></div>`;
|
||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= addslashes(__('vouchers_select_hint')) ?></p></div>`;
|
||||
document.getElementById('statsContainer').style.display = 'none';
|
||||
searchInput.style.display = 'none';
|
||||
refreshBtn.disabled = true;
|
||||
|
|
@ -227,9 +229,9 @@ async function loadVouchers(syncFirst=false) {
|
|||
|
||||
currentSiteId = siteId;
|
||||
refreshBtn.disabled = false;
|
||||
refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
|
||||
refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
|
||||
|
||||
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner"></i><span>${syncFirst ? 'Synchronisiere...' : 'Lade...'}</span></div>`;
|
||||
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner" aria-hidden="true"></i><span>${syncFirst ? 'Synchronisiere...' : 'Lade...'}</span></div>`;
|
||||
|
||||
try {
|
||||
const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json());
|
||||
|
|
@ -247,15 +249,15 @@ async function loadVouchers(syncFirst=false) {
|
|||
csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`;
|
||||
if (syncFirst) showToast('success', '<?= addslashes(__('btn_refresh')) ?>', `${result.count} Vouchers geladen`);
|
||||
} else {
|
||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>${result.message}</p></div>`;
|
||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)" aria-hidden="true"></i><p>${result.message}</p></div>`;
|
||||
document.getElementById('statsContainer').style.display = 'none';
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>Verbindungsfehler: ${e.message}</p></div>`;
|
||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)" aria-hidden="true"></i><p>Verbindungsfehler: ${e.message}</p></div>`;
|
||||
document.getElementById('statsContainer').style.display = 'none';
|
||||
}
|
||||
refreshBtn.disabled = false;
|
||||
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= addslashes(__('btn_refresh')) ?>';
|
||||
refreshBtn.innerHTML = '<i class="fas fa-sync-alt" aria-hidden="true"></i> <?= addslashes(__('btn_refresh')) ?>';
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
|
|
@ -293,7 +295,7 @@ function renderVouchers() {
|
|||
const pageVouchers = vouchers.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE);
|
||||
|
||||
if (vouchers.length === 0) {
|
||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`;
|
||||
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt" aria-hidden="true"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -309,7 +311,7 @@ function renderVouchers() {
|
|||
<button class="filter-btn ${currentFilter==='expired'?'active':''}" data-filter="expired" onclick="setFilter('expired')"><?= __('vouchers_filter_expired') ?> (${expiredCnt})</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container"><table class="table">
|
||||
<div class="table-container"><table class="table table-stack">
|
||||
<thead><tr>
|
||||
<th><?= __('label_created') ?></th>
|
||||
<th><?= __('label_code') ?></th>
|
||||
|
|
@ -330,21 +332,21 @@ function renderVouchers() {
|
|||
})() : '';
|
||||
const usagePct = v.quota > 0 ? Math.min(100, (v.used/v.quota)*100) : 0;
|
||||
const statusBadge = v.status==='valid'
|
||||
? `<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_valid') ?></span>`
|
||||
? `<span class="badge badge-success"><i class="fas fa-check" aria-hidden="true"></i> <?= __('status_valid') ?></span>`
|
||||
: v.status==='used'
|
||||
? `<span class="badge badge-warning"><i class="fas fa-user-check"></i> <?= __('status_used') ?></span>`
|
||||
: `<span class="badge badge-danger"><i class="fas fa-times"></i> <?= __('status_expired') ?></span>`;
|
||||
? `<span class="badge badge-warning"><i class="fas fa-user-check" aria-hidden="true"></i> <?= __('status_used') ?></span>`
|
||||
: `<span class="badge badge-danger"><i class="fas fa-times" aria-hidden="true"></i> <?= __('status_expired') ?></span>`;
|
||||
|
||||
html += `<tr id="voucher-${v._id}">
|
||||
<td><strong>${createDate.toLocaleDateString('de-DE')}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}</small></td>
|
||||
<td><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','Kopiert!')" title="Kopieren" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
|
||||
<td class="voucher-note" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
|
||||
<td>${remaining?`<span style="color:var(--success)"><i class="fas fa-clock"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} Min.</small></td>
|
||||
<td style="white-space:nowrap;">
|
||||
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="Per E-Mail senden"><i class="fas fa-envelope"></i></button>
|
||||
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button>
|
||||
<td data-label="<?= __('label_created') ?>"><strong>${createDate.toLocaleDateString(LOCALE)}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString(LOCALE,{hour:'2-digit',minute:'2-digit'})}</small></td>
|
||||
<td data-label="<?= __('label_code') ?>"><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','<?= __('js_copied') ?>')" title="<?= __('js_copy') ?>" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
|
||||
<td class="voucher-note" data-label="<?= __('label_note') ?>" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
|
||||
<td data-label="<?= __('label_status') ?>">${statusBadge}</td>
|
||||
<td data-label="<?= __('label_usage') ?>"><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
|
||||
<td data-label="<?= __('label_expires') ?>">${remaining?`<span style="color:var(--success)"><i class="fas fa-clock" aria-hidden="true"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} <?= __('label_minutes_short') ?></small></td>
|
||||
<td data-label="<?= __('label_actions') ?>" style="white-space:nowrap;">
|
||||
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="<?= __('vouchers_resend') ?>" aria-label="<?= __('vouchers_resend') ?>"><i class="fas fa-envelope" aria-hidden="true"></i></button>
|
||||
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>"><i class="fas fa-trash" aria-hidden="true"></i></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
|
@ -356,11 +358,11 @@ function renderVouchers() {
|
|||
<span style="font-size:13px;color:var(--text-muted);">${<?= json_encode(__('vouchers_page_of')) ?>
|
||||
.replace('{current}',currentPage).replace('{total}',totalPages)} (${vouchers.length})</span>
|
||||
<div style="display:flex;gap:5px;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage-1})" ${currentPage<=1?'disabled':''}><i class="fas fa-chevron-left"></i></button>`;
|
||||
<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage-1})" ${currentPage<=1?'disabled':''}><i class="fas fa-chevron-left" aria-hidden="true"></i></button>`;
|
||||
for (let p=Math.max(1,currentPage-2); p<=Math.min(totalPages,currentPage+2); p++) {
|
||||
html += `<button class="btn btn-sm ${p===currentPage?'btn-primary':'btn-secondary'}" onclick="setPage(${p})">${p}</button>`;
|
||||
}
|
||||
html += `<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}><i class="fas fa-chevron-right"></i></button>
|
||||
html += `<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
|
|
@ -384,7 +386,7 @@ async function resendVoucher(voucherId, code) {
|
|||
}
|
||||
|
||||
async function deleteVoucher(voucherId) {
|
||||
if (!confirm('Voucher wirklich löschen?')) return;
|
||||
if (!confirm('<?= __('js_confirm_delete_voucher') ?>')) return;
|
||||
const row = document.getElementById(`voucher-${voucherId}`);
|
||||
if (row) row.classList.add('deleting');
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
/* Text */
|
||||
--text-primary: #101625;
|
||||
--text-secondary: #525c6e;
|
||||
--text-muted: #8a93a3;
|
||||
--text-muted: #6b7280;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* Linien */
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
|
||||
--text-primary: #e9ecf3;
|
||||
--text-secondary: #a2abbd;
|
||||
--text-muted: #6f7889;
|
||||
--text-muted: #8a93a6;
|
||||
--text-inverse: #0a0c11;
|
||||
|
||||
--border-color: #232838;
|
||||
|
|
@ -562,7 +562,28 @@ input:focus, select:focus, textarea:focus, .input:focus {
|
|||
}
|
||||
input:disabled, select:disabled, textarea:disabled { background: var(--bg-hover); color: var(--text-muted); cursor: not-allowed; }
|
||||
input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; }
|
||||
input[type="file"] { font-size: 13px; color: var(--text-secondary); }
|
||||
input[type="file"] {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
input[type="file"]::file-selector-button {
|
||||
margin-right: 10px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
transition: background-color .15s, border-color .15s;
|
||||
}
|
||||
input[type="file"]::file-selector-button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.chk, label.chk {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
|
|
@ -1060,6 +1081,22 @@ input[type="file"] { font-size: 13px; color: var(--text-secondary); }
|
|||
border-radius: var(--r-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
/* Bildfeld: Vorschau, Upload und URL nebeneinander */
|
||||
.image-field { display: flex; gap: 14px; align-items: flex-start; }
|
||||
.image-preview {
|
||||
width: 84px; height: 64px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 6px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-field-controls { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.image-field-controls .chk { margin: 0; font-size: 12.5px; color: var(--text-secondary); }
|
||||
|
||||
/* Farbwähler mit Hex-Eingabe */
|
||||
.color-field { display: flex; align-items: center; gap: 8px; }
|
||||
.color-field input[type="color"] {
|
||||
|
|
@ -1494,6 +1531,71 @@ input.search-bar, .site-selector .search-bar { min-width: 240px; width: auto; fl
|
|||
.row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
@media (max-width: 720px) { .row, .row3 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* QR-Code auf der Druckkarte */
|
||||
.print-qr { display: flex; justify-content: center; line-height: 0; }
|
||||
.print-qr img, .print-qr canvas { display: block; }
|
||||
|
||||
/* Sprungmarke zum Inhalt (nur bei Tastaturfokus sichtbar) */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: -60px;
|
||||
z-index: 999;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
color: var(--accent);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
transition: top .15s;
|
||||
}
|
||||
.skip-link:focus { top: 12px; }
|
||||
|
||||
/* Tabellen, die auf schmalen Geräten zu Karten werden.
|
||||
Die Spaltenüberschrift steht dann als data-label vor dem Wert. */
|
||||
@media (max-width: 720px) {
|
||||
.table-stack thead { display: none; }
|
||||
.table-stack, .table-stack tbody, .table-stack tr, .table-stack td { display: block; width: 100%; }
|
||||
.table-stack tr {
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.table-stack td {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: right;
|
||||
}
|
||||
.table-stack tr td:last-child { border-bottom: none; }
|
||||
.table-stack td::before {
|
||||
content: attr(data-label);
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 11.5px;
|
||||
font-weight: 620;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
text-align: left;
|
||||
}
|
||||
.table-stack td:not([data-label])::before { content: ''; }
|
||||
.table-stack .details-cell, .table-stack .voucher-note { max-width: none; white-space: normal; }
|
||||
}
|
||||
|
||||
/* Nutzer, die Bewegung reduzieren möchten, bekommen keine Animationen. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
13. RESPONSIVE
|
||||
========================================================================= */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
/* === DARK MODE === */
|
||||
/* === DARK MODE ===
|
||||
Reihenfolge: ausdrueckliche Auswahl des Nutzers > Systemeinstellung. */
|
||||
(function() {
|
||||
const saved = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
const saved = localStorage.getItem('theme');
|
||||
// Der Inline-Schnipsel im <head> hat das Theme bereits gesetzt – diese
|
||||
// Entscheidung wird hier nicht überschrieben.
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const system = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', saved || current || system);
|
||||
|
||||
// Solange nichts ausgewaehlt wurde, folgt die Oberflaeche dem System.
|
||||
if (!saved && window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (localStorage.getItem('theme')) return;
|
||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||
if (typeof updateDarkModeBtn === 'function') updateDarkModeBtn();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function toggleDarkMode() {
|
||||
|
|
|
|||
30
assets/vendor/README.md
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Lokale Drittanbieter-Assets
|
||||
|
||||
Alle Frontend-Bibliotheken werden aus diesem Ordner ausgeliefert. Damit gibt es
|
||||
im Betrieb **keine Verbindungen zu externen CDNs** – wichtig für den Datenschutz
|
||||
(keine IP-Übertragung an Dritte) und für abgeschottete Netze ohne Internetzugang.
|
||||
|
||||
| Ordner | Inhalt | Version | Lizenz |
|
||||
|---|---|---|---|
|
||||
| `inter/` | Schriftschnitte 400/500/600/700 als woff2 | Inter 4.0 | SIL OFL 1.1 |
|
||||
| `fontawesome/` | Solid- und Brands-Icons, gekürzte CSS (nur woff2) | Font Awesome Free 6.4.0 | Icons CC BY 4.0, Fonts SIL OFL 1.1, Code MIT |
|
||||
| `chartjs/` | Diagramme für Dashboard und Reporting | Chart.js 4.4.0 | MIT |
|
||||
| `qrcodejs/` | QR-Code-Erzeugung im Browser | qrcodejs 1.0.0 | MIT |
|
||||
| `tinymce/` | WYSIWYG-Editor (GPL-Variante), auf die genutzten Plugins gekürzt | TinyMCE 6.8.3 | GPL-2.0-or-later |
|
||||
|
||||
Eingebunden werden sie über `Ui::head()` bzw. `Ui::script()` aus
|
||||
`includes/Ui.php` – inklusive Versionsstempel (`?v=<filemtime>`), damit Browser
|
||||
nach einem Update nicht die alten Dateien aus dem Cache verwenden.
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
```bash
|
||||
# Beispiel Chart.js
|
||||
curl -L -o assets/vendor/chartjs/chart.umd.min.js \
|
||||
https://cdn.jsdelivr.net/npm/chart.js@<version>/dist/chart.umd.min.js
|
||||
```
|
||||
|
||||
Bei TinyMCE werden nur `tinymce.min.js`, `themes/silver`, `models/dom`,
|
||||
`icons/default`, die Skins `oxide`/`oxide-dark`, die deutsche Sprachdatei und
|
||||
die tatsächlich genutzten Plugins übernommen (siehe `initTinyMCE()` in
|
||||
`admin/settings.php`).
|
||||
20
assets/vendor/chartjs/chart.umd.min.js
vendored
Normal file
11
assets/vendor/fontawesome/fontawesome.css
vendored
Normal file
BIN
assets/vendor/fontawesome/webfonts/fa-brands-400.woff2
vendored
Normal file
BIN
assets/vendor/fontawesome/webfonts/fa-solid-900.woff2
vendored
Normal file
BIN
assets/vendor/inter/Inter-Bold.woff2
vendored
Normal file
BIN
assets/vendor/inter/Inter-Medium.woff2
vendored
Normal file
BIN
assets/vendor/inter/Inter-Regular.woff2
vendored
Normal file
BIN
assets/vendor/inter/Inter-SemiBold.woff2
vendored
Normal file
30
assets/vendor/inter/inter.css
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* Inter v4 – lokal ausgeliefert (SIL Open Font License 1.1).
|
||||
Quelle: https://github.com/rsms/inter/releases – nur die vier genutzten Schnitte. */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('Inter-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('Inter-Medium.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('Inter-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
1
assets/vendor/qrcodejs/qrcode.min.js
vendored
Normal file
1
assets/vendor/tinymce/icons/default/icons.min.js
vendored
Normal file
406
assets/vendor/tinymce/langs/de.js
vendored
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
tinymce.addI18n("de", {
|
||||
"Redo": "Wiederholen",
|
||||
"Undo": "R\xfcckg\xe4ngig machen",
|
||||
"Cut": "Ausschneiden",
|
||||
"Copy": "Kopieren",
|
||||
"Paste": "Einf\xfcgen",
|
||||
"Select all": "Alles ausw\xe4hlen",
|
||||
"New document": "Neues Dokument",
|
||||
"Ok": "Ok",
|
||||
"Cancel": "Abbrechen",
|
||||
"Visual aids": "Visuelle Hilfen",
|
||||
"Bold": "Fett",
|
||||
"Italic": "Kursiv",
|
||||
"Underline": "Unterstrichen",
|
||||
"Strikethrough": "Durchgestrichen",
|
||||
"Superscript": "Hochgestellt",
|
||||
"Subscript": "Tiefgestellt",
|
||||
"Clear formatting": "Formatierung entfernen",
|
||||
"Remove": "Entfernen",
|
||||
"Align left": "Linksb\xfcndig ausrichten",
|
||||
"Align center": "Zentrieren",
|
||||
"Align right": "Rechtsb\xfcndig ausrichten",
|
||||
"No alignment": "Keine Ausrichtung",
|
||||
"Justify": "Blocksatz",
|
||||
"Bullet list": "Aufz\xe4hlung",
|
||||
"Numbered list": "Nummerierte Liste",
|
||||
"Decrease indent": "Einzug verkleinern",
|
||||
"Increase indent": "Einzug vergr\xf6\xdfern",
|
||||
"Close": "Schlie\xdfen",
|
||||
"Formats": "Formate",
|
||||
"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X/C/V keyboard shortcuts instead.": "Ihr Browser unterst\xfctzt leider keinen direkten Zugriff auf die Zwischenablage. Bitte benutzen Sie die Tastenkombinationen Strg+X/C/V.",
|
||||
"Headings": "\xdcberschriften",
|
||||
"Heading 1": "\xdcberschrift 1",
|
||||
"Heading 2": "\xdcberschrift 2",
|
||||
"Heading 3": "\xdcberschrift 3",
|
||||
"Heading 4": "\xdcberschrift 4",
|
||||
"Heading 5": "\xdcberschrift 5",
|
||||
"Heading 6": "\xdcberschrift 6",
|
||||
"Preformatted": "Vorformatiert",
|
||||
"Div": "Div",
|
||||
"Pre": "Pre",
|
||||
"Code": "Code",
|
||||
"Paragraph": "Absatz",
|
||||
"Blockquote": "Blockzitat",
|
||||
"Inline": "Zeichenformate",
|
||||
"Blocks": "Bl\xf6cke",
|
||||
"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Einf\xfcgen ist nun im unformatierten Textmodus. Inhalte werden ab jetzt als unformatierter Text eingef\xfcgt, bis Sie diese Einstellung wieder deaktivieren.",
|
||||
"Fonts": "Schriftarten",
|
||||
"Font sizes": "Schriftgr\xf6\xdfen",
|
||||
"Class": "Klasse",
|
||||
"Browse for an image": "Bild...",
|
||||
"OR": "ODER",
|
||||
"Drop an image here": "Bild hier ablegen",
|
||||
"Upload": "Hochladen",
|
||||
"Uploading image": "Bild wird hochgeladen",
|
||||
"Block": "Blocksatz",
|
||||
"Align": "Ausrichtung",
|
||||
"Default": "Standard",
|
||||
"Circle": "Kreis",
|
||||
"Disc": "Scheibe",
|
||||
"Square": "Rechteck",
|
||||
"Lower Alpha": "Lateinisches Alphabet in Kleinbuchstaben",
|
||||
"Lower Greek": "Griechische Kleinbuchstaben",
|
||||
"Lower Roman": "Kleiner r\xf6mischer Buchstabe",
|
||||
"Upper Alpha": "Lateinisches Alphabet in Gro\xdfbuchstaben",
|
||||
"Upper Roman": "Gro\xdfer r\xf6mischer Buchstabe",
|
||||
"Anchor...": "Textmarke",
|
||||
"Anchor": "Anker",
|
||||
"Name": "Name",
|
||||
"ID": "ID",
|
||||
"ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Die ID muss mit einem Buchstaben beginnen gefolgt von Buchstaben, Zahlen, Bindestrichen, Punkten, Doppelpunkten oder Unterstrichen.",
|
||||
"You have unsaved changes are you sure you want to navigate away?": "Die \xc4nderungen wurden noch nicht gespeichert. Sind Sie sicher, dass Sie diese Seite verlassen wollen?",
|
||||
"Restore last draft": "Letzten Entwurf wiederherstellen",
|
||||
"Special character...": "Sonderzeichen...",
|
||||
"Special Character": "Sonderzeichen",
|
||||
"Source code": "Quellcode",
|
||||
"Insert/Edit code sample": "Codebeispiel einf\xfcgen/bearbeiten",
|
||||
"Language": "Sprache",
|
||||
"Code sample...": "Codebeispiel...",
|
||||
"Left to right": "Von links nach rechts",
|
||||
"Right to left": "Von rechts nach links",
|
||||
"Title": "Titel",
|
||||
"Fullscreen": "Vollbild",
|
||||
"Action": "Aktion",
|
||||
"Shortcut": "Tastenkombination",
|
||||
"Help": "Hilfe",
|
||||
"Address": "Adresse",
|
||||
"Focus to menubar": "Fokus auf Men\xfcleiste",
|
||||
"Focus to toolbar": "Fokus auf Symbolleiste",
|
||||
"Focus to element path": "Fokus auf Elementpfad",
|
||||
"Focus to contextual toolbar": "Fokus auf kontextbezogene Symbolleiste",
|
||||
"Insert link (if link plugin activated)": "Link einf\xfcgen (wenn Link-Plugin aktiviert ist)",
|
||||
"Save (if save plugin activated)": "Speichern (wenn Save-Plugin aktiviert ist)",
|
||||
"Find (if searchreplace plugin activated)": "Suchen (wenn Suchen/Ersetzen-Plugin aktiviert ist)",
|
||||
"Plugins installed ({0}):": "Installierte Plugins ({0}):",
|
||||
"Premium plugins:": "Premium-Plugins:",
|
||||
"Learn more...": "Erfahren Sie mehr dazu...",
|
||||
"You are using {0}": "Sie verwenden {0}",
|
||||
"Plugins": "Plugins",
|
||||
"Handy Shortcuts": "Praktische Tastenkombinationen",
|
||||
"Horizontal line": "Horizontale Linie",
|
||||
"Insert/edit image": "Bild einf\xfcgen/bearbeiten",
|
||||
"Alternative description": "Alternative Beschreibung",
|
||||
"Accessibility": "Barrierefreiheit",
|
||||
"Image is decorative": "Bild ist dekorativ",
|
||||
"Source": "Quelle",
|
||||
"Dimensions": "Abmessungen",
|
||||
"Constrain proportions": "Seitenverh\xe4ltnis beibehalten",
|
||||
"General": "Allgemein",
|
||||
"Advanced": "Erweitert",
|
||||
"Style": "Formatvorlage",
|
||||
"Vertical space": "Vertikaler Raum",
|
||||
"Horizontal space": "Horizontaler Raum",
|
||||
"Border": "Rahmen",
|
||||
"Insert image": "Bild einf\xfcgen",
|
||||
"Image...": "Bild...",
|
||||
"Image list": "Bildliste",
|
||||
"Resize": "Skalieren",
|
||||
"Insert date/time": "Datum/Uhrzeit einf\xfcgen",
|
||||
"Date/time": "Datum/Uhrzeit",
|
||||
"Insert/edit link": "Link einf\xfcgen/bearbeiten",
|
||||
"Text to display": "Anzuzeigender Text",
|
||||
"Url": "URL",
|
||||
"Open link in...": "Link \xf6ffnen in...",
|
||||
"Current window": "Aktuelles Fenster",
|
||||
"None": "Keine",
|
||||
"New window": "Neues Fenster",
|
||||
"Open link": "Link \xf6ffnen",
|
||||
"Remove link": "Link entfernen",
|
||||
"Anchors": "Anker",
|
||||
"Link...": "Link...",
|
||||
"Paste or type a link": "Link einf\xfcgen oder eingeben",
|
||||
"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "Diese URL scheint eine E-Mail-Adresse zu sein. M\xf6chten Sie das dazu ben\xf6tigte mailto: voranstellen?",
|
||||
"The URL you entered seems to be an external link. Do you want to add the required http:// prefix?": "Diese URL scheint ein externer Link zu sein. M\xf6chten Sie das dazu ben\xf6tigte http:// voranstellen?",
|
||||
"The URL you entered seems to be an external link. Do you want to add the required https:// prefix?": "Die eingegebene URL scheint ein externer Link zu sein. Soll das fehlende https:// davor erg\xe4nzt werden?",
|
||||
"Link list": "Linkliste",
|
||||
"Insert video": "Video einf\xfcgen",
|
||||
"Insert/edit video": "Video einf\xfcgen/bearbeiten",
|
||||
"Insert/edit media": "Medien einf\xfcgen/bearbeiten",
|
||||
"Alternative source": "Alternative Quelle",
|
||||
"Alternative source URL": "URL der alternativen Quelle",
|
||||
"Media poster (Image URL)": "Medienposter (Bild-URL)",
|
||||
"Paste your embed code below:": "F\xfcgen Sie Ihren Einbettungscode unten ein:",
|
||||
"Embed": "Einbettung",
|
||||
"Media...": "Medien...",
|
||||
"Nonbreaking space": "Gesch\xfctztes Leerzeichen",
|
||||
"Page break": "Seitenumbruch",
|
||||
"Paste as text": "Als Text einf\xfcgen",
|
||||
"Preview": "Vorschau",
|
||||
"Print": "Drucken",
|
||||
"Print...": "Drucken...",
|
||||
"Save": "Speichern",
|
||||
"Find": "Suchen",
|
||||
"Replace with": "Ersetzen durch",
|
||||
"Replace": "Ersetzen",
|
||||
"Replace all": "Alle ersetzen",
|
||||
"Previous": "Vorherige",
|
||||
"Next": "N\xe4chste",
|
||||
"Find and Replace": "Suchen und Ersetzen",
|
||||
"Find and replace...": "Suchen und ersetzen...",
|
||||
"Could not find the specified string.": "Die angegebene Zeichenfolge wurde nicht gefunden.",
|
||||
"Match case": "Gro\xdf-/Kleinschreibung beachten",
|
||||
"Find whole words only": "Nur ganze W\xf6rter suchen",
|
||||
"Find in selection": "In Auswahl suchen",
|
||||
"Insert table": "Tabelle einf\xfcgen",
|
||||
"Table properties": "Tabelleneigenschaften",
|
||||
"Delete table": "Tabelle l\xf6schen",
|
||||
"Cell": "Zelle",
|
||||
"Row": "Zeile",
|
||||
"Column": "Spalte",
|
||||
"Cell properties": "Zelleigenschaften",
|
||||
"Merge cells": "Zellen verbinden",
|
||||
"Split cell": "Zelle aufteilen",
|
||||
"Insert row before": "Neue Zeile davor einf\xfcgen",
|
||||
"Insert row after": "Neue Zeile danach einf\xfcgen",
|
||||
"Delete row": "Zeile l\xf6schen",
|
||||
"Row properties": "Zeileneigenschaften",
|
||||
"Cut row": "Zeile ausschneiden",
|
||||
"Cut column": "Spalte ausschneiden",
|
||||
"Copy row": "Zeile kopieren",
|
||||
"Copy column": "Spalte kopieren",
|
||||
"Paste row before": "Zeile davor einf\xfcgen",
|
||||
"Paste column before": "Spalte davor einf\xfcgen",
|
||||
"Paste row after": "Zeile danach einf\xfcgen",
|
||||
"Paste column after": "Spalte danach einf\xfcgen",
|
||||
"Insert column before": "Neue Spalte davor einf\xfcgen",
|
||||
"Insert column after": "Neue Spalte danach einf\xfcgen",
|
||||
"Delete column": "Spalte l\xf6schen",
|
||||
"Cols": "Spalten",
|
||||
"Rows": "Zeilen",
|
||||
"Width": "Breite",
|
||||
"Height": "H\xf6he",
|
||||
"Cell spacing": "Zellenabstand",
|
||||
"Cell padding": "Zelleninnenabstand",
|
||||
"Row clipboard actions": "Zeilen-Zwischenablage-Aktionen",
|
||||
"Column clipboard actions": "Spalten-Zwischenablage-Aktionen",
|
||||
"Table styles": "Tabellenstil",
|
||||
"Cell styles": "Zellstil",
|
||||
"Column header": "Spaltenkopf",
|
||||
"Row header": "Zeilenkopf",
|
||||
"Table caption": "Tabellenbeschriftung",
|
||||
"Caption": "Beschriftung",
|
||||
"Show caption": "Beschriftung anzeigen",
|
||||
"Left": "Links",
|
||||
"Center": "Zentriert",
|
||||
"Right": "Rechts",
|
||||
"Cell type": "Zelltyp",
|
||||
"Scope": "Bereich",
|
||||
"Alignment": "Ausrichtung",
|
||||
"Horizontal align": "Horizontal ausrichten",
|
||||
"Vertical align": "Vertikal ausrichten",
|
||||
"Top": "Oben",
|
||||
"Middle": "Mitte",
|
||||
"Bottom": "Unten",
|
||||
"Header cell": "Kopfzelle",
|
||||
"Row group": "Zeilengruppe",
|
||||
"Column group": "Spaltengruppe",
|
||||
"Row type": "Zeilentyp",
|
||||
"Header": "Kopfzeile",
|
||||
"Body": "Inhalt",
|
||||
"Footer": "Fu\xdfzeile",
|
||||
"Border color": "Rahmenfarbe",
|
||||
"Solid": "Durchgezogen",
|
||||
"Dotted": "Gepunktet",
|
||||
"Dashed": "Gestrichelt",
|
||||
"Double": "Doppelt",
|
||||
"Groove": "Gekantet",
|
||||
"Ridge": "Eingeritzt",
|
||||
"Inset": "Eingelassen",
|
||||
"Outset": "Hervorstehend",
|
||||
"Hidden": "Unsichtbar",
|
||||
"Insert template...": "Vorlage einf\xfcgen...",
|
||||
"Templates": "Vorlagen",
|
||||
"Template": "Vorlage",
|
||||
"Insert Template": "Vorlage einf\xfcgen",
|
||||
"Text color": "Textfarbe",
|
||||
"Background color": "Hintergrundfarbe",
|
||||
"Custom...": "Benutzerdefiniert...",
|
||||
"Custom color": "Benutzerdefinierte Farbe",
|
||||
"No color": "Keine Farbe",
|
||||
"Remove color": "Farbauswahl aufheben",
|
||||
"Show blocks": "Bl\xf6cke anzeigen",
|
||||
"Show invisible characters": "Unsichtbare Zeichen anzeigen",
|
||||
"Word count": "Anzahl der W\xf6rter",
|
||||
"Count": "Anzahl",
|
||||
"Document": "Dokument",
|
||||
"Selection": "Auswahl",
|
||||
"Words": "W\xf6rter",
|
||||
"Words: {0}": "Wortzahl: {0}",
|
||||
"{0} words": "{0} W\xf6rter",
|
||||
"File": "Datei",
|
||||
"Edit": "Bearbeiten",
|
||||
"Insert": "Einf\xfcgen",
|
||||
"View": "Ansicht",
|
||||
"Format": "Format",
|
||||
"Table": "Tabelle",
|
||||
"Tools": "Werkzeuge",
|
||||
"Powered by {0}": "Betrieben von {0}",
|
||||
"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Rich-Text-Bereich. Dr\xfccken Sie Alt+F9 f\xfcr das Men\xfc. Dr\xfccken Sie Alt+F10 f\xfcr die Symbolleiste. Dr\xfccken Sie Alt+0 f\xfcr Hilfe.",
|
||||
"Image title": "Bildtitel",
|
||||
"Border width": "Rahmenbreite",
|
||||
"Border style": "Rahmenstil",
|
||||
"Error": "Fehler",
|
||||
"Warn": "Warnung",
|
||||
"Valid": "G\xfcltig",
|
||||
"To open the popup, press Shift+Enter": "Dr\xfccken Sie Umschalt+Eingabe, um das Popup-Fenster zu \xf6ffnen.",
|
||||
"Rich Text Area": "Rich-Text-Area",
|
||||
"Rich Text Area. Press ALT-0 for help.": "Rich-Text-Bereich. Dr\xfccken Sie Alt+0 f\xfcr Hilfe.",
|
||||
"System Font": "Betriebssystemschriftart",
|
||||
"Failed to upload image: {0}": "Bild konnte nicht hochgeladen werden: {0}",
|
||||
"Failed to load plugin: {0} from url {1}": "Plugin konnte nicht geladen werden: {0} von URL {1}",
|
||||
"Failed to load plugin url: {0}": "Plugin-URL konnte nicht geladen werden: {0}",
|
||||
"Failed to initialize plugin: {0}": "Plugin konnte nicht initialisiert werden: {0}",
|
||||
"example": "Beispiel",
|
||||
"Search": "Suchen",
|
||||
"All": "Alle",
|
||||
"Currency": "W\xe4hrung",
|
||||
"Text": "Text",
|
||||
"Quotations": "Anf\xfchrungszeichen",
|
||||
"Mathematical": "Mathematisch",
|
||||
"Extended Latin": "Erweitertes Latein",
|
||||
"Symbols": "Symbole",
|
||||
"Arrows": "Pfeile",
|
||||
"User Defined": "Benutzerdefiniert",
|
||||
"dollar sign": "Dollarzeichen",
|
||||
"currency sign": "W\xe4hrungssymbol",
|
||||
"euro-currency sign": "Eurozeichen",
|
||||
"colon sign": "Doppelpunkt",
|
||||
"cruzeiro sign": "Cruzeirozeichen",
|
||||
"french franc sign": "Franczeichen",
|
||||
"lira sign": "Lirezeichen",
|
||||
"mill sign": "Millzeichen",
|
||||
"naira sign": "Nairazeichen",
|
||||
"peseta sign": "Pesetazeichen",
|
||||
"rupee sign": "Rupiezeichen",
|
||||
"won sign": "Wonzeichen",
|
||||
"new sheqel sign": "Schekelzeichen",
|
||||
"dong sign": "Dongzeichen",
|
||||
"kip sign": "Kipzeichen",
|
||||
"tugrik sign": "Tugrikzeichen",
|
||||
"drachma sign": "Drachmezeichen",
|
||||
"german penny symbol": "Pfennigzeichen",
|
||||
"peso sign": "Pesozeichen",
|
||||
"guarani sign": "Guaranizeichen",
|
||||
"austral sign": "Australzeichen",
|
||||
"hryvnia sign": "Hrywnjazeichen",
|
||||
"cedi sign": "Cedizeichen",
|
||||
"livre tournois sign": "Livrezeichen",
|
||||
"spesmilo sign": "Spesmilozeichen",
|
||||
"tenge sign": "Tengezeichen",
|
||||
"indian rupee sign": "Indisches Rupiezeichen",
|
||||
"turkish lira sign": "T\xfcrkisches Lirazeichen",
|
||||
"nordic mark sign": "Zeichen nordische Mark",
|
||||
"manat sign": "Manatzeichen",
|
||||
"ruble sign": "Rubelzeichen",
|
||||
"yen character": "Yenzeichen",
|
||||
"yuan character": "Yuanzeichen",
|
||||
"yuan character, in hong kong and taiwan": "Yuanzeichen in Hongkong und Taiwan",
|
||||
"yen/yuan character variant one": "Yen-/Yuanzeichen Variante 1",
|
||||
"Emojis": "Emojis",
|
||||
"Emojis...": "Emojis...",
|
||||
"Loading emojis...": "Lade Emojis...",
|
||||
"Could not load emojis": "Emojis konnten nicht geladen werden",
|
||||
"People": "Menschen",
|
||||
"Animals and Nature": "Tiere und Natur",
|
||||
"Food and Drink": "Essen und Trinken",
|
||||
"Activity": "Aktivit\xe4t",
|
||||
"Travel and Places": "Reisen und Orte",
|
||||
"Objects": "Objekte",
|
||||
"Flags": "Flaggen",
|
||||
"Characters": "Zeichen",
|
||||
"Characters (no spaces)": "Zeichen (ohne Leerzeichen)",
|
||||
"{0} characters": "{0}\xa0Zeichen",
|
||||
"Error: Form submit field collision.": "Fehler: Kollision der Formularbest\xe4tigungsfelder.",
|
||||
"Error: No form element found.": "Fehler: Kein Formularelement gefunden.",
|
||||
"Color swatch": "Farbpalette",
|
||||
"Color Picker": "Farbwahl",
|
||||
"Invalid hex color code: {0}": "Ung\xfcltiger Hexadezimal-Farbwert: {0}",
|
||||
"Invalid input": "Ung\xfcltige Eingabe",
|
||||
"R": "R",
|
||||
"Red component": "Rotanteil",
|
||||
"G": "G",
|
||||
"Green component": "Gr\xfcnanteil",
|
||||
"B": "B",
|
||||
"Blue component": "Blauanteil",
|
||||
"#": "#",
|
||||
"Hex color code": "Hexadezimal-Farbwert",
|
||||
"Range 0 to 255": "Spanne 0 bis 255",
|
||||
"Turquoise": "T\xfcrkis",
|
||||
"Green": "Gr\xfcn",
|
||||
"Blue": "Blau",
|
||||
"Purple": "Violett",
|
||||
"Navy Blue": "Marineblau",
|
||||
"Dark Turquoise": "Dunkelt\xfcrkis",
|
||||
"Dark Green": "Dunkelgr\xfcn",
|
||||
"Medium Blue": "Mittleres Blau",
|
||||
"Medium Purple": "Mittelviolett",
|
||||
"Midnight Blue": "Mitternachtsblau",
|
||||
"Yellow": "Gelb",
|
||||
"Orange": "Orange",
|
||||
"Red": "Rot",
|
||||
"Light Gray": "Hellgrau",
|
||||
"Gray": "Grau",
|
||||
"Dark Yellow": "Dunkelgelb",
|
||||
"Dark Orange": "Dunkelorange",
|
||||
"Dark Red": "Dunkelrot",
|
||||
"Medium Gray": "Mittelgrau",
|
||||
"Dark Gray": "Dunkelgrau",
|
||||
"Light Green": "Hellgr\xfcn",
|
||||
"Light Yellow": "Hellgelb",
|
||||
"Light Red": "Hellrot",
|
||||
"Light Purple": "Helllila",
|
||||
"Light Blue": "Hellblau",
|
||||
"Dark Purple": "Dunkellila",
|
||||
"Dark Blue": "Dunkelblau",
|
||||
"Black": "Schwarz",
|
||||
"White": "Wei\xdf",
|
||||
"Switch to or from fullscreen mode": "Vollbildmodus umschalten",
|
||||
"Open help dialog": "Hilfe-Dialog \xf6ffnen",
|
||||
"history": "Historie",
|
||||
"styles": "Stile",
|
||||
"formatting": "Formatierung",
|
||||
"alignment": "Ausrichtung",
|
||||
"indentation": "Einr\xfcckungen",
|
||||
"Font": "Schriftart",
|
||||
"Size": "Schriftgr\xf6\xdfe",
|
||||
"More...": "Mehr...",
|
||||
"Select...": "Auswahl...",
|
||||
"Preferences": "Einstellungen",
|
||||
"Yes": "Ja",
|
||||
"No": "Nein",
|
||||
"Keyboard Navigation": "Tastaturnavigation",
|
||||
"Version": "Version",
|
||||
"Code view": "Code Ansicht",
|
||||
"Open popup menu for split buttons": "\xd6ffne Popup Menge um Buttons zu trennen",
|
||||
"List Properties": "Liste Eigenschaften",
|
||||
"List properties...": "Liste Eigenschaften",
|
||||
"Start list at number": "Beginne Liste mit Nummer",
|
||||
"Line height": "Liniendicke",
|
||||
"Dropped file type is not supported": "Hereingezogener Dateityp wird nicht unterst\xfctzt",
|
||||
"Loading...": "Wird geladen...",
|
||||
"ImageProxy HTTP error: Rejected request": "Image Proxy HTTP Fehler: Abgewiesene Anfrage",
|
||||
"ImageProxy HTTP error: Could not find Image Proxy": "Image Proxy HTTP Fehler: Kann Image Proxy nicht finden",
|
||||
"ImageProxy HTTP error: Incorrect Image Proxy URL": "Image Proxy HTTP Fehler: Falsche Image Proxy URL",
|
||||
"ImageProxy HTTP error: Unknown ImageProxy error": "Image Proxy HTTP Fehler: Unbekannter Image Proxy Fehler"
|
||||
});
|
||||
21
assets/vendor/tinymce/license.txt
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
4
assets/vendor/tinymce/models/dom/model.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/advlist/plugin.min.js
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),i=t=>null==t,l=t=>!i(t);var o=tinymce.util.Tools.resolve("tinymce.util.Tools");class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return l(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=t=>e=>l(e)&&t.test(e.nodeName),d=u(/^(OL|UL|DL)$/),g=u(/^(TH|TD)$/),c=t=>i(t)||"default"===t?"":t,h=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,(r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let e=0,n=t.length;e<n;e++){const n=t[e];if(d(r=n)&&!/\btox\-/.test(r.className))return a.some(n);if(s(n,e))break}var r;return a.none()})(e,0,g).exists((e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e))))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)&&t.selection.isEditable()})(t,n)&&t.selection.isEditable())})(t,r.parents))),m=(t,s,r,n,i,l)=>{l.length>1?((t,s,r,n,i,l)=>{t.ui.registry.addSplitButton(s,{tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:t=>{t(o.map(l,(t=>{const e="OL"===i?"num":"bull",s="disc"===t||"decimal"===t?"default":t,r=c(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,(t=>t.toUpperCase())))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}})))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,i,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return a.from(s)})(t);return s.map((t=>e===t)).getOr(!1)},onSetup:h(t,i)})})(t,s,r,n,i,l):((t,s,r,n,i,l)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",onSetup:h(t,i),onAction:()=>t.queryCommandState(n)||""===l?t.execCommand(n):e(t,i,l)})})(t,s,r,n,i,c(l[0]))};t.add("advlist",(t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,circle,square".split(",")})})(t),(t=>{m(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),m(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",((s,r)=>{e(t,"UL",r["list-style-type"])})),t.addCommand("ApplyOrderedListStyle",((s,r)=>{e(t,"OL",r["list-style-type"])}))})(t)):console.error("Please use the Lists plugin together with the Advanced List plugin.")}))}();
|
||||
4
assets/vendor/tinymce/plugins/autolink/plugin.min.js
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),n=t("autolink_pattern"),o=t("link_default_target"),r=t("link_default_protocol"),a=t("allow_unsafe_link_target"),s=("string",e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e));const l=(void 0,e=>undefined===e);const i=e=>!(e=>null==e)(e),c=Object.hasOwnProperty,d=e=>"\ufeff"===e;var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!d(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=n(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]"))return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),c.call(t,n))||"false"===i.getContentEditable(e);var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return $="www.",(b=t).length>=4&&b.substr(0,4)===$?t=r(e)+"://"+t:((e,t,n=0,o)=>{const r=e.indexOf(t,n);return-1!==r&&(!!l(o)||r+t.length<=o)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,$;return null},k=(e,t)=>{const{dom:n,selection:r}=e,{rng:l,url:i}=t,c=r.getBookmark();r.setRng(l);const d="createlink",u={command:d,ui:!1,value:i};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(d,!1,i),e.dispatch("ExecCommand",u);const t=o(e);if(s(t)){const o=r.getNode();n.setAttrib(o,"target",t),"_blank"!==t||a(e)||n.setAttrib(o,"rel","noopener")}}r.moveToBookmark(c),e.nodeChanged()},p=e=>{const t=m(e,-1);i(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);i(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}();
|
||||
4
assets/vendor/tinymce/plugins/code/plugin.min.js
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",(e=>((e=>{e.addCommand("mceCodeEditor",(()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact((()=>{e.setContent(o)})),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)}))})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{})))}();
|
||||
4
assets/vendor/tinymce/plugins/fullscreen/plugin.min.js
vendored
Normal file
90
assets/vendor/tinymce/plugins/help/js/i18n/keynav/de.js
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.de',
|
||||
'<h1>Grundlagen der Tastaturnavigation</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Fokus auf Menüleiste</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Fokus auf Symbolleiste</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Fokus auf Fußzeile</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Fokus auf kontextbezogene Symbolleiste</dt>\n' +
|
||||
' <dd>Windows, Linux oder macOS: STRG+F9\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Die Navigation beginnt beim ersten Benutzeroberflächenelement, welches hervorgehoben ist. Falls sich das erste Element im Pfad der Fußzeile befindet,\n' +
|
||||
' ist es unterstrichen.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Zwischen Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Abschnitt der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie <strong>TAB</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Abschnitt der Benutzeroberfläche zum vorherigen zu wechseln, drücken Sie <strong>UMSCHALT+TAB</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Die Abschnitte der Benutzeroberfläche haben folgende <strong>TAB</strong>-Reihenfolge:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menüleiste</li>\n' +
|
||||
' <li>Einzelne Gruppen der Symbolleiste</li>\n' +
|
||||
' <li>Randleiste</li>\n' +
|
||||
' <li>Elementpfad in der Fußzeile</li>\n' +
|
||||
' <li>Umschaltfläche „Wörter zählen“ in der Fußzeile</li>\n' +
|
||||
' <li>Branding-Link in der Fußzeile</li>\n' +
|
||||
' <li>Editor-Ziehpunkt zur Größenänderung in der Fußzeile</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Falls ein Abschnitt der Benutzeroberflächen nicht vorhanden ist, wird er übersprungen.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn in der Fußzeile die Tastaturnavigation fokussiert ist und keine Randleiste angezeigt wird, wechselt der Fokus durch Drücken von <strong>UMSCHALT+TAB</strong>\n' +
|
||||
' zur ersten Gruppe der Symbolleiste, nicht zur letzten.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Innerhalb von Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Element der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie die entsprechende <strong>Pfeiltaste</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Die Pfeiltasten <strong>Links</strong> und <strong>Rechts</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>wechseln zwischen Menüs in der Menüleiste.</li>\n' +
|
||||
' <li>öffnen das Untermenü eines Menüs.</li>\n' +
|
||||
' <li>wechseln zwischen Schaltflächen in einer Gruppe der Symbolleiste.</li>\n' +
|
||||
' <li>wechseln zwischen Elementen im Elementpfad der Fußzeile.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Die Pfeiltasten <strong>Abwärts</strong> und <strong>Aufwärts</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>wechseln zwischen Menüelementen in einem Menü.</li>\n' +
|
||||
' <li>wechseln zwischen Elementen in einem Popupmenü der Symbolleiste.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Die <strong>Pfeiltasten</strong> rotieren innerhalb des fokussierten Abschnitts der Benutzeroberfläche.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Um ein geöffnetes Menü, ein geöffnetes Untermenü oder ein geöffnetes Popupmenü zu schließen, drücken Sie die <strong>ESC</strong>-Taste.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn sich der aktuelle Fokus ganz oben in einem bestimmten Abschnitt der Benutzeroberfläche befindet, wird durch Drücken der <strong>ESC</strong>-Taste auch\n' +
|
||||
' die Tastaturnavigation beendet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Ein Menüelement oder eine Symbolleistenschaltfläche ausführen</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn das gewünschte Menüelement oder die gewünschte Symbolleistenschaltfläche hervorgehoben ist, drücken Sie <strong>Zurück</strong>, <strong>Eingabe</strong>\n' +
|
||||
' oder die <strong>Leertaste</strong>, um das Element auszuführen.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>In Dialogfeldern ohne Registerkarten navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In Dialogfeldern ohne Registerkarten ist beim Öffnen eines Dialogfelds die erste interaktive Komponente fokussiert.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigieren Sie zwischen den interaktiven Komponenten eines Dialogfelds, indem Sie <strong>TAB</strong> oder <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>In Dialogfeldern mit Registerkarten navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In Dialogfeldern mit Registerkarten ist beim Öffnen eines Dialogfelds die erste Schaltfläche eines Registerkartenmenüs fokussiert.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigieren Sie zwischen den interaktiven Komponenten auf dieser Registerkarte des Dialogfelds, indem Sie <strong>TAB</strong> oder\n' +
|
||||
' <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wechseln Sie zu einer anderen Registerkarte des Dialogfelds, indem Sie den Fokus auf das Registerkartenmenü legen und dann die entsprechende <strong>Pfeiltaste</strong>\n' +
|
||||
' drücken, um durch die verfügbaren Registerkarten zu rotieren.</p>\n');
|
||||
90
assets/vendor/tinymce/plugins/help/js/i18n/keynav/en.js
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.en',
|
||||
'<h1>Begin keyboard navigation</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Focus the Menu bar</dt>\n' +
|
||||
' <dd>Windows or Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Focus the Toolbar</dt>\n' +
|
||||
' <dd>Windows or Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Focus the footer</dt>\n' +
|
||||
' <dd>Windows or Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Focus a contextual toolbar</dt>\n' +
|
||||
' <dd>Windows, Linux or macOS: Ctrl+F9\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Navigation will start at the first UI item, which will be highlighted, or underlined in the case of the first item in\n' +
|
||||
' the Footer element path.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigate between UI sections</h1>\n' +
|
||||
'\n' +
|
||||
'<p>To move from one UI section to the next, press <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>To move from one UI section to the previous, press <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>The <strong>Tab</strong> order of these UI sections is:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menu bar</li>\n' +
|
||||
' <li>Each toolbar group</li>\n' +
|
||||
' <li>Sidebar</li>\n' +
|
||||
' <li>Element path in the footer</li>\n' +
|
||||
' <li>Word count toggle button in the footer</li>\n' +
|
||||
' <li>Branding link in the footer</li>\n' +
|
||||
' <li>Editor resize handle in the footer</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>If a UI section is not present, it is skipped.</p>\n' +
|
||||
'\n' +
|
||||
'<p>If the footer has keyboard navigation focus, and there is no visible sidebar, pressing <strong>Shift+Tab</strong>\n' +
|
||||
' moves focus to the first toolbar group, not the last.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigate within UI sections</h1>\n' +
|
||||
'\n' +
|
||||
'<p>To move from one UI element to the next, press the appropriate <strong>Arrow</strong> key.</p>\n' +
|
||||
'\n' +
|
||||
'<p>The <strong>Left</strong> and <strong>Right</strong> arrow keys</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>move between menus in the menu bar.</li>\n' +
|
||||
' <li>open a sub-menu in a menu.</li>\n' +
|
||||
' <li>move between buttons in a toolbar group.</li>\n' +
|
||||
' <li>move between items in the footer’s element path.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>The <strong>Down</strong> and <strong>Up</strong> arrow keys</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>move between menu items in a menu.</li>\n' +
|
||||
' <li>move between items in a toolbar pop-up menu.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Arrow</strong> keys cycle within the focused UI section.</p>\n' +
|
||||
'\n' +
|
||||
'<p>To close an open menu, an open sub-menu, or an open pop-up menu, press the <strong>Esc</strong> key.</p>\n' +
|
||||
'\n' +
|
||||
'<p>If the current focus is at the ‘top’ of a particular UI section, pressing the <strong>Esc</strong> key also exits\n' +
|
||||
' keyboard navigation entirely.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Execute a menu item or toolbar button</h1>\n' +
|
||||
'\n' +
|
||||
'<p>When the desired menu item or toolbar button is highlighted, press <strong>Return</strong>, <strong>Enter</strong>,\n' +
|
||||
' or the <strong>Space bar</strong> to execute the item.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigate non-tabbed dialogs</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In non-tabbed dialogs, the first interactive component takes focus when the dialog opens.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigate between interactive dialog components by pressing <strong>Tab</strong> or <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigate tabbed dialogs</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In tabbed dialogs, the first button in the tab menu takes focus when the dialog opens.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigate between interactive components of this dialog tab by pressing <strong>Tab</strong> or\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Switch to another dialog tab by giving the tab menu focus and then pressing the appropriate <strong>Arrow</strong>\n' +
|
||||
' key to cycle through the available tabs.</p>\n');
|
||||
4
assets/vendor/tinymce/plugins/help/plugin.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/link/plugin.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/lists/plugin.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/searchreplace/plugin.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/table/plugin.min.js
vendored
Normal file
4
assets/vendor/tinymce/plugins/visualblocks/plugin.min.js
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const s=(t,s,o)=>{t.dom.toggleClass(t.getBody(),"mce-visualblocks"),o.set(!o.get()),((t,s)=>{t.dispatch("VisualBlocks",{state:s})})(t,o.get())},o=("visualblocks_default_state",t=>t.options.get("visualblocks_default_state"));const e=(t,s)=>o=>{o.setActive(s.get());const e=t=>o.setActive(t.state);return t.on("VisualBlocks",e),()=>t.off("VisualBlocks",e)};t.add("visualblocks",((t,l)=>{(t=>{(0,t.options.register)("visualblocks_default_state",{processor:"boolean",default:!1})})(t);const a=(t=>{let s=!1;return{get:()=>s,set:t=>{s=t}}})();((t,o,e)=>{t.addCommand("mceVisualBlocks",(()=>{s(t,0,e)}))})(t,0,a),((t,s)=>{const o=()=>t.execCommand("mceVisualBlocks");t.ui.registry.addToggleButton("visualblocks",{icon:"visualblocks",tooltip:"Show blocks",onAction:o,onSetup:e(t,s)}),t.ui.registry.addToggleMenuItem("visualblocks",{text:"Show blocks",icon:"visualblocks",onAction:o,onSetup:e(t,s)})})(t,a),((t,e,l)=>{t.on("PreviewFormats AfterPreviewFormats",(s=>{l.get()&&t.dom.toggleClass(t.getBody(),"mce-visualblocks","afterpreviewformats"===s.type)})),t.on("init",(()=>{o(t)&&s(t,0,l)}))})(t,0,a)}))}();
|
||||
4
assets/vendor/tinymce/plugins/wordcount/plugin.min.js
vendored
Normal file
1
assets/vendor/tinymce/skins/content/dark/content.min.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
body{background-color:#222f3e;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem}
|
||||
1
assets/vendor/tinymce/skins/content/default/content.min.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}
|
||||
1
assets/vendor/tinymce/skins/ui/oxide-dark/content.min.css
vendored
Normal file
1
assets/vendor/tinymce/skins/ui/oxide-dark/skin.min.css
vendored
Normal file
1
assets/vendor/tinymce/skins/ui/oxide/content.min.css
vendored
Normal file
1
assets/vendor/tinymce/skins/ui/oxide/skin.min.css
vendored
Normal file
4
assets/vendor/tinymce/themes/silver/theme.min.js
vendored
Normal file
4
assets/vendor/tinymce/tinymce.min.js
vendored
Normal file
|
|
@ -12,6 +12,9 @@ services:
|
|||
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
|
||||
APP_KEY: ""
|
||||
TZ: Europe/Berlin
|
||||
volumes:
|
||||
# Hochgeladene Logos/Hintergruende ueberleben so ein Image-Update
|
||||
- uploads:/var/www/html/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
@ -36,3 +39,4 @@ services:
|
|||
|
||||
volumes:
|
||||
db_data:
|
||||
uploads:
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 377 KiB After Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 377 KiB After Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 464 KiB After Width: | Height: | Size: 465 KiB |
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 244 KiB |
BIN
docs/screenshots/mobile-vouchers.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/screenshots/settings-branding.png
Normal file
|
After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 750 KiB After Width: | Height: | Size: 751 KiB |
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 412 KiB |
|
|
@ -7,6 +7,7 @@ require_once __DIR__ . '/config.php';
|
|||
require_once __DIR__ . '/includes/Database.php';
|
||||
require_once __DIR__ . '/includes/Auth.php';
|
||||
require_once __DIR__ . '/includes/Mailer.php';
|
||||
require_once __DIR__ . '/includes/Ui.php';
|
||||
require_once __DIR__ . '/includes/I18n.php';
|
||||
|
||||
$auth = new Auth();
|
||||
|
|
@ -83,17 +84,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= __('reset_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/global.css">
|
||||
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
||||
<?= Ui::head($db) ?>
|
||||
</head>
|
||||
<body class="app-body focus-page">
|
||||
<div class="focus-card card">
|
||||
<?php if ($logoUrl): ?>
|
||||
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
|
||||
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
|
||||
<?php else: ?>
|
||||
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
||||
<?php endif; ?>
|
||||
|
|
@ -118,7 +114,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="auth-links"><a href="login.php" class="back-link"><i class="fas fa-arrow-left"></i> <?= __('reset_back_login') ?></a></div>
|
||||
<div class="auth-links"><a href="login.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('reset_back_login') ?></a></div>
|
||||
</div>
|
||||
<script src="assets/global.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
2
includes/.htaccess
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
|
||||
Require all denied
|
||||
|
|
@ -1,265 +1,339 @@
|
|||
<?php
|
||||
class Mailer {
|
||||
private $db;
|
||||
private $smtpEnabled;
|
||||
private $smtpHost;
|
||||
private $smtpPort;
|
||||
private $smtpUsername;
|
||||
private $smtpPassword;
|
||||
private $smtpEncryption;
|
||||
private $fromEmail;
|
||||
private $fromName;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
$this->loadSettings();
|
||||
}
|
||||
|
||||
private function loadSettings() {
|
||||
$this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1';
|
||||
$this->smtpHost = $this->db->getSetting('smtp_host', '');
|
||||
$this->smtpPort = (int)$this->db->getSetting('smtp_port', '587');
|
||||
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
|
||||
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
|
||||
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
|
||||
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']);
|
||||
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
|
||||
}
|
||||
|
||||
public function sendRaw($to, $subject, $plainBody) {
|
||||
return $this->send($to, $subject, $plainBody, false);
|
||||
}
|
||||
|
||||
public function send($to, $subject, $body, $isHtml = false) {
|
||||
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
|
||||
$attempts = 2;
|
||||
for ($i = 1; $i <= $attempts; $i++) {
|
||||
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
||||
$ok = $this->sendWithPhpMail($to, $subject, $body);
|
||||
} else {
|
||||
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
||||
}
|
||||
if ($ok) {
|
||||
return true;
|
||||
}
|
||||
if ($i < $attempts) {
|
||||
usleep(500000); // 0,5s vor erneutem Versuch
|
||||
}
|
||||
}
|
||||
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private function sendWithPhpMail($to, $subject, $body) {
|
||||
$headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
||||
$headers .= "Reply-To: {$this->fromEmail}\r\n";
|
||||
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||
|
||||
return mail($to, $subject, $body, $headers);
|
||||
}
|
||||
|
||||
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
|
||||
try {
|
||||
// Verbindung aufbauen
|
||||
$socket = $this->connectToSmtp();
|
||||
|
||||
// EHLO
|
||||
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
||||
|
||||
// STARTTLS wenn nötig
|
||||
if ($this->smtpEncryption === 'tls') {
|
||||
$this->smtpCommand($socket, "STARTTLS");
|
||||
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
|
||||
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
||||
}
|
||||
|
||||
// AUTH LOGIN
|
||||
$this->smtpCommand($socket, "AUTH LOGIN");
|
||||
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
|
||||
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
|
||||
|
||||
// MAIL FROM
|
||||
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
|
||||
|
||||
// RCPT TO
|
||||
$this->smtpCommand($socket, "RCPT TO:<{$to}>");
|
||||
|
||||
// DATA
|
||||
$this->smtpCommand($socket, "DATA");
|
||||
|
||||
// Headers
|
||||
$message = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
||||
$message .= "To: {$to}\r\n";
|
||||
$message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
|
||||
$message .= "MIME-Version: 1.0\r\n";
|
||||
|
||||
if ($isHtml) {
|
||||
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
} else {
|
||||
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||
}
|
||||
|
||||
$message .= "\r\n";
|
||||
|
||||
// Body - bei Plain Text Zeilenumbrüche konvertieren
|
||||
if (!$isHtml) {
|
||||
$body = nl2br($body, false); // Für Plain Text
|
||||
$body = str_replace('<br>', "\r\n", $body);
|
||||
}
|
||||
|
||||
$message .= $body;
|
||||
$message .= "\r\n.\r\n";
|
||||
|
||||
fwrite($socket, $message);
|
||||
$response = fgets($socket);
|
||||
|
||||
// QUIT
|
||||
$this->smtpCommand($socket, "QUIT");
|
||||
fclose($socket);
|
||||
|
||||
return strpos($response, '250') === 0;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("SMTP Error: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function connectToSmtp() {
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true
|
||||
]
|
||||
]);
|
||||
|
||||
if ($this->smtpEncryption === 'ssl') {
|
||||
$host = 'ssl://' . $this->smtpHost;
|
||||
} else {
|
||||
$host = $this->smtpHost;
|
||||
}
|
||||
|
||||
$socket = stream_socket_client(
|
||||
$host . ':' . $this->smtpPort,
|
||||
$errno,
|
||||
$errstr,
|
||||
30,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
|
||||
if (!$socket) {
|
||||
throw new Exception("SMTP Connection failed: $errstr ($errno)");
|
||||
}
|
||||
|
||||
// Willkommensnachricht lesen
|
||||
fgets($socket);
|
||||
|
||||
return $socket;
|
||||
}
|
||||
|
||||
private function smtpCommand($socket, $command) {
|
||||
fwrite($socket, $command . "\r\n");
|
||||
$response = fgets($socket);
|
||||
|
||||
// Prüfen auf Fehler (4xx oder 5xx)
|
||||
if (preg_match('/^[45]/', $response)) {
|
||||
throw new Exception("SMTP Error: $response");
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Vordefinierte E-Mail-Templates
|
||||
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
|
||||
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||
$instructionHeader = $this->db->getSetting('instruction_header', '');
|
||||
$instructionText = $this->db->getSetting('instruction_text', '');
|
||||
|
||||
// System-URL aus Einstellungen oder automatisch erkennen
|
||||
$systemUrl = $this->db->getSetting('system_url', '');
|
||||
if (empty($systemUrl)) {
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
||||
}
|
||||
|
||||
// Template aus Datenbank laden
|
||||
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang');
|
||||
$bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nIhr WLAN-Zugangscode lautet:\n\n<strong>{VOUCHER_CODE}</strong>\n\nGültigkeit: 8 Stunden ab Erstellung\nMaximale Geräte: {MAX_USES}\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
|
||||
|
||||
// Anleitung formatieren
|
||||
$instructions = '';
|
||||
if ($instructionText) {
|
||||
$instructions = $instructionHeader . "\n" . $instructionText;
|
||||
}
|
||||
|
||||
// Platzhalter ersetzen
|
||||
$placeholders = [
|
||||
'{VOUCHER_CODE}' => $voucherCode,
|
||||
'{SITE_NAME}' => $siteName,
|
||||
'{MAX_USES}' => $maxUses,
|
||||
'{APP_TITLE}' => $appTitle,
|
||||
'{INSTRUCTIONS}' => $instructions,
|
||||
'{SYSTEM_URL}' => $systemUrl
|
||||
];
|
||||
|
||||
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
||||
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
|
||||
|
||||
// HTML oder Plain Text prüfen
|
||||
$isHtml = strip_tags($body) !== $body;
|
||||
|
||||
return $this->send($to, $subject, $body, $isHtml);
|
||||
}
|
||||
|
||||
public function sendTestEmail($to) {
|
||||
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||
$subject = '[Test] E-Mail-Konfiguration – ' . $appTitle;
|
||||
$body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet.";
|
||||
return $this->send($to, $subject, $body, false);
|
||||
}
|
||||
|
||||
public function sendUserNotification($to, $userName, $changes) {
|
||||
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||
|
||||
// System-URL aus Einstellungen oder automatisch erkennen
|
||||
$systemUrl = $this->db->getSetting('system_url', '');
|
||||
if (empty($systemUrl)) {
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
||||
}
|
||||
|
||||
// Template aus Datenbank laden
|
||||
$subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert');
|
||||
$bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
|
||||
|
||||
// Änderungen formatieren
|
||||
$changesText = '';
|
||||
foreach ($changes as $change) {
|
||||
$changesText .= "• $change\n";
|
||||
}
|
||||
|
||||
// Platzhalter ersetzen
|
||||
$placeholders = [
|
||||
'{USER_NAME}' => $userName,
|
||||
'{CHANGES}' => $changesText,
|
||||
'{APP_TITLE}' => $appTitle,
|
||||
'{SYSTEM_URL}' => $systemUrl
|
||||
];
|
||||
|
||||
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
||||
$body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate);
|
||||
|
||||
// HTML oder Plain Text prüfen
|
||||
$isHtml = strip_tags($body) !== $body;
|
||||
|
||||
return $this->send($to, $subject, $body, $isHtml);
|
||||
}
|
||||
<?php
|
||||
class Mailer {
|
||||
private $db;
|
||||
private $smtpEnabled;
|
||||
private $smtpHost;
|
||||
private $smtpPort;
|
||||
private $smtpUsername;
|
||||
private $smtpPassword;
|
||||
private $smtpEncryption;
|
||||
private $fromEmail;
|
||||
private $fromName;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
$this->loadSettings();
|
||||
}
|
||||
|
||||
private function loadSettings() {
|
||||
$this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1';
|
||||
$this->smtpHost = $this->db->getSetting('smtp_host', '');
|
||||
$this->smtpPort = (int)$this->db->getSetting('smtp_port', '587');
|
||||
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
|
||||
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
|
||||
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
|
||||
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']);
|
||||
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
|
||||
}
|
||||
|
||||
public function sendRaw($to, $subject, $plainBody) {
|
||||
return $this->send($to, $subject, $plainBody, false);
|
||||
}
|
||||
|
||||
public function send($to, $subject, $body, $isHtml = false) {
|
||||
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
|
||||
$attempts = 2;
|
||||
for ($i = 1; $i <= $attempts; $i++) {
|
||||
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
||||
$ok = $this->sendWithPhpMail($to, $subject, $body);
|
||||
} else {
|
||||
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
||||
}
|
||||
if ($ok) {
|
||||
return true;
|
||||
}
|
||||
if ($i < $attempts) {
|
||||
usleep(500000); // 0,5s vor erneutem Versuch
|
||||
}
|
||||
}
|
||||
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private function sendWithPhpMail($to, $subject, $body) {
|
||||
$headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
||||
$headers .= "Reply-To: {$this->fromEmail}\r\n";
|
||||
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||
|
||||
return mail($to, $subject, $body, $headers);
|
||||
}
|
||||
|
||||
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
|
||||
try {
|
||||
// Verbindung aufbauen
|
||||
$socket = $this->connectToSmtp();
|
||||
|
||||
// EHLO
|
||||
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
||||
|
||||
// STARTTLS wenn nötig
|
||||
if ($this->smtpEncryption === 'tls') {
|
||||
$this->smtpCommand($socket, "STARTTLS");
|
||||
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
|
||||
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
||||
}
|
||||
|
||||
// AUTH LOGIN
|
||||
$this->smtpCommand($socket, "AUTH LOGIN");
|
||||
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
|
||||
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
|
||||
|
||||
// MAIL FROM
|
||||
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
|
||||
|
||||
// RCPT TO
|
||||
$this->smtpCommand($socket, "RCPT TO:<{$to}>");
|
||||
|
||||
// DATA
|
||||
$this->smtpCommand($socket, "DATA");
|
||||
|
||||
// Headers
|
||||
$message = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
|
||||
$message .= "To: {$to}\r\n";
|
||||
$message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
|
||||
$message .= "MIME-Version: 1.0\r\n";
|
||||
|
||||
if ($isHtml) {
|
||||
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
} else {
|
||||
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||
}
|
||||
|
||||
$message .= "\r\n";
|
||||
|
||||
// Body - bei Plain Text Zeilenumbrüche konvertieren
|
||||
if (!$isHtml) {
|
||||
$body = nl2br($body, false); // Für Plain Text
|
||||
$body = str_replace('<br>', "\r\n", $body);
|
||||
}
|
||||
|
||||
$message .= $body;
|
||||
$message .= "\r\n.\r\n";
|
||||
|
||||
fwrite($socket, $message);
|
||||
$response = fgets($socket);
|
||||
|
||||
// QUIT
|
||||
$this->smtpCommand($socket, "QUIT");
|
||||
fclose($socket);
|
||||
|
||||
return strpos($response, '250') === 0;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("SMTP Error: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function connectToSmtp() {
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true
|
||||
]
|
||||
]);
|
||||
|
||||
if ($this->smtpEncryption === 'ssl') {
|
||||
$host = 'ssl://' . $this->smtpHost;
|
||||
} else {
|
||||
$host = $this->smtpHost;
|
||||
}
|
||||
|
||||
$socket = stream_socket_client(
|
||||
$host . ':' . $this->smtpPort,
|
||||
$errno,
|
||||
$errstr,
|
||||
30,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
|
||||
if (!$socket) {
|
||||
throw new Exception("SMTP Connection failed: $errstr ($errno)");
|
||||
}
|
||||
|
||||
// Willkommensnachricht lesen
|
||||
fgets($socket);
|
||||
|
||||
return $socket;
|
||||
}
|
||||
|
||||
private function smtpCommand($socket, $command) {
|
||||
fwrite($socket, $command . "\r\n");
|
||||
$response = fgets($socket);
|
||||
|
||||
// Prüfen auf Fehler (4xx oder 5xx)
|
||||
if (preg_match('/^[45]/', $response)) {
|
||||
throw new Exception("SMTP Error: $response");
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Vordefinierte E-Mail-Templates
|
||||
/**
|
||||
* Legt den Nachrichtentext in ein schlichtes, markentreues HTML-Gerüst.
|
||||
* Bewusst Tabellen + Inline-Styles: nur so rendern Outlook & Co. zuverlässig.
|
||||
*/
|
||||
private function brandedHtml(string $title, string $contentHtml, string $footerNote = ''): string
|
||||
{
|
||||
$accent = $this->db->getSetting('brand_gradient_from', '') ?: '#5b5bd6';
|
||||
$accent2 = $this->db->getSetting('brand_gradient_to', '') ?: '#8b5cf6';
|
||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent)) { $accent = '#5b5bd6'; }
|
||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent2)) { $accent2 = '#8b5cf6'; }
|
||||
|
||||
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
|
||||
$year = date('Y');
|
||||
$footer = $footerNote !== '' ? '<div style="margin-top:8px;">' . htmlspecialchars($footerNote, ENT_QUOTES, 'UTF-8') . '</div>' : '';
|
||||
|
||||
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
|
||||
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
|
||||
. '<title>' . $safeTitle . '</title></head>'
|
||||
. '<body style="margin:0;padding:0;background:#f6f7f9;">'
|
||||
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f7f9;padding:28px 12px;">'
|
||||
. '<tr><td align="center">'
|
||||
. '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border:1px solid #e5e8ee;border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;">'
|
||||
. '<tr><td style="background:' . $accent . ';background-image:linear-gradient(135deg,' . $accent . ' 0%,' . $accent2 . ' 100%);padding:22px 26px;">'
|
||||
. '<div style="color:#ffffff;font-size:16px;font-weight:600;letter-spacing:-0.01em;">' . $safeTitle . '</div>'
|
||||
. '</td></tr>'
|
||||
. '<tr><td style="padding:26px;color:#101625;font-size:15px;line-height:1.6;">' . $contentHtml . '</td></tr>'
|
||||
. '<tr><td style="padding:16px 26px;background:#f8f9fb;border-top:1px solid #e5e8ee;color:#6b7280;font-size:12px;">'
|
||||
. '© ' . $year . ' ' . $safeTitle . $footer
|
||||
. '</td></tr>'
|
||||
. '</table></td></tr></table></body></html>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Voucher-Code als hervorgehobene Karte für die E-Mail.
|
||||
*/
|
||||
private function voucherCardHtml(string $code, string $siteName, $maxUses): string
|
||||
{
|
||||
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
||||
. 'style="margin:18px 0;background:#f8f9fb;border:1px solid #e5e8ee;border-radius:12px;">'
|
||||
. '<tr><td align="center" style="padding:22px;">'
|
||||
. '<div style="font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">'
|
||||
. htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
. '<div style="margin:10px 0;font-family:Consolas,Menlo,monospace;font-size:28px;font-weight:700;letter-spacing:.12em;color:#101625;">'
|
||||
. htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
. '<div style="font-size:13px;color:#525c6e;">'
|
||||
. htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' '
|
||||
. htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
. '</td></tr></table>';
|
||||
}
|
||||
|
||||
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
|
||||
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||
$instructionHeader = $this->db->getSetting('instruction_header', '');
|
||||
$instructionText = $this->db->getSetting('instruction_text', '');
|
||||
|
||||
// System-URL aus Einstellungen oder automatisch erkennen
|
||||
$systemUrl = $this->db->getSetting('system_url', '');
|
||||
if (empty($systemUrl)) {
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
||||
}
|
||||
|
||||
// Template aus Datenbank laden
|
||||
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang');
|
||||
$bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\n<strong>Maximale Geräte:</strong> {MAX_USES}<br>\n<strong>Standort:</strong> {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}");
|
||||
|
||||
// Anleitung formatieren
|
||||
$instructions = '';
|
||||
if ($instructionText) {
|
||||
$instructions = $instructionHeader . "\n" . $instructionText;
|
||||
}
|
||||
|
||||
// Platzhalter ersetzen
|
||||
$placeholders = [
|
||||
'{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses),
|
||||
'{VOUCHER_CODE}' => $voucherCode,
|
||||
'{SITE_NAME}' => $siteName,
|
||||
'{MAX_USES}' => $maxUses,
|
||||
'{APP_TITLE}' => $appTitle,
|
||||
'{INSTRUCTIONS}' => $instructions,
|
||||
'{SYSTEM_URL}' => $systemUrl
|
||||
];
|
||||
|
||||
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
||||
|
||||
// Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln,
|
||||
// sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren.
|
||||
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate || strpos($bodyTemplate, '{VOUCHER_CARD}') !== false;
|
||||
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
|
||||
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
|
||||
|
||||
if ($isHtml) {
|
||||
$body = $this->brandedHtml($appTitle, $body);
|
||||
}
|
||||
|
||||
return $this->send($to, $subject, $body, $isHtml);
|
||||
}
|
||||
|
||||
public function sendTestEmail($to) {
|
||||
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||
$subject = '[Test] E-Mail-Konfiguration – ' . $appTitle;
|
||||
$body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet.";
|
||||
return $this->send($to, $subject, $body, false);
|
||||
}
|
||||
|
||||
public function sendUserNotification($to, $userName, $changes) {
|
||||
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
|
||||
|
||||
// System-URL aus Einstellungen oder automatisch erkennen
|
||||
$systemUrl = $this->db->getSetting('system_url', '');
|
||||
if (empty($systemUrl)) {
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||
$systemUrl = $protocol . '://' . $host . $scriptPath;
|
||||
}
|
||||
|
||||
// Template aus Datenbank laden
|
||||
$subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert');
|
||||
$bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
|
||||
|
||||
// Änderungen formatieren
|
||||
$changesText = '';
|
||||
foreach ($changes as $change) {
|
||||
$changesText .= "• $change\n";
|
||||
}
|
||||
|
||||
// Platzhalter ersetzen
|
||||
$placeholders = [
|
||||
'{USER_NAME}' => $userName,
|
||||
'{CHANGES}' => $changesText,
|
||||
'{APP_TITLE}' => $appTitle,
|
||||
'{SYSTEM_URL}' => $systemUrl
|
||||
];
|
||||
|
||||
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
|
||||
|
||||
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate;
|
||||
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
|
||||
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
|
||||
|
||||
if ($isHtml) {
|
||||
$body = $this->brandedHtml($appTitle, $body);
|
||||
}
|
||||
|
||||
return $this->send($to, $subject, $body, $isHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeilenumbrüche aus dem Vorlagentext in HTML übernehmen, ohne bereits
|
||||
* vorhandenes Markup (z. B. aus dem WYSIWYG-Editor) zu zerstören.
|
||||
*/
|
||||
private function textToHtml(string $body): string
|
||||
{
|
||||
if (preg_match('#<(p|div|ul|ol|h[1-6])[\s>]#i', $body)) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
return nl2br($body, false);
|
||||
}
|
||||
}
|
||||
183
includes/Ui.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?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>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
142
includes/Upload.php
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,9 @@
|
|||
* Expects $currentPage (string), $appTitle (string), $auth, $db to be set before include.
|
||||
* Expects I18n to be initialized.
|
||||
*/
|
||||
require_once __DIR__ . '/Ui.php';
|
||||
|
||||
$currentPage = $currentPage ?? '';
|
||||
$faviconUrl = isset($db) ? $db->getSetting('favicon_url', '') : '';
|
||||
$currentUser = isset($auth) ? $auth->getCurrentUser() : null;
|
||||
$lang = I18n::getLanguage();
|
||||
$base = $adminBase ?? ''; // Prefix bis zum admin/-Ordner
|
||||
|
|
@ -43,28 +44,16 @@ foreach ($navGroups as $items) {
|
|||
}
|
||||
}
|
||||
?>
|
||||
<?php if ($faviconUrl): ?>
|
||||
<link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($faviconUrl) ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="<?= $rootBase ?>assets/global.css">
|
||||
<script>
|
||||
(function(){
|
||||
const t = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
<?= Ui::head($db ?? null, $rootBase) ?>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a class="skip-link" href="#main-content"><?= __('a11y_skip') ?></a>
|
||||
<div class="sidebar-overlay" onclick="closeMobileSidebar()"></div>
|
||||
|
||||
<aside class="sidebar" id="adminSidebar">
|
||||
<aside class="sidebar" id="adminSidebar" aria-label="<?= __('nav_administration') ?>">
|
||||
<a href="<?= $rootBase ?>index.php" class="sidebar-brand">
|
||||
<span class="brand-mark"><i class="fas fa-wifi"></i></span>
|
||||
<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>
|
||||
|
|
@ -77,8 +66,9 @@ foreach ($navGroups as $items) {
|
|||
<ul class="sidebar-nav">
|
||||
<?php foreach ($items as [$key, $href, $icon, $labelKey]): ?>
|
||||
<li>
|
||||
<a href="<?= $base . $href ?>" class="<?= $currentPage === $key ? 'active' : '' ?>">
|
||||
<i class="fas <?= $icon ?>"></i> <?= __($labelKey) ?>
|
||||
<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; ?>
|
||||
|
|
@ -94,8 +84,8 @@ foreach ($navGroups as $items) {
|
|||
<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') ?>" style="width:30px;height:30px;font-size:12px;">
|
||||
<i class="fas fa-arrow-right-from-bracket"></i>
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -104,8 +94,8 @@ foreach ($navGroups as $items) {
|
|||
|
||||
<header class="topbar">
|
||||
<div class="header-left">
|
||||
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" title="Menu">
|
||||
<i class="fas fa-bars"></i>
|
||||
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" aria-label="<?= __('a11y_menu') ?>" title="<?= __('a11y_menu') ?>">
|
||||
<i class="fas fa-bars" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="breadcrumb">
|
||||
<span><?= __('nav_administration') ?></span>
|
||||
|
|
@ -114,20 +104,20 @@ foreach ($navGroups as $items) {
|
|||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="lang-switcher">
|
||||
<div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
|
||||
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
||||
<button class="lang-btn <?= $lang === $code ? 'active' : '' ?>"
|
||||
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">
|
||||
<i class="fas fa-moon"></i>
|
||||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
|
||||
<i class="fas fa-moon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<a href="<?= $rootBase ?>index.php" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<i class="fas fa-arrow-left" aria-hidden="true"></i>
|
||||
<span class="hide-mobile"><?= __('nav_back') ?></span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<main class="main-content" id="main-content">
|
||||
|
|
|
|||
101
index.php
|
|
@ -19,6 +19,7 @@ require_once __DIR__ . '/includes/Mailer.php';
|
|||
require_once __DIR__ . '/includes/Notifier.php';
|
||||
require_once __DIR__ . '/includes/Captcha.php';
|
||||
require_once __DIR__ . '/includes/Sms.php';
|
||||
require_once __DIR__ . '/includes/Ui.php';
|
||||
require_once __DIR__ . '/includes/I18n.php';
|
||||
|
||||
$auth = new Auth();
|
||||
|
|
@ -74,7 +75,7 @@ $instructionHeader = $db->getSetting('instruction_header', '');
|
|||
$instructionText = $db->getSetting('instruction_text', '');
|
||||
$publicAccess = $db->getSetting('public_access', 0);
|
||||
$smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1';
|
||||
$printTemplate = $db->getSetting('print_template', '<div style="text-align:center;padding:40px;font-family:sans-serif;"><h1>{APP_TITLE}</h1><h2>WLAN Zugangscode</h2><div style="font-size:48px;font-weight:bold;margin:30px 0;font-family:monospace;letter-spacing:4px;">{VOUCHER_CODE}</div><p><strong>Gültig bis:</strong> {EXPIRY_DATE} {EXPIRY_TIME}</p><p><strong>Standort:</strong> {SITE_NAME}</p><p><strong>Maximale Geräte:</strong> {MAX_USES}</p><hr style="margin:30px 0;"><div style="font-size:14px;text-align:left;">{INSTRUCTIONS}</div></div>');
|
||||
$printTemplate = $db->getSetting('print_template', Ui::defaultPrintTemplate());
|
||||
$defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
|
||||
$defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
|
||||
$maxUsesLimit = max(1, (int)$db->getSetting('max_uses_limit', 10));
|
||||
|
|
@ -271,9 +272,12 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
$instructions = $instructionHeader || $instructionText
|
||||
? htmlspecialchars($instructionHeader) . "\n" . $instructionText
|
||||
: '';
|
||||
// {QR_CODE} wird erst im Browser gefuellt (siehe renderPrintQr()).
|
||||
$qr = '<div class="print-qr" data-code="' . htmlspecialchars(str_replace('-', '', (string)$data['code']), ENT_QUOTES) . '"></div>';
|
||||
|
||||
return str_replace(
|
||||
['{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'],
|
||||
[$data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']],
|
||||
['{QR_CODE}', '{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'],
|
||||
[$qr, $data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']],
|
||||
$template
|
||||
);
|
||||
}
|
||||
|
|
@ -284,17 +288,12 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($appTitle) ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/global.css">
|
||||
<?= Ui::head($db) ?>
|
||||
<?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?>
|
||||
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
|
||||
<?php endif; ?>
|
||||
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
||||
<?php if ($voucherCreated): ?>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<?php if ($voucherCreated || $bulkCreated): ?>
|
||||
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js') ?>
|
||||
<?php endif; ?>
|
||||
<style>
|
||||
/* Seitenspezifisch: Druckansicht der Voucher-Karten.
|
||||
|
|
@ -312,45 +311,46 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
</head>
|
||||
<body class="app-body">
|
||||
|
||||
<a class="skip-link no-print" href="#main-content"><?= __('a11y_skip') ?></a>
|
||||
<header class="app-topbar no-print">
|
||||
<a href="index.php" class="brand">
|
||||
<span class="brand-mark"><i class="fas fa-wifi"></i></span>
|
||||
<span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||||
<span class="brand-name"><?= htmlspecialchars($appTitle) ?></span>
|
||||
</a>
|
||||
<div class="app-actions">
|
||||
<div class="lang-switcher">
|
||||
<div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
|
||||
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
||||
<button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>"
|
||||
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">
|
||||
<i class="fas fa-moon"></i>
|
||||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
|
||||
<i class="fas fa-moon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<?php if ($currentUser): ?>
|
||||
<?php if ($auth->isAdmin()): ?>
|
||||
<a href="admin/" class="btn btn-secondary">
|
||||
<i class="fas fa-sliders"></i> <span class="hide-mobile"><?= __('nav_administration') ?></span>
|
||||
<i class="fas fa-sliders" aria-hidden="true"></i> <span class="hide-mobile"><?= __('nav_administration') ?></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<div class="user-menu">
|
||||
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
|
||||
<div class="user-name hide-mobile"><?= htmlspecialchars($currentUser['name']) ?></div>
|
||||
<a href="logout.php" class="icon-btn" title="<?= __('btn_logout') ?>" style="width:28px;height:28px;font-size:11px;">
|
||||
<i class="fas fa-arrow-right-from-bracket"></i>
|
||||
<a href="logout.php" class="icon-btn" title="<?= __('btn_logout') ?>" aria-label="<?= __('btn_logout') ?>" style="width:28px;height:28px;font-size:11px;">
|
||||
<i class="fas fa-arrow-right-from-bracket" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
<?php elseif ($publicAccess): ?>
|
||||
<a href="login.php" class="btn btn-secondary">
|
||||
<i class="fas fa-right-to-bracket"></i> <?= __('btn_login') ?>
|
||||
<i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('btn_login') ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<main class="container" id="main-content">
|
||||
<?php if ($logoUrl && !$voucherCreated && !$bulkCreated): ?>
|
||||
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
|
||||
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$voucherCreated && !$bulkCreated): ?>
|
||||
|
|
@ -370,17 +370,17 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
</div>
|
||||
|
||||
<div class="voucher-result no-print">
|
||||
<div class="result-label"><i class="fas fa-circle-check"></i> <?= __('voucher_success_title') ?></div>
|
||||
<div class="voucher-code" id="voucherCode" onclick="copyCode()" title="Klicken zum Kopieren">
|
||||
<div class="result-label"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('voucher_success_title') ?></div>
|
||||
<div class="voucher-code" id="voucherCode" onclick="copyCode()" title="<?= __('js_click_to_copy') ?>">
|
||||
<?= htmlspecialchars($voucherCode) ?>
|
||||
</div>
|
||||
<div class="voucher-info">
|
||||
<i class="fas fa-copy"></i> <?= __('voucher_copy_hint') ?>
|
||||
<i class="fas fa-copy" aria-hidden="true"></i> <?= __('voucher_copy_hint') ?>
|
||||
</div>
|
||||
<div class="voucher-meta">
|
||||
<span><i class="fas fa-clock"></i> <?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?></span>
|
||||
<span><i class="fas fa-location-dot"></i> <?= htmlspecialchars($voucherData['site_name']) ?></span>
|
||||
<span><i class="fas fa-mobile-screen"></i> <?= (int)$voucherData['max_uses'] ?> <?= __('label_devices') ?></span>
|
||||
<span><i class="fas fa-clock" aria-hidden="true"></i> <?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?></span>
|
||||
<span><i class="fas fa-location-dot" aria-hidden="true"></i> <?= htmlspecialchars($voucherData['site_name']) ?></span>
|
||||
<span><i class="fas fa-mobile-screen" aria-hidden="true"></i> <?= (int)$voucherData['max_uses'] ?> <?= __('label_devices') ?></span>
|
||||
</div>
|
||||
<div class="ticket-divider"></div>
|
||||
<div class="qr-wrapper no-print">
|
||||
|
|
@ -399,11 +399,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<div class="no-print" style="display:flex;gap:10px;margin-top:18px;">
|
||||
<form method="get" style="flex:1;">
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||
<i class="fas fa-plus"></i> <?= __('btn_new_code') ?>
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('btn_new_code') ?>
|
||||
</button>
|
||||
</form>
|
||||
<button onclick="window.print()" class="btn btn-secondary btn-lg">
|
||||
<i class="fas fa-print"></i> <?= __('voucher_print_btn') ?>
|
||||
<i class="fas fa-print" aria-hidden="true"></i> <?= __('voucher_print_btn') ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -436,8 +436,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<tr>
|
||||
<td><?= $i + 1 ?></td>
|
||||
<td>
|
||||
<code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', 'Kopiert!')"
|
||||
title="Klicken zum Kopieren"><?= htmlspecialchars($bv['code']) ?></code>
|
||||
<code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', '<?= __('js_copied') ?>')"
|
||||
title="<?= __('js_click_to_copy') ?>"><?= htmlspecialchars($bv['code']) ?></code>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($bv['site_name']) ?></td>
|
||||
<td><?= $bv['expiry_date'] ?> <?= $bv['expiry_time'] ?></td>
|
||||
|
|
@ -445,12 +445,12 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="copy-hint"><i class="fas fa-copy"></i> <?= __('voucher_copy_hint') ?></p>
|
||||
<p class="copy-hint"><i class="fas fa-copy" aria-hidden="true"></i> <?= __('voucher_copy_hint') ?></p>
|
||||
</div>
|
||||
|
||||
<div class="no-print" style="display:flex;gap:10px;margin-top:20px;">
|
||||
<button onclick="window.print()" class="btn btn-primary btn-lg" style="flex:1;">
|
||||
<i class="fas fa-print"></i> <?= __('bulk_print_all') ?>
|
||||
<i class="fas fa-print" aria-hidden="true"></i> <?= __('bulk_print_all') ?>
|
||||
</button>
|
||||
<form method="get" style="flex:1;">
|
||||
<button type="submit" class="btn btn-secondary btn-lg btn-block"><?= __('btn_new_code') ?></button>
|
||||
|
|
@ -459,7 +459,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
|
||||
<?php elseif (empty($sites)): ?>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><i class="fas fa-wifi"></i></div>
|
||||
<div class="empty-icon"><i class="fas fa-wifi" aria-hidden="true"></i></div>
|
||||
<p><?= __('voucher_no_sites') ?><br>
|
||||
<?php if ($auth->isAdmin()): ?>
|
||||
<a href="admin/" style="color:var(--accent);"><?= __('voucher_no_sites_admin') ?></a>
|
||||
|
|
@ -543,7 +543,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<div class="email-checkbox-wrapper">
|
||||
<input type="checkbox" id="send_email" name="send_email" onchange="toggleEmailField()">
|
||||
<label for="send_email">
|
||||
<i class="fas fa-envelope" style="color:var(--accent);"></i> <?= __('voucher_email_send') ?>
|
||||
<i class="fas fa-envelope" style="color:var(--accent);" aria-hidden="true"></i> <?= __('voucher_email_send') ?>
|
||||
</label>
|
||||
</div>
|
||||
<div class="email-input-wrapper" id="email_field">
|
||||
|
|
@ -560,7 +560,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<div class="email-option">
|
||||
<div class="email-checkbox-wrapper">
|
||||
<input type="checkbox" id="send_sms" name="send_sms" onchange="document.getElementById('sms_field').style.display=this.checked?'block':'none'">
|
||||
<label for="send_sms"><i class="fas fa-comment-sms" style="color:var(--accent);"></i> Code per SMS versenden</label>
|
||||
<label for="send_sms"><i class="fas fa-comment-sms" style="color:var(--accent);" aria-hidden="true"></i> Code per SMS versenden</label>
|
||||
</div>
|
||||
<div id="sms_field" style="display:none;margin-top:12px;">
|
||||
<label for="recipient_phone">Telefonnummer (international, z.B. +49170…)</label>
|
||||
|
|
@ -570,7 +570,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<?php endif; ?>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block" id="submitBtn">
|
||||
<i class="fas fa-ticket"></i> <?= __('voucher_create_btn') ?>
|
||||
<i class="fas fa-ticket" aria-hidden="true"></i> <?= __('voucher_create_btn') ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -633,11 +633,28 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
<div id="toast-container" role="status" aria-live="polite"></div>
|
||||
<script src="assets/global.js"></script>
|
||||
<script>
|
||||
<?php if ($voucherCreated || $bulkCreated): ?>
|
||||
// QR-Codes der Druckkarten erzeugen (im Browser, ohne externen Dienst)
|
||||
function renderPrintQr() {
|
||||
document.querySelectorAll('.print-qr').forEach(function (el) {
|
||||
if (el.dataset.done) return;
|
||||
el.dataset.done = '1';
|
||||
new QRCode(el, {
|
||||
text: el.dataset.code || '',
|
||||
width: 132, height: 132,
|
||||
colorDark: '#101625', colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', renderPrintQr);
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($voucherCreated): ?>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
new QRCode(document.getElementById('qrcode'), {
|
||||
|
|
@ -649,7 +666,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
});
|
||||
|
||||
function copyCode() {
|
||||
copyToClipboard('<?= addslashes($voucherCode) ?>', 'Code kopiert!');
|
||||
copyToClipboard('<?= addslashes($voucherCode) ?>', '<?= __('js_code_copied') ?>');
|
||||
}
|
||||
<?php endif; ?>
|
||||
|
||||
|
|
@ -715,7 +732,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
const btn = document.getElementById('submitBtn');
|
||||
if (!btn || btn.disabled) { e.preventDefault(); return; }
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> <?= __('voucher_creating') ?>';
|
||||
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> <?= __('voucher_creating') ?>';
|
||||
});
|
||||
|
||||
document.getElementById('bulkForm')?.addEventListener('submit', function(e) {
|
||||
|
|
@ -723,7 +740,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
if (!btn || btn.disabled) { e.preventDefault(); return; }
|
||||
const count = document.getElementById('bulk_count').value;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> <?= addslashes(str_replace('{count}', "' + count + '", __('bulk_creating'))) ?>';
|
||||
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> <?= addslashes(str_replace('{count}', "' + count + '", __('bulk_creating'))) ?>';
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
|
|
|||
761
install.php
|
|
@ -1,383 +1,380 @@
|
|||
<?php
|
||||
session_start();
|
||||
|
||||
// Prüfen ob bereits installiert.
|
||||
// Wenn eine config.php existiert, darf der Installer NICHT mehr ohne Weiteres
|
||||
// erreichbar sein – sonst koennte jeder die Konfiguration ueberschreiben und
|
||||
// einen neuen Admin anlegen. Reinstall ist nur fuer angemeldete Admins erlaubt.
|
||||
if (file_exists(__DIR__ . '/config.php')) {
|
||||
if (!isset($_GET['reinstall'])) {
|
||||
die('System bereits installiert. Eine Neuinstallation ist nur fuer angemeldete Administratoren ueber install.php?reinstall=1 moeglich.');
|
||||
}
|
||||
|
||||
// Reinstall angefordert -> Admin-Authentifizierung erzwingen
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/includes/Database.php';
|
||||
require_once __DIR__ . '/includes/Auth.php';
|
||||
try {
|
||||
$reinstallAuth = new Auth();
|
||||
if (!$reinstallAuth->isAdmin()) {
|
||||
die('Neuinstallation nicht erlaubt: Bitte zuerst als Administrator <a href="login.php">anmelden</a>.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
die('Neuinstallation nicht moeglich (Konfigurationsfehler).');
|
||||
}
|
||||
}
|
||||
|
||||
$step = isset($_POST['step']) ? (int)$_POST['step'] : 1;
|
||||
$errors = [];
|
||||
$success = false;
|
||||
|
||||
// Step 1: Datenbankverbindung testen
|
||||
if ($step === 2 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$db_host = $_POST['db_host'] ?? '';
|
||||
$db_name = $_POST['db_name'] ?? '';
|
||||
$db_user = $_POST['db_user'] ?? '';
|
||||
$db_pass = $_POST['db_pass'] ?? '';
|
||||
|
||||
try {
|
||||
$pdo = new PDO("mysql:host=$db_host;charset=utf8mb4", $db_user, $db_pass);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
// Datenbank erstellen falls nicht vorhanden
|
||||
$pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
$pdo->exec("USE `$db_name`");
|
||||
|
||||
// Tabellen erstellen
|
||||
$sql = file_get_contents(__DIR__ . '/database.sql');
|
||||
$pdo->exec($sql);
|
||||
|
||||
$_SESSION['install_db'] = [
|
||||
'host' => $db_host,
|
||||
'name' => $db_name,
|
||||
'user' => $db_user,
|
||||
'pass' => $db_pass
|
||||
];
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$errors[] = "Datenbankfehler: " . $e->getMessage();
|
||||
$step = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Admin-Account erstellen
|
||||
if ($step === 3 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$admin_email = filter_var($_POST['admin_email'] ?? '', FILTER_VALIDATE_EMAIL);
|
||||
$admin_name = $_POST['admin_name'] ?? '';
|
||||
$admin_password = $_POST['admin_password'] ?? '';
|
||||
$admin_password_confirm = $_POST['admin_password_confirm'] ?? '';
|
||||
|
||||
if (!$admin_email) {
|
||||
$errors[] = "Ungültige E-Mail-Adresse";
|
||||
$step = 2;
|
||||
} elseif (strlen($admin_password) < 8) {
|
||||
$errors[] = "Passwort muss mindestens 8 Zeichen lang sein";
|
||||
$step = 2;
|
||||
} elseif ($admin_password !== $admin_password_confirm) {
|
||||
$errors[] = "Passwörter stimmen nicht überein";
|
||||
$step = 2;
|
||||
} else {
|
||||
$_SESSION['install_admin'] = [
|
||||
'email' => $admin_email,
|
||||
'name' => $admin_name,
|
||||
'password' => password_hash($admin_password, PASSWORD_DEFAULT)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Allgemeine Einstellungen
|
||||
if ($step === 4 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$app_title = $_POST['app_title'] ?? 'UniFi Voucher System';
|
||||
$logo_url = $_POST['logo_url'] ?? '';
|
||||
$instruction_header = $_POST['instruction_header'] ?? '';
|
||||
$instruction_text = $_POST['instruction_text'] ?? '';
|
||||
$public_access = isset($_POST['public_access']) ? 1 : 0;
|
||||
|
||||
// Microsoft 365 OAuth (optional)
|
||||
$m365_client_id = $_POST['m365_client_id'] ?? '';
|
||||
$m365_client_secret = $_POST['m365_client_secret'] ?? '';
|
||||
$m365_tenant_id = $_POST['m365_tenant_id'] ?? '';
|
||||
|
||||
$_SESSION['install_settings'] = [
|
||||
'app_title' => $app_title,
|
||||
'logo_url' => $logo_url,
|
||||
'instruction_header' => $instruction_header,
|
||||
'instruction_text' => $instruction_text,
|
||||
'public_access' => $public_access,
|
||||
'm365_client_id' => $m365_client_id,
|
||||
'm365_client_secret' => $m365_client_secret,
|
||||
'm365_tenant_id' => $m365_tenant_id
|
||||
];
|
||||
}
|
||||
|
||||
// Step 4: Installation abschließen
|
||||
if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$db = $_SESSION['install_db'];
|
||||
$admin = $_SESSION['install_admin'];
|
||||
$settings = $_SESSION['install_settings'];
|
||||
|
||||
$pdo = new PDO("mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4", $db['user'], $db['pass']);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
// Admin-User erstellen
|
||||
$stmt = $pdo->prepare("INSERT INTO users (email, name, password_hash, is_admin, is_active) VALUES (?, ?, ?, 1, 1)");
|
||||
$stmt->execute([$admin['email'], $admin['name'], $admin['password']]);
|
||||
|
||||
// Settings speichern
|
||||
$settingsData = [
|
||||
'app_title' => $settings['app_title'],
|
||||
'logo_url' => $settings['logo_url'],
|
||||
'instruction_header' => $settings['instruction_header'],
|
||||
'instruction_text' => $settings['instruction_text'],
|
||||
'public_access' => $settings['public_access'],
|
||||
'm365_client_id' => $settings['m365_client_id'],
|
||||
'm365_client_secret' => $settings['m365_client_secret'],
|
||||
'm365_tenant_id' => $settings['m365_tenant_id']
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)");
|
||||
foreach ($settingsData as $key => $value) {
|
||||
$stmt->execute([$key, $value]);
|
||||
}
|
||||
|
||||
// config.php erstellen
|
||||
$configContent = "<?php\n";
|
||||
$configContent .= "// UniFi Voucher Management System - Configuration\n\n";
|
||||
$configContent .= "define('DB_HOST', '{$db['host']}');\n";
|
||||
$configContent .= "define('DB_NAME', '{$db['name']}');\n";
|
||||
$configContent .= "define('DB_USER', '{$db['user']}');\n";
|
||||
$configContent .= "define('DB_PASS', '" . addslashes($db['pass']) . "');\n\n";
|
||||
$configContent .= "// Anwendungs-Schluessel fuer Verschluesselung-at-rest (z.B. UniFi-Passwoerter)\n";
|
||||
$configContent .= "// NICHT aendern, sonst koennen bestehende verschluesselte Werte nicht mehr gelesen werden.\n";
|
||||
$configContent .= "define('APP_KEY', '" . base64_encode(random_bytes(32)) . "');\n\n";
|
||||
$configContent .= "// Sitzungs-Einstellungen\n";
|
||||
$configContent .= "define('SESSION_LIFETIME', 3600); // 1 Stunde\n\n";
|
||||
$configContent .= "// Zeitzone\n";
|
||||
$configContent .= "date_default_timezone_set('Europe/Berlin');\n";
|
||||
|
||||
file_put_contents(__DIR__ . '/config.php', $configContent);
|
||||
|
||||
// .htaccess erstellen (ohne Rewrite Rules die Probleme machen)
|
||||
$htaccess = "# UniFi Voucher System\n\n";
|
||||
$htaccess .= "# Security\n";
|
||||
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|\\.md)$\">\n";
|
||||
$htaccess .= " Order Allow,Deny\n";
|
||||
$htaccess .= " Deny from all\n";
|
||||
$htaccess .= "</FilesMatch>\n\n";
|
||||
$htaccess .= "DirectoryIndex index.php\n";
|
||||
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
|
||||
|
||||
$success = true;
|
||||
|
||||
// Session-Daten löschen
|
||||
unset($_SESSION['install_db'], $_SESSION['install_admin'], $_SESSION['install_settings']);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$errors[] = "Fehler bei der Installation: " . $e->getMessage();
|
||||
$step = 4;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UniFi Voucher System - Installation</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/global.css">
|
||||
<style>
|
||||
/* Installer-spezifisch: Fortschrittsanzeige und Abschnitte */
|
||||
.install-head { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; }
|
||||
.progress { position: relative; display: flex; justify-content: space-between; margin: 26px 0 28px; }
|
||||
.progress::before {
|
||||
content: ''; position: absolute; top: 14px; left: 14px; right: 14px;
|
||||
height: 2px; background: var(--border-color); z-index: 0;
|
||||
}
|
||||
.progress-step {
|
||||
position: relative; z-index: 1;
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||
color: var(--text-muted); font-size: 12px; font-weight: 650;
|
||||
}
|
||||
.progress-step.active { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--ring); }
|
||||
.progress-step.completed { background: var(--success); border-color: var(--success); color: #fff; }
|
||||
.section {
|
||||
padding: 18px;
|
||||
margin-bottom: 20px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.section h3 { font-size: 14px; margin-bottom: 14px; }
|
||||
.error, .success {
|
||||
padding: 12px 15px; margin-bottom: 20px;
|
||||
border-radius: var(--r-md); font-size: 13.5px;
|
||||
border: 1px solid var(--danger-border); background: var(--danger-soft); color: var(--danger);
|
||||
}
|
||||
.success { border-color: var(--success-border); background: var(--success-soft); color: var(--success); }
|
||||
form > h3 { font-size: 15px; margin-bottom: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-body focus-page">
|
||||
<div class="focus-card card">
|
||||
<div class="install-head">
|
||||
<span class="focus-icon"><i class="fas fa-rocket"></i></span>
|
||||
<div>
|
||||
<h1 style="font-size:20px;">UniFi Voucher System</h1>
|
||||
<p class="sub">Installation in fünf Schritten</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress">
|
||||
<div class="progress-step <?= $step >= 1 ? 'completed' : '' ?>">1</div>
|
||||
<div class="progress-step <?= $step >= 2 ? 'completed' : ($step === 1 ? 'active' : '') ?>">2</div>
|
||||
<div class="progress-step <?= $step >= 3 ? 'completed' : ($step === 2 ? 'active' : '') ?>">3</div>
|
||||
<div class="progress-step <?= $step >= 4 ? 'completed' : ($step === 3 ? 'active' : '') ?>">4</div>
|
||||
<div class="progress-step <?= $step >= 5 ? 'active' : '' ?>">5</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div class="error">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<div><?= htmlspecialchars($error) ?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="success">
|
||||
<strong>✓ Installation erfolgreich abgeschlossen!</strong><br>
|
||||
Sie können sich jetzt mit Ihren Admin-Zugangsdaten anmelden.
|
||||
</div>
|
||||
<a href="index.php" class="btn btn-primary btn-lg btn-block">Zum Login</a>
|
||||
<?php elseif ($step === 1): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="step" value="2">
|
||||
<h3>Schritt 1: Datenbank-Konfiguration</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Datenbank-Host</label>
|
||||
<input type="text" name="db_host" value="localhost" required>
|
||||
<div class="help-text">Meist "localhost"</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Datenbankname</label>
|
||||
<input type="text" name="db_name" value="unifi_voucher" required>
|
||||
<div class="help-text">Name der Datenbank (wird erstellt falls nicht vorhanden)</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Datenbank-Benutzer</label>
|
||||
<input type="text" name="db_user" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Datenbank-Passwort</label>
|
||||
<input type="password" name="db_pass">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right"></i></button>
|
||||
</form>
|
||||
|
||||
<?php elseif ($step === 2): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="step" value="3">
|
||||
<h3>Schritt 2: Administrator-Account</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" name="admin_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" name="admin_email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Passwort</label>
|
||||
<input type="password" name="admin_password" required minlength="8">
|
||||
<div class="help-text">Mindestens 8 Zeichen</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Passwort bestätigen</label>
|
||||
<input type="password" name="admin_password_confirm" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right"></i></button>
|
||||
</form>
|
||||
|
||||
<?php elseif ($step === 3): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="step" value="4">
|
||||
<h3>Schritt 3: Allgemeine Einstellungen</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Anwendungs-Titel</label>
|
||||
<input type="text" name="app_title" value="UniFi Voucher System" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Logo-URL (optional)</label>
|
||||
<input type="text" name="logo_url" placeholder="https://example.com/logo.png">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Anleitung Überschrift</label>
|
||||
<input type="text" name="instruction_header" value="So verwenden Sie Ihren Code">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Anleitung Text</label>
|
||||
<textarea name="instruction_text">Verbinden Sie sich mit dem WLAN und geben Sie den Code auf der Anmeldeseite ein.</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" name="public_access" id="public_access">
|
||||
<label for="public_access" style="margin: 0;">Öffentlicher Zugriff auf Code-Erstellung</label>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Microsoft 365 Login (Optional)</h3>
|
||||
<div class="form-group">
|
||||
<label>Client ID</label>
|
||||
<input type="text" name="m365_client_id">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Client Secret</label>
|
||||
<input type="password" name="m365_client_secret">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tenant ID</label>
|
||||
<input type="text" name="m365_tenant_id">
|
||||
</div>
|
||||
<div class="help-text">Leer lassen, wenn M365-Login nicht verwendet werden soll</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right"></i></button>
|
||||
</form>
|
||||
|
||||
<?php elseif ($step === 4): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="step" value="5">
|
||||
<h3>Schritt 4: Installation abschließen</h3>
|
||||
|
||||
<p style="margin-bottom: 20px; color: #666;">
|
||||
Klicken Sie auf "Installation abschließen", um die Einrichtung zu beenden.
|
||||
Die Datenbank und alle notwendigen Dateien werden erstellt.
|
||||
</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Installation abschließen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
<?php
|
||||
session_start();
|
||||
|
||||
// Prüfen ob bereits installiert.
|
||||
// Wenn eine config.php existiert, darf der Installer NICHT mehr ohne Weiteres
|
||||
// erreichbar sein – sonst koennte jeder die Konfiguration ueberschreiben und
|
||||
// einen neuen Admin anlegen. Reinstall ist nur fuer angemeldete Admins erlaubt.
|
||||
if (file_exists(__DIR__ . '/config.php')) {
|
||||
if (!isset($_GET['reinstall'])) {
|
||||
die('System bereits installiert. Eine Neuinstallation ist nur fuer angemeldete Administratoren ueber install.php?reinstall=1 moeglich.');
|
||||
}
|
||||
|
||||
// Reinstall angefordert -> Admin-Authentifizierung erzwingen
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/includes/Database.php';
|
||||
require_once __DIR__ . '/includes/Auth.php';
|
||||
try {
|
||||
$reinstallAuth = new Auth();
|
||||
if (!$reinstallAuth->isAdmin()) {
|
||||
die('Neuinstallation nicht erlaubt: Bitte zuerst als Administrator <a href="login.php">anmelden</a>.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
die('Neuinstallation nicht moeglich (Konfigurationsfehler).');
|
||||
}
|
||||
}
|
||||
|
||||
$step = isset($_POST['step']) ? (int)$_POST['step'] : 1;
|
||||
$errors = [];
|
||||
$success = false;
|
||||
|
||||
// Step 1: Datenbankverbindung testen
|
||||
if ($step === 2 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$db_host = $_POST['db_host'] ?? '';
|
||||
$db_name = $_POST['db_name'] ?? '';
|
||||
$db_user = $_POST['db_user'] ?? '';
|
||||
$db_pass = $_POST['db_pass'] ?? '';
|
||||
|
||||
try {
|
||||
$pdo = new PDO("mysql:host=$db_host;charset=utf8mb4", $db_user, $db_pass);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
// Datenbank erstellen falls nicht vorhanden
|
||||
$pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
$pdo->exec("USE `$db_name`");
|
||||
|
||||
// Tabellen erstellen
|
||||
$sql = file_get_contents(__DIR__ . '/database.sql');
|
||||
$pdo->exec($sql);
|
||||
|
||||
$_SESSION['install_db'] = [
|
||||
'host' => $db_host,
|
||||
'name' => $db_name,
|
||||
'user' => $db_user,
|
||||
'pass' => $db_pass
|
||||
];
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$errors[] = "Datenbankfehler: " . $e->getMessage();
|
||||
$step = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Admin-Account erstellen
|
||||
if ($step === 3 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$admin_email = filter_var($_POST['admin_email'] ?? '', FILTER_VALIDATE_EMAIL);
|
||||
$admin_name = $_POST['admin_name'] ?? '';
|
||||
$admin_password = $_POST['admin_password'] ?? '';
|
||||
$admin_password_confirm = $_POST['admin_password_confirm'] ?? '';
|
||||
|
||||
if (!$admin_email) {
|
||||
$errors[] = "Ungültige E-Mail-Adresse";
|
||||
$step = 2;
|
||||
} elseif (strlen($admin_password) < 8) {
|
||||
$errors[] = "Passwort muss mindestens 8 Zeichen lang sein";
|
||||
$step = 2;
|
||||
} elseif ($admin_password !== $admin_password_confirm) {
|
||||
$errors[] = "Passwörter stimmen nicht überein";
|
||||
$step = 2;
|
||||
} else {
|
||||
$_SESSION['install_admin'] = [
|
||||
'email' => $admin_email,
|
||||
'name' => $admin_name,
|
||||
'password' => password_hash($admin_password, PASSWORD_DEFAULT)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Allgemeine Einstellungen
|
||||
if ($step === 4 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$app_title = $_POST['app_title'] ?? 'UniFi Voucher System';
|
||||
$logo_url = $_POST['logo_url'] ?? '';
|
||||
$instruction_header = $_POST['instruction_header'] ?? '';
|
||||
$instruction_text = $_POST['instruction_text'] ?? '';
|
||||
$public_access = isset($_POST['public_access']) ? 1 : 0;
|
||||
|
||||
// Microsoft 365 OAuth (optional)
|
||||
$m365_client_id = $_POST['m365_client_id'] ?? '';
|
||||
$m365_client_secret = $_POST['m365_client_secret'] ?? '';
|
||||
$m365_tenant_id = $_POST['m365_tenant_id'] ?? '';
|
||||
|
||||
$_SESSION['install_settings'] = [
|
||||
'app_title' => $app_title,
|
||||
'logo_url' => $logo_url,
|
||||
'instruction_header' => $instruction_header,
|
||||
'instruction_text' => $instruction_text,
|
||||
'public_access' => $public_access,
|
||||
'm365_client_id' => $m365_client_id,
|
||||
'm365_client_secret' => $m365_client_secret,
|
||||
'm365_tenant_id' => $m365_tenant_id
|
||||
];
|
||||
}
|
||||
|
||||
// Step 4: Installation abschließen
|
||||
if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$db = $_SESSION['install_db'];
|
||||
$admin = $_SESSION['install_admin'];
|
||||
$settings = $_SESSION['install_settings'];
|
||||
|
||||
$pdo = new PDO("mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4", $db['user'], $db['pass']);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
// Admin-User erstellen
|
||||
$stmt = $pdo->prepare("INSERT INTO users (email, name, password_hash, is_admin, is_active) VALUES (?, ?, ?, 1, 1)");
|
||||
$stmt->execute([$admin['email'], $admin['name'], $admin['password']]);
|
||||
|
||||
// Settings speichern
|
||||
$settingsData = [
|
||||
'app_title' => $settings['app_title'],
|
||||
'logo_url' => $settings['logo_url'],
|
||||
'instruction_header' => $settings['instruction_header'],
|
||||
'instruction_text' => $settings['instruction_text'],
|
||||
'public_access' => $settings['public_access'],
|
||||
'm365_client_id' => $settings['m365_client_id'],
|
||||
'm365_client_secret' => $settings['m365_client_secret'],
|
||||
'm365_tenant_id' => $settings['m365_tenant_id']
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)");
|
||||
foreach ($settingsData as $key => $value) {
|
||||
$stmt->execute([$key, $value]);
|
||||
}
|
||||
|
||||
// config.php erstellen
|
||||
$configContent = "<?php\n";
|
||||
$configContent .= "// UniFi Voucher Management System - Configuration\n\n";
|
||||
$configContent .= "define('DB_HOST', '{$db['host']}');\n";
|
||||
$configContent .= "define('DB_NAME', '{$db['name']}');\n";
|
||||
$configContent .= "define('DB_USER', '{$db['user']}');\n";
|
||||
$configContent .= "define('DB_PASS', '" . addslashes($db['pass']) . "');\n\n";
|
||||
$configContent .= "// Anwendungs-Schluessel fuer Verschluesselung-at-rest (z.B. UniFi-Passwoerter)\n";
|
||||
$configContent .= "// NICHT aendern, sonst koennen bestehende verschluesselte Werte nicht mehr gelesen werden.\n";
|
||||
$configContent .= "define('APP_KEY', '" . base64_encode(random_bytes(32)) . "');\n\n";
|
||||
$configContent .= "// Sitzungs-Einstellungen\n";
|
||||
$configContent .= "define('SESSION_LIFETIME', 3600); // 1 Stunde\n\n";
|
||||
$configContent .= "// Zeitzone\n";
|
||||
$configContent .= "date_default_timezone_set('Europe/Berlin');\n";
|
||||
|
||||
file_put_contents(__DIR__ . '/config.php', $configContent);
|
||||
|
||||
// .htaccess erstellen (ohne Rewrite Rules die Probleme machen)
|
||||
$htaccess = "# UniFi Voucher System\n\n";
|
||||
$htaccess .= "# Security\n";
|
||||
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|\\.md)$\">\n";
|
||||
$htaccess .= " Order Allow,Deny\n";
|
||||
$htaccess .= " Deny from all\n";
|
||||
$htaccess .= "</FilesMatch>\n\n";
|
||||
$htaccess .= "DirectoryIndex index.php\n";
|
||||
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
|
||||
|
||||
$success = true;
|
||||
|
||||
// Session-Daten löschen
|
||||
unset($_SESSION['install_db'], $_SESSION['install_admin'], $_SESSION['install_settings']);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$errors[] = "Fehler bei der Installation: " . $e->getMessage();
|
||||
$step = 4;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UniFi Voucher System - Installation</title>
|
||||
<?php require_once __DIR__ . '/includes/Ui.php'; ?>
|
||||
<?= Ui::head(null) ?>
|
||||
<style>
|
||||
/* Installer-spezifisch: Fortschrittsanzeige und Abschnitte */
|
||||
.install-head { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; }
|
||||
.progress { position: relative; display: flex; justify-content: space-between; margin: 26px 0 28px; }
|
||||
.progress::before {
|
||||
content: ''; position: absolute; top: 14px; left: 14px; right: 14px;
|
||||
height: 2px; background: var(--border-color); z-index: 0;
|
||||
}
|
||||
.progress-step {
|
||||
position: relative; z-index: 1;
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||
color: var(--text-muted); font-size: 12px; font-weight: 650;
|
||||
}
|
||||
.progress-step.active { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--ring); }
|
||||
.progress-step.completed { background: var(--success); border-color: var(--success); color: #fff; }
|
||||
.section {
|
||||
padding: 18px;
|
||||
margin-bottom: 20px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.section h3 { font-size: 14px; margin-bottom: 14px; }
|
||||
.error, .success {
|
||||
padding: 12px 15px; margin-bottom: 20px;
|
||||
border-radius: var(--r-md); font-size: 13.5px;
|
||||
border: 1px solid var(--danger-border); background: var(--danger-soft); color: var(--danger);
|
||||
}
|
||||
.success { border-color: var(--success-border); background: var(--success-soft); color: var(--success); }
|
||||
form > h3 { font-size: 15px; margin-bottom: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-body focus-page">
|
||||
<div class="focus-card card">
|
||||
<div class="install-head">
|
||||
<span class="focus-icon"><i class="fas fa-rocket" aria-hidden="true"></i></span>
|
||||
<div>
|
||||
<h1 style="font-size:20px;">UniFi Voucher System</h1>
|
||||
<p class="sub">Installation in fünf Schritten</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress">
|
||||
<div class="progress-step <?= $step >= 1 ? 'completed' : '' ?>">1</div>
|
||||
<div class="progress-step <?= $step >= 2 ? 'completed' : ($step === 1 ? 'active' : '') ?>">2</div>
|
||||
<div class="progress-step <?= $step >= 3 ? 'completed' : ($step === 2 ? 'active' : '') ?>">3</div>
|
||||
<div class="progress-step <?= $step >= 4 ? 'completed' : ($step === 3 ? 'active' : '') ?>">4</div>
|
||||
<div class="progress-step <?= $step >= 5 ? 'active' : '' ?>">5</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div class="error">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<div><?= htmlspecialchars($error) ?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="success">
|
||||
<strong>✓ Installation erfolgreich abgeschlossen!</strong><br>
|
||||
Sie können sich jetzt mit Ihren Admin-Zugangsdaten anmelden.
|
||||
</div>
|
||||
<a href="index.php" class="btn btn-primary btn-lg btn-block">Zum Login</a>
|
||||
<?php elseif ($step === 1): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="step" value="2">
|
||||
<h3>Schritt 1: Datenbank-Konfiguration</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Datenbank-Host</label>
|
||||
<input type="text" name="db_host" value="localhost" required>
|
||||
<div class="help-text">Meist "localhost"</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Datenbankname</label>
|
||||
<input type="text" name="db_name" value="unifi_voucher" required>
|
||||
<div class="help-text">Name der Datenbank (wird erstellt falls nicht vorhanden)</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Datenbank-Benutzer</label>
|
||||
<input type="text" name="db_user" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Datenbank-Passwort</label>
|
||||
<input type="password" name="db_pass">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
|
||||
<?php elseif ($step === 2): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="step" value="3">
|
||||
<h3>Schritt 2: Administrator-Account</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" name="admin_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" name="admin_email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Passwort</label>
|
||||
<input type="password" name="admin_password" required minlength="8">
|
||||
<div class="help-text">Mindestens 8 Zeichen</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Passwort bestätigen</label>
|
||||
<input type="password" name="admin_password_confirm" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
|
||||
<?php elseif ($step === 3): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="step" value="4">
|
||||
<h3>Schritt 3: Allgemeine Einstellungen</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Anwendungs-Titel</label>
|
||||
<input type="text" name="app_title" value="UniFi Voucher System" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Logo-URL (optional)</label>
|
||||
<input type="text" name="logo_url" placeholder="https://example.com/logo.png">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Anleitung Überschrift</label>
|
||||
<input type="text" name="instruction_header" value="So verwenden Sie Ihren Code">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Anleitung Text</label>
|
||||
<textarea name="instruction_text">Verbinden Sie sich mit dem WLAN und geben Sie den Code auf der Anmeldeseite ein.</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" name="public_access" id="public_access">
|
||||
<label for="public_access" style="margin: 0;">Öffentlicher Zugriff auf Code-Erstellung</label>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Microsoft 365 Login (Optional)</h3>
|
||||
<div class="form-group">
|
||||
<label>Client ID</label>
|
||||
<input type="text" name="m365_client_id">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Client Secret</label>
|
||||
<input type="password" name="m365_client_secret">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tenant ID</label>
|
||||
<input type="text" name="m365_tenant_id">
|
||||
</div>
|
||||
<div class="help-text">Leer lassen, wenn M365-Login nicht verwendet werden soll</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Weiter <i class="fas fa-arrow-right" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
|
||||
<?php elseif ($step === 4): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="step" value="5">
|
||||
<h3>Schritt 4: Installation abschließen</h3>
|
||||
|
||||
<p style="margin-bottom: 20px; color: #666;">
|
||||
Klicken Sie auf "Installation abschließen", um die Einrichtung zu beenden.
|
||||
Die Datenbank und alle notwendigen Dateien werden erstellt.
|
||||
</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Installation abschließen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
234
lang/de.php
|
|
@ -220,6 +220,28 @@ return [
|
|||
// Settings
|
||||
'settings_title' => 'Einstellungen',
|
||||
'settings_subtitle' => 'System-Konfiguration und Personalisierung',
|
||||
'settings_tab_branding' => 'Design',
|
||||
'settings_branding_intro' => 'Farben und Formen der gesamten Oberfläche – Frontend wie Administration.',
|
||||
'settings_brand_accent' => 'Akzentfarbe (hell)',
|
||||
'settings_brand_accent_hint' => 'Buttons, aktive Navigation, Links. Abgeleitete Töne werden automatisch berechnet.',
|
||||
'settings_brand_accent_dark' => 'Akzentfarbe (Dark Mode)',
|
||||
'settings_brand_accent_dark_hint' => 'Im Dark Mode meist eine hellere Variante der Grundfarbe.',
|
||||
'settings_brand_gradient_from'=> 'Markenverlauf: Start',
|
||||
'settings_brand_gradient_to' => 'Markenverlauf: Ende',
|
||||
'settings_brand_gradient_hint'=> 'Für Logo-Kachel, Avatare und die Voucher-Karte.',
|
||||
'settings_brand_radius' => 'Eckenradius',
|
||||
'settings_brand_radius_sharp' => 'Kantig',
|
||||
'settings_brand_radius_default'=> 'Standard',
|
||||
'settings_brand_radius_round' => 'Rund',
|
||||
'settings_brand_preview' => 'Vorschau',
|
||||
'settings_brand_link' => 'Beispiel-Link',
|
||||
'settings_image_url_placeholder' => 'https://… oder Datei hochladen',
|
||||
'settings_image_remove' => 'Bild entfernen',
|
||||
'settings_upload_hint' => 'PNG, JPG, WEBP, GIF oder SVG – maximal 3 MB.',
|
||||
'upload_error_generic' => 'Die Datei konnte nicht hochgeladen werden.',
|
||||
'upload_error_size' => 'Die Datei ist zu groß (maximal 3 MB).',
|
||||
'upload_error_type' => 'Dieser Dateityp wird nicht unterstützt.',
|
||||
'upload_error_dir' => 'Der Ordner uploads/ ist nicht beschreibbar.',
|
||||
'settings_tab_login' => 'Login-Seite',
|
||||
'settings_login_intro' => 'Aussehen und Texte der Anmeldeseite. Leere Felder verwenden die Standardwerte.',
|
||||
'settings_login_panel' => 'Linke Bildspalte (Split-Screen) anzeigen',
|
||||
|
|
@ -239,6 +261,214 @@ return [
|
|||
'settings_login_overlay' => 'Abdunklung des Bildes (%)',
|
||||
'settings_login_overlay_hint' => '0–90 %. Höhere Werte machen den Text auf hellen Bildern besser lesbar.',
|
||||
'settings_login_preview' => 'Vorschau öffnen',
|
||||
'api_title' => 'API-Schlüssel',
|
||||
'api_subtitle' => 'Zugänge für externe Systeme – mit Scope und Rate-Limit.',
|
||||
'api_new_key' => 'Neuer Schlüssel',
|
||||
'api_new_key_hint' => 'Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.',
|
||||
'api_create_title' => 'Neuen API-Schlüssel erstellen',
|
||||
'api_label_name' => 'Bezeichnung',
|
||||
'api_name_placeholder' => 'z.B. Buchungssystem, Terminal Foyer',
|
||||
'api_label_scope' => 'Berechtigung',
|
||||
'api_scope_write' => 'Lesen + Erstellen',
|
||||
'api_scope_read' => 'Nur Lesen',
|
||||
'api_scope_write_short' => 'Lesen+Erstellen',
|
||||
'api_scope_read_short' => 'nur Lesen',
|
||||
'api_label_limit' => 'Limit (Anfr./min)',
|
||||
'api_limit_title' => '0 = unbegrenzt',
|
||||
'api_existing' => 'Vorhandene Schlüssel',
|
||||
'api_none' => 'Noch keine API-Schlüssel angelegt.',
|
||||
'api_col_prefix' => 'Präfix',
|
||||
'api_col_scope' => 'Scope',
|
||||
'api_col_limit' => 'Limit',
|
||||
'api_col_last_used' => 'Zuletzt genutzt',
|
||||
'api_col_created_by' => 'Erstellt von',
|
||||
'api_state_active' => 'aktiv',
|
||||
'api_state_blocked' => 'gesperrt',
|
||||
'api_action_block' => 'Sperren',
|
||||
'api_action_unblock' => 'Aktivieren',
|
||||
'api_delete_confirm' => 'Schlüssel löschen?',
|
||||
'api_usage' => 'Verwendung',
|
||||
'api_usage_hint' => 'Authentifizierung per Header',
|
||||
'api_openapi' => 'OpenAPI-Spezifikation (Import in Postman/Swagger):',
|
||||
'api_created' => 'API-Schlüssel erstellt.',
|
||||
'api_deleted' => 'API-Schlüssel gelöscht.',
|
||||
'api_toggled' => 'Status geändert.',
|
||||
'api_name_required' => 'Bitte eine Bezeichnung angeben.',
|
||||
'api_created_once' => 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!',
|
||||
'api_status_updated' => 'Status aktualisiert.',
|
||||
'int_title' => 'Integration & Wartung',
|
||||
'int_subtitle' => 'SSO, Webhooks, SMS-Versand und Aufbewahrungsfristen.',
|
||||
'int_security' => 'Sicherheitsrichtlinie',
|
||||
'int_security_hint' => 'Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.',
|
||||
'int_enforce_2fa' => '2FA für Administratoren verpflichtend',
|
||||
'int_daily_limit' => 'Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)',
|
||||
'int_session_driver' => 'Session-Speicher',
|
||||
'int_session_php' => 'PHP-Standard (Dateien)',
|
||||
'int_session_db' => 'Datenbank (ermöglicht „überall abmelden")',
|
||||
'int_captcha' => 'Captcha im öffentlichen Modus',
|
||||
'int_captcha_off' => 'Aus',
|
||||
'int_captcha_math' => 'Rechenaufgabe (ohne externen Dienst)',
|
||||
'int_secret_set' => ' (gesetzt)',
|
||||
'int_secret_placeholder' => '••••••• (leer = unverändert)',
|
||||
'int_proxy' => 'Reverse-Proxy',
|
||||
'int_proxy_hint' => 'IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus',
|
||||
'int_proxy_hint2' => 'für Rate-Limit & Audit verwendet.',
|
||||
'int_webhook' => 'Webhook-Benachrichtigungen',
|
||||
'int_webhook_hint' => 'Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.',
|
||||
'int_webhook_active' => 'Webhook aktiv',
|
||||
'int_webhook_url' => 'Webhook-URL',
|
||||
'int_webhook_test' => 'Test senden',
|
||||
'int_sms' => 'SMS-Versand (Twilio)',
|
||||
'int_sms_hint' => 'Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.',
|
||||
'int_sms_active' => 'SMS-Versand aktiv',
|
||||
'int_sms_from' => 'Absender (From)',
|
||||
'int_sso' => 'Single Sign-On (OpenID Connect)',
|
||||
'int_sso_hint' => 'Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI:',
|
||||
'int_sso_active' => 'OIDC-Login aktiv',
|
||||
'int_sso_button' => 'Button-Text',
|
||||
'int_cleanup' => 'Datenhaltung & Cleanup (DSGVO)',
|
||||
'int_cleanup_hint' => 'Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per',
|
||||
'int_cleanup_hint2' => '(täglich empfohlen).',
|
||||
'int_cleanup_last' => 'Letzter Lauf:',
|
||||
'int_cleanup_expired' => 'Abgelaufene Voucher',
|
||||
'int_cleanup_audit' => 'Audit-Log',
|
||||
'int_cleanup_logins' => 'Login-Versuche',
|
||||
'int_saved' => 'Einstellungen gespeichert.',
|
||||
'int_webhook_sent' => 'Test-Webhook gesendet.',
|
||||
'int_webhook_failed' => 'Test-Webhook fehlgeschlagen.',
|
||||
'int_webhook_test_sent' => 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).',
|
||||
'sec_token_invalid' => 'Ungültiges Sicherheits-Token',
|
||||
'sec_setup_expired' => 'Setup abgelaufen, bitte erneut starten.',
|
||||
'sec_code_invalid' => 'Code ungültig. Bitte erneut versuchen.',
|
||||
'sec_enabled' => 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!',
|
||||
'sec_sessions_closed' => 'Alle anderen Sitzungen wurden beendet.',
|
||||
'sec_codes_new' => 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.',
|
||||
'sec_disabled' => 'Zwei-Faktor-Authentifizierung wurde deaktiviert.',
|
||||
'backup_choose_file' => 'Bitte eine Backup-Datei auswählen.',
|
||||
'backup_invalid_file' => 'Ungültige oder fremde Backup-Datei.',
|
||||
'sec_title' => 'Zwei-Faktor-Authentifizierung',
|
||||
'sec_account' => 'Konto:',
|
||||
'sec_required_hint' => 'Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.',
|
||||
'sec_recovery_codes' => 'Recovery-Codes',
|
||||
'sec_recovery_hint' => 'Bewahren Sie diese sicher auf. Jeder Code funktioniert einmal, falls Sie keinen Zugriff auf Ihre App haben.',
|
||||
'sec_unavailable' => 'Nicht verfügbar',
|
||||
'sec_m365_hint' => 'Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.',
|
||||
'sec_active' => 'Aktiv',
|
||||
'sec_inactive' => 'Inaktiv',
|
||||
'sec_active_hint' => 'Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.',
|
||||
'sec_codes_left' => 'Verbleibende Recovery-Codes:',
|
||||
'sec_regen_codes' => 'Recovery-Codes neu erzeugen',
|
||||
'sec_disable_confirm' => '2FA wirklich deaktivieren?',
|
||||
'sec_disable' => '2FA deaktivieren',
|
||||
'sec_step_1' => 'Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)',
|
||||
'sec_step_2' => 'QR-Code scannen oder Secret manuell eingeben',
|
||||
'sec_step_3' => 'Den angezeigten 6-stelligen Code unten eingeben',
|
||||
'sec_code_label' => '6-stelliger Code',
|
||||
'sec_enable' => '2FA aktivieren',
|
||||
'sec_sessions' => 'Aktive Sitzungen:',
|
||||
'sec_logout_others_confirm' => 'Alle anderen Sitzungen abmelden?',
|
||||
'sec_logout_others' => 'Auf allen anderen Geräten abmelden',
|
||||
'import_title' => 'Voucher-Import',
|
||||
'import_subtitle' => 'Mehrere Vouchers auf einmal aus einer CSV-Liste erstellen.',
|
||||
'import_card_title' => 'Mehrere Voucher erstellen',
|
||||
'import_format_hint' => 'Eine Zeile pro Voucher:',
|
||||
'import_format_hint2' => '– MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:',
|
||||
'import_site' => 'Standort',
|
||||
'import_file' => 'CSV-Datei (optional)',
|
||||
'import_paste' => '… oder direkt einfügen',
|
||||
'import_confirm' => 'Import jetzt starten?',
|
||||
'import_submit' => 'Importieren',
|
||||
'import_result' => 'Ergebnis',
|
||||
'import_col_code' => 'Code / Fehler',
|
||||
'import_created' => '{count} Voucher erstellt.',
|
||||
'backup_title' => 'Backup & Restore',
|
||||
'backup_subtitle' => 'Konfiguration und Daten sichern oder wiederherstellen.',
|
||||
'backup_export' => 'Export',
|
||||
'backup_export_hint' => 'Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem',
|
||||
'backup_export_hint2' => 'dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.',
|
||||
'backup_export_btn' => 'Konfiguration exportieren',
|
||||
'backup_import' => 'Import / Restore',
|
||||
'backup_import_hint' => 'Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.',
|
||||
'backup_opt_settings' => 'Einstellungen',
|
||||
'backup_opt_sites' => 'Sites',
|
||||
'backup_opt_templates' => 'Voucher-Profile',
|
||||
'backup_import_confirm' => 'Import jetzt durchführen?',
|
||||
'backup_imported' => 'Import abgeschlossen: {settings} Einstellungen, {sites} Sites, {templates} Profile.',
|
||||
'rep_title' => 'Reporting',
|
||||
'rep_subtitle' => 'Auswertungen nach Zeitraum, Site und Benutzer.',
|
||||
'rep_period' => 'Zeitraum',
|
||||
'rep_days' => 'Tage',
|
||||
'rep_csv_daily' => 'CSV (täglich)',
|
||||
'rep_csv_site' => 'CSV (pro Site)',
|
||||
'rep_csv_user' => 'CSV (pro Nutzer)',
|
||||
'rep_print' => 'Drucken/PDF',
|
||||
'rep_total' => 'Vouchers gesamt',
|
||||
'rep_in_period' => 'In {days} Tagen erstellt',
|
||||
'rep_chart_title' => 'Erstellte Voucher ({days} Tage)',
|
||||
'rep_per_site' => 'Pro Site',
|
||||
'rep_top_users' => 'Top-Nutzer',
|
||||
'rep_col_created' => 'Voucher erstellt',
|
||||
'rep_no_data' => 'Keine Daten',
|
||||
'label_total' => 'Gesamt',
|
||||
'label_user' => 'Benutzer',
|
||||
'js_confirm_delete_voucher' => 'Voucher wirklich löschen?',
|
||||
'js_confirm_delete_user' => 'Benutzer wirklich löschen?',
|
||||
'js_confirm_delete_site' => 'Möchten Sie diese Site wirklich löschen?',
|
||||
'js_confirm_delete_template' => 'Profil wirklich löschen?',
|
||||
'js_confirm_delete_token' => 'Token wirklich löschen?',
|
||||
'js_error' => 'Fehler',
|
||||
'js_enter_email' => 'Bitte E-Mail eingeben.',
|
||||
'js_running' => 'Läuft...',
|
||||
'js_run_now' => 'Jetzt ausführen',
|
||||
'js_copied' => 'Kopiert!',
|
||||
'js_code_copied' => 'Code kopiert!',
|
||||
'js_click_to_copy' => 'Klicken zum Kopieren',
|
||||
'settings_defaults_hint' => 'Diese Werte werden als Vorgabe im Voucher-Formular verwendet.',
|
||||
'settings_cron_what' => 'Was macht der Cron-Job?',
|
||||
'settings_smtp_from_name' => 'Absender Name',
|
||||
'settings_tpl_voucher_mail' => 'Voucher E-Mail',
|
||||
'settings_tpl_user_notify' => 'Benutzer-Benachrichtigung',
|
||||
'settings_editor_hint' => 'Der Editor (TinyMCE, GPL-Variante) wird lokal aus',
|
||||
'settings_editor_hint2' => 'geladen – es werden keine externen Dienste aufgerufen.',
|
||||
'settings_print_template' => 'HTML Template für Voucher-Druck',
|
||||
'settings_leave_empty' => 'Leer = nicht ändern',
|
||||
'sites_id_hint' => 'Zu finden in der UniFi Controller URL',
|
||||
'sites_pw_unchanged' => 'Leer lassen = nicht ändern',
|
||||
'templates_minutes_hint' => '480 = 8 Stunden',
|
||||
'templates_desc_placeholder' => 'Kurze Beschreibung für Ihr Team',
|
||||
'audit_all_users' => 'Alle Benutzer',
|
||||
'label_expires_at' => 'Gültig bis:',
|
||||
'label_site_colon' => 'Standort:',
|
||||
'audit_action_voucher_created' => 'Voucher erstellt',
|
||||
'audit_action_voucher_bulk' => 'Bulk Voucher',
|
||||
'audit_action_user_login' => 'Login',
|
||||
'audit_action_user_logout' => 'Logout',
|
||||
'audit_action_user_created' => 'Benutzer erstellt',
|
||||
'audit_action_user_updated' => 'Benutzer geändert',
|
||||
'audit_action_user_deleted' => 'Benutzer gelöscht',
|
||||
'audit_action_site_added' => 'Site hinzugefügt',
|
||||
'audit_action_site_updated' => 'Site geändert',
|
||||
'audit_action_site_deleted' => 'Site gelöscht',
|
||||
'audit_action_settings_saved' => 'Einstellungen gespeichert',
|
||||
'audit_action_password_reset' => 'Passwort-Reset',
|
||||
'audit_action_template_created' => 'Profil erstellt',
|
||||
'audit_action_template_updated' => 'Profil geändert',
|
||||
'audit_action_template_deleted' => 'Profil gelöscht',
|
||||
'audit_page_info' => 'Seite {page} von {pages} ({total} Einträge)',
|
||||
'a11y_skip' => 'Zum Inhalt springen',
|
||||
'a11y_theme' => 'Darstellung umschalten (hell/dunkel)',
|
||||
'a11y_menu' => 'Navigation öffnen',
|
||||
'a11y_language' => 'Sprache',
|
||||
'a11y_notifications' => 'Benachrichtigungen',
|
||||
'audit_system_anon' => 'System/Anonym',
|
||||
'vouchers_resend' => 'Per E-Mail senden',
|
||||
'label_minutes_short' => 'Min.',
|
||||
'js_copy' => 'Kopieren',
|
||||
'settings_placeholders' => 'Platzhalter:',
|
||||
'settings_card_hint' => '{VOUCHER_CARD} rendert den Code als hervorgehobene Karte im Marken-Layout.',
|
||||
'settings_qr_hint' => '{QR_CODE} fügt einen QR-Code mit dem Voucher-Code ein.',
|
||||
'print_valid_until' => 'Gültig bis',
|
||||
'print_devices' => 'Geräte',
|
||||
'settings_tab_general' => 'Allgemein',
|
||||
'settings_tab_defaults' => 'Voucher-Standards',
|
||||
'settings_tab_cron' => 'Cron-Sync',
|
||||
|
|
@ -249,8 +479,8 @@ return [
|
|||
'settings_tab_password' => 'Passwort',
|
||||
'settings_saved' => 'Einstellungen erfolgreich gespeichert!',
|
||||
'settings_app_title' => 'Anwendungs-Titel *',
|
||||
'settings_logo_url' => 'Logo-URL',
|
||||
'settings_favicon_url' => 'Favicon-URL',
|
||||
'settings_logo_url' => 'Logo',
|
||||
'settings_favicon_url' => 'Favicon',
|
||||
'settings_favicon_hint' => 'Icon im Browser-Tab (.ico, .png, .svg)',
|
||||
'settings_instr_header' => 'Anleitung - Überschrift',
|
||||
'settings_instr_text' => 'Anleitung - Text',
|
||||
|
|
|
|||
234
lang/en.php
|
|
@ -220,6 +220,28 @@ return [
|
|||
// Settings
|
||||
'settings_title' => 'Settings',
|
||||
'settings_subtitle' => 'System configuration and customization',
|
||||
'settings_tab_branding' => 'Design',
|
||||
'settings_branding_intro' => 'Colours and shapes for the whole interface – front end and administration.',
|
||||
'settings_brand_accent' => 'Accent colour (light)',
|
||||
'settings_brand_accent_hint' => 'Buttons, active navigation, links. Derived shades are calculated automatically.',
|
||||
'settings_brand_accent_dark' => 'Accent colour (dark mode)',
|
||||
'settings_brand_accent_dark_hint' => 'Usually a lighter variant of the base colour for dark mode.',
|
||||
'settings_brand_gradient_from'=> 'Brand gradient: start',
|
||||
'settings_brand_gradient_to' => 'Brand gradient: end',
|
||||
'settings_brand_gradient_hint'=> 'Used for the logo tile, avatars and the voucher card.',
|
||||
'settings_brand_radius' => 'Corner radius',
|
||||
'settings_brand_radius_sharp' => 'Sharp',
|
||||
'settings_brand_radius_default'=> 'Default',
|
||||
'settings_brand_radius_round' => 'Round',
|
||||
'settings_brand_preview' => 'Preview',
|
||||
'settings_brand_link' => 'Example link',
|
||||
'settings_image_url_placeholder' => 'https://… or upload a file',
|
||||
'settings_image_remove' => 'Remove image',
|
||||
'settings_upload_hint' => 'PNG, JPG, WEBP, GIF or SVG – 3 MB maximum.',
|
||||
'upload_error_generic' => 'The file could not be uploaded.',
|
||||
'upload_error_size' => 'The file is too large (3 MB maximum).',
|
||||
'upload_error_type' => 'This file type is not supported.',
|
||||
'upload_error_dir' => 'The uploads/ directory is not writable.',
|
||||
'settings_tab_login' => 'Login page',
|
||||
'settings_login_intro' => 'Appearance and wording of the sign-in page. Empty fields fall back to the defaults.',
|
||||
'settings_login_panel' => 'Show left image column (split screen)',
|
||||
|
|
@ -239,6 +261,214 @@ return [
|
|||
'settings_login_overlay' => 'Image dimming (%)',
|
||||
'settings_login_overlay_hint' => '0–90%. Higher values keep text readable on bright images.',
|
||||
'settings_login_preview' => 'Open preview',
|
||||
'api_title' => 'API keys',
|
||||
'api_subtitle' => 'Access for external systems – with scope and rate limit.',
|
||||
'api_new_key' => 'New key',
|
||||
'api_new_key_hint' => 'Copy it now – for security reasons it will not be shown again.',
|
||||
'api_create_title' => 'Create a new API key',
|
||||
'api_label_name' => 'Label',
|
||||
'api_name_placeholder' => 'e.g. booking system, lobby terminal',
|
||||
'api_label_scope' => 'Permission',
|
||||
'api_scope_write' => 'Read + create',
|
||||
'api_scope_read' => 'Read only',
|
||||
'api_scope_write_short' => 'Read+create',
|
||||
'api_scope_read_short' => 'read only',
|
||||
'api_label_limit' => 'Limit (req./min)',
|
||||
'api_limit_title' => '0 = unlimited',
|
||||
'api_existing' => 'Existing keys',
|
||||
'api_none' => 'No API keys created yet.',
|
||||
'api_col_prefix' => 'Prefix',
|
||||
'api_col_scope' => 'Scope',
|
||||
'api_col_limit' => 'Limit',
|
||||
'api_col_last_used' => 'Last used',
|
||||
'api_col_created_by' => 'Created by',
|
||||
'api_state_active' => 'active',
|
||||
'api_state_blocked' => 'blocked',
|
||||
'api_action_block' => 'Block',
|
||||
'api_action_unblock' => 'Activate',
|
||||
'api_delete_confirm' => 'Delete key?',
|
||||
'api_usage' => 'Usage',
|
||||
'api_usage_hint' => 'Authenticate using header',
|
||||
'api_openapi' => 'OpenAPI specification (import into Postman/Swagger):',
|
||||
'api_created' => 'API key created.',
|
||||
'api_deleted' => 'API key deleted.',
|
||||
'api_toggled' => 'Status changed.',
|
||||
'api_name_required' => 'Please enter a label.',
|
||||
'api_created_once' => 'API key created. Copy it NOW – it is shown only once!',
|
||||
'api_status_updated' => 'Status updated.',
|
||||
'int_title' => 'Integration & maintenance',
|
||||
'int_subtitle' => 'SSO, webhooks, SMS delivery and retention periods.',
|
||||
'int_security' => 'Security policy',
|
||||
'int_security_hint' => 'Enforces two-factor authentication for all administrator accounts (local accounts). Admins without 2FA are sent to the setup on their next action.',
|
||||
'int_enforce_2fa' => 'Two-factor authentication mandatory for administrators',
|
||||
'int_daily_limit' => 'Daily voucher limit per non-admin user (0 = unlimited)',
|
||||
'int_session_driver' => 'Session storage',
|
||||
'int_session_php' => 'PHP default (files)',
|
||||
'int_session_db' => 'Database (enables "sign out everywhere")',
|
||||
'int_captcha' => 'Captcha in public mode',
|
||||
'int_captcha_off' => 'Off',
|
||||
'int_captcha_math' => 'Arithmetic question (no external service)',
|
||||
'int_secret_set' => ' (set)',
|
||||
'int_secret_placeholder' => '••••••• (empty = unchanged)',
|
||||
'int_proxy' => 'Reverse proxy',
|
||||
'int_proxy_hint' => 'IP addresses of trusted proxies (comma separated). Only then is the real client IP taken from',
|
||||
'int_proxy_hint2' => 'for rate limiting and the audit log.',
|
||||
'int_webhook' => 'Webhook notifications',
|
||||
'int_webhook_hint' => 'Slack, Microsoft Teams or generic JSON webhook URL. Triggered when a voucher is created.',
|
||||
'int_webhook_active' => 'Webhook active',
|
||||
'int_webhook_url' => 'Webhook URL',
|
||||
'int_webhook_test' => 'Send test',
|
||||
'int_sms' => 'SMS delivery (Twilio)',
|
||||
'int_sms_hint' => 'Optionally send voucher codes by SMS. Requires a Twilio account.',
|
||||
'int_sms_active' => 'SMS delivery active',
|
||||
'int_sms_from' => 'Sender (from)',
|
||||
'int_sso' => 'Single sign-on (OpenID Connect)',
|
||||
'int_sso_hint' => 'Generic OIDC provider (e.g. Keycloak, Authentik, Google, Auth0). Redirect URI:',
|
||||
'int_sso_active' => 'OIDC login active',
|
||||
'int_sso_button' => 'Button label',
|
||||
'int_cleanup' => 'Data retention & cleanup (GDPR)',
|
||||
'int_cleanup_hint' => 'Retention periods in days (0 = disabled). Executed via',
|
||||
'int_cleanup_hint2' => '(daily recommended).',
|
||||
'int_cleanup_last' => 'Last run:',
|
||||
'int_cleanup_expired' => 'Expired vouchers',
|
||||
'int_cleanup_audit' => 'Audit log',
|
||||
'int_cleanup_logins' => 'Login attempts',
|
||||
'int_saved' => 'Settings saved.',
|
||||
'int_webhook_sent' => 'Test webhook sent.',
|
||||
'int_webhook_failed' => 'Test webhook failed.',
|
||||
'int_webhook_test_sent' => 'Test notification sent (provided the webhook is active and the URL valid).',
|
||||
'sec_token_invalid' => 'Invalid security token',
|
||||
'sec_setup_expired' => 'Setup expired, please start again.',
|
||||
'sec_code_invalid' => 'Invalid code. Please try again.',
|
||||
'sec_enabled' => 'Two-factor authentication is now active. Please store the recovery codes safely!',
|
||||
'sec_sessions_closed' => 'All other sessions have been signed out.',
|
||||
'sec_codes_new' => 'New recovery codes generated. The previous ones are now invalid.',
|
||||
'sec_disabled' => 'Two-factor authentication has been disabled.',
|
||||
'backup_choose_file' => 'Please choose a backup file.',
|
||||
'backup_invalid_file' => 'Invalid or foreign backup file.',
|
||||
'sec_title' => 'Two-factor authentication',
|
||||
'sec_account' => 'Account:',
|
||||
'sec_required_hint' => 'For security reasons two-factor authentication is mandatory for administrators. Please set it up now.',
|
||||
'sec_recovery_codes' => 'Recovery codes',
|
||||
'sec_recovery_hint' => 'Keep them somewhere safe. Each code works once if you lose access to your app.',
|
||||
'sec_unavailable' => 'Not available',
|
||||
'sec_m365_hint' => 'Your account signs in via Microsoft 365. Two-factor authentication is managed in your Microsoft account.',
|
||||
'sec_active' => 'Active',
|
||||
'sec_inactive' => 'Inactive',
|
||||
'sec_active_hint' => 'Every sign-in additionally asks for a code from your authenticator app.',
|
||||
'sec_codes_left' => 'Remaining recovery codes:',
|
||||
'sec_regen_codes' => 'Generate new recovery codes',
|
||||
'sec_disable_confirm' => 'Really disable two-factor authentication?',
|
||||
'sec_disable' => 'Disable two-factor authentication',
|
||||
'sec_step_1' => 'Open your authenticator app (Google Authenticator, Authy, Microsoft Authenticator …)',
|
||||
'sec_step_2' => 'Scan the QR code or enter the secret manually',
|
||||
'sec_step_3' => 'Enter the six-digit code shown below',
|
||||
'sec_code_label' => 'Six-digit code',
|
||||
'sec_enable' => 'Enable two-factor authentication',
|
||||
'sec_sessions' => 'Active sessions:',
|
||||
'sec_logout_others_confirm' => 'Sign out all other sessions?',
|
||||
'sec_logout_others' => 'Sign out on all other devices',
|
||||
'import_title' => 'Voucher import',
|
||||
'import_subtitle' => 'Create several vouchers at once from a CSV list.',
|
||||
'import_card_title' => 'Create several vouchers',
|
||||
'import_format_hint' => 'One line per voucher:',
|
||||
'import_format_hint2' => '– max. devices and minutes are optional (defaults apply). 200 lines maximum. Example:',
|
||||
'import_site' => 'Site',
|
||||
'import_file' => 'CSV file (optional)',
|
||||
'import_paste' => '… or paste directly',
|
||||
'import_confirm' => 'Start the import now?',
|
||||
'import_submit' => 'Import',
|
||||
'import_result' => 'Result',
|
||||
'import_col_code' => 'Code / error',
|
||||
'import_created' => '{count} vouchers created.',
|
||||
'backup_title' => 'Backup & restore',
|
||||
'backup_subtitle' => 'Back up or restore configuration and data.',
|
||||
'backup_export' => 'Export',
|
||||
'backup_export_hint' => 'Downloads settings, sites and voucher profiles as JSON. Site passwords stay encrypted with the',
|
||||
'backup_export_hint2' => 'of this installation – a restore on an installation with a different APP_KEY cannot decrypt them.',
|
||||
'backup_export_btn' => 'Export configuration',
|
||||
'backup_import' => 'Import / restore',
|
||||
'backup_import_hint' => 'Existing sites are matched by name + site ID and updated, new ones are added. Profiles are only created if the name does not exist yet. The cron token is never overwritten.',
|
||||
'backup_opt_settings' => 'Settings',
|
||||
'backup_opt_sites' => 'Sites',
|
||||
'backup_opt_templates' => 'Voucher profiles',
|
||||
'backup_import_confirm' => 'Run the import now?',
|
||||
'backup_imported' => 'Import finished: {settings} settings, {sites} sites, {templates} profiles.',
|
||||
'rep_title' => 'Reporting',
|
||||
'rep_subtitle' => 'Analytics by period, site and user.',
|
||||
'rep_period' => 'Period',
|
||||
'rep_days' => 'days',
|
||||
'rep_csv_daily' => 'CSV (daily)',
|
||||
'rep_csv_site' => 'CSV (per site)',
|
||||
'rep_csv_user' => 'CSV (per user)',
|
||||
'rep_print' => 'Print / PDF',
|
||||
'rep_total' => 'Vouchers total',
|
||||
'rep_in_period' => 'Created in {days} days',
|
||||
'rep_chart_title' => 'Vouchers created ({days} days)',
|
||||
'rep_per_site' => 'Per site',
|
||||
'rep_top_users' => 'Top users',
|
||||
'rep_col_created' => 'Vouchers created',
|
||||
'rep_no_data' => 'No data',
|
||||
'label_total' => 'Total',
|
||||
'label_user' => 'User',
|
||||
'js_confirm_delete_voucher' => 'Really delete this voucher?',
|
||||
'js_confirm_delete_user' => 'Really delete this user?',
|
||||
'js_confirm_delete_site' => 'Do you really want to delete this site?',
|
||||
'js_confirm_delete_template' => 'Really delete this profile?',
|
||||
'js_confirm_delete_token' => 'Really delete this token?',
|
||||
'js_error' => 'Error',
|
||||
'js_enter_email' => 'Please enter an email address.',
|
||||
'js_running' => 'Running…',
|
||||
'js_run_now' => 'Run now',
|
||||
'js_copied' => 'Copied!',
|
||||
'js_code_copied' => 'Code copied!',
|
||||
'js_click_to_copy' => 'Click to copy',
|
||||
'settings_defaults_hint' => 'These values are used as defaults in the voucher form.',
|
||||
'settings_cron_what' => 'What does the cron job do?',
|
||||
'settings_smtp_from_name' => 'Sender name',
|
||||
'settings_tpl_voucher_mail' => 'Voucher email',
|
||||
'settings_tpl_user_notify' => 'User notification',
|
||||
'settings_editor_hint' => 'The editor (TinyMCE, GPL build) is served locally from',
|
||||
'settings_editor_hint2' => '– no external services are called.',
|
||||
'settings_print_template' => 'HTML template for voucher printing',
|
||||
'settings_leave_empty' => 'Empty = leave unchanged',
|
||||
'sites_id_hint' => 'Found in the UniFi controller URL',
|
||||
'sites_pw_unchanged' => 'Leave empty = unchanged',
|
||||
'templates_minutes_hint' => '480 = 8 hours',
|
||||
'templates_desc_placeholder' => 'Short description for your team',
|
||||
'audit_all_users' => 'All users',
|
||||
'label_expires_at' => 'Valid until:',
|
||||
'label_site_colon' => 'Site:',
|
||||
'audit_action_voucher_created' => 'Voucher created',
|
||||
'audit_action_voucher_bulk' => 'Bulk vouchers',
|
||||
'audit_action_user_login' => 'Login',
|
||||
'audit_action_user_logout' => 'Logout',
|
||||
'audit_action_user_created' => 'User created',
|
||||
'audit_action_user_updated' => 'User updated',
|
||||
'audit_action_user_deleted' => 'User deleted',
|
||||
'audit_action_site_added' => 'Site added',
|
||||
'audit_action_site_updated' => 'Site updated',
|
||||
'audit_action_site_deleted' => 'Site deleted',
|
||||
'audit_action_settings_saved' => 'Settings saved',
|
||||
'audit_action_password_reset' => 'Password reset',
|
||||
'audit_action_template_created' => 'Profile created',
|
||||
'audit_action_template_updated' => 'Profile updated',
|
||||
'audit_action_template_deleted' => 'Profile deleted',
|
||||
'audit_page_info' => 'Page {page} of {pages} ({total} entries)',
|
||||
'a11y_skip' => 'Skip to content',
|
||||
'a11y_theme' => 'Toggle appearance (light/dark)',
|
||||
'a11y_menu' => 'Open navigation',
|
||||
'a11y_language' => 'Language',
|
||||
'a11y_notifications' => 'Notifications',
|
||||
'audit_system_anon' => 'System/anonymous',
|
||||
'vouchers_resend' => 'Send by email',
|
||||
'label_minutes_short' => 'min',
|
||||
'js_copy' => 'Copy',
|
||||
'settings_placeholders' => 'Placeholders:',
|
||||
'settings_card_hint' => '{VOUCHER_CARD} renders the code as a highlighted card in the branded layout.',
|
||||
'settings_qr_hint' => '{QR_CODE} inserts a QR code containing the voucher code.',
|
||||
'print_valid_until' => 'Valid until',
|
||||
'print_devices' => 'devices',
|
||||
'settings_tab_general' => 'General',
|
||||
'settings_tab_defaults' => 'Voucher Defaults',
|
||||
'settings_tab_cron' => 'Cron Sync',
|
||||
|
|
@ -249,8 +479,8 @@ return [
|
|||
'settings_tab_password' => 'Password',
|
||||
'settings_saved' => 'Settings saved successfully!',
|
||||
'settings_app_title' => 'Application Title *',
|
||||
'settings_logo_url' => 'Logo URL',
|
||||
'settings_favicon_url' => 'Favicon URL',
|
||||
'settings_logo_url' => 'Logo',
|
||||
'settings_favicon_url' => 'Favicon',
|
||||
'settings_favicon_hint' => 'Browser tab icon (.ico, .png, .svg)',
|
||||
'settings_instr_header' => 'Instructions - Headline',
|
||||
'settings_instr_text' => 'Instructions - Text',
|
||||
|
|
|
|||
30
login.php
|
|
@ -6,6 +6,7 @@ ini_set('log_errors', 1);
|
|||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/includes/Database.php';
|
||||
require_once __DIR__ . '/includes/Auth.php';
|
||||
require_once __DIR__ . '/includes/Ui.php';
|
||||
require_once __DIR__ . '/includes/I18n.php';
|
||||
|
||||
try {
|
||||
|
|
@ -145,7 +146,7 @@ try {
|
|||
. ';--login-to:' . htmlspecialchars($loginBgTo, ENT_QUOTES)
|
||||
. ';--login-overlay:' . ($loginOverlay / 100);
|
||||
if ($loginBgImage !== '') {
|
||||
$visualStyle .= ";--login-image:url('" . htmlspecialchars($loginBgImage, ENT_QUOTES) . "')";
|
||||
$visualStyle .= ";--login-image:url('" . htmlspecialchars(Ui::mediaUrl($loginBgImage), ENT_QUOTES) . "')";
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
|
|
@ -158,12 +159,7 @@ try {
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= __('login_title') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/global.css">
|
||||
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
||||
<?= Ui::head($db) ?>
|
||||
</head>
|
||||
<body class="auth-body<?= $showPanel ? '' : ' auth-body-single' ?>">
|
||||
|
||||
|
|
@ -171,9 +167,9 @@ try {
|
|||
<section class="auth-visual<?= $loginBgImage !== '' ? ' has-image' : '' ?>" style="<?= $visualStyle ?>">
|
||||
<div class="auth-brand">
|
||||
<?php if ($loginLogo): ?>
|
||||
<img src="<?= htmlspecialchars($loginLogo) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="auth-brand-logo">
|
||||
<img src="<?= htmlspecialchars(Ui::mediaUrl($loginLogo)) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="auth-brand-logo">
|
||||
<?php else: ?>
|
||||
<span class="brand-mark"><i class="fas fa-wifi"></i></span>
|
||||
<span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||||
<span><?= htmlspecialchars($loginBrand) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
|
@ -183,7 +179,7 @@ try {
|
|||
<?php if (!empty($loginFeatures)): ?>
|
||||
<ul class="auth-features">
|
||||
<?php foreach ($loginFeatures as $feature): ?>
|
||||
<li><span class="tick"><i class="fas fa-check"></i></span> <?= htmlspecialchars($feature) ?></li>
|
||||
<li><span class="tick"><i class="fas fa-check" aria-hidden="true"></i></span> <?= htmlspecialchars($feature) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
|
@ -194,24 +190,24 @@ try {
|
|||
|
||||
<section class="auth-panel">
|
||||
<div class="auth-tools">
|
||||
<div class="lang-switcher">
|
||||
<div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
|
||||
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
||||
<button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>"
|
||||
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">
|
||||
<i class="fas fa-moon"></i>
|
||||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
|
||||
<i class="fas fa-moon" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="login-container">
|
||||
<?php if (!$showPanel): ?>
|
||||
<?php if ($loginLogo): ?>
|
||||
<img src="<?= htmlspecialchars($loginLogo) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="logo">
|
||||
<img src="<?= htmlspecialchars(Ui::mediaUrl($loginLogo)) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="logo">
|
||||
<?php else: ?>
|
||||
<div class="auth-mark">
|
||||
<span class="brand-mark"><i class="fas fa-wifi"></i></span>
|
||||
<span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||||
<span><?= htmlspecialchars($loginBrand) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
|
@ -285,12 +281,12 @@ try {
|
|||
<?php if (!$show2fa && $oidcEnabled): ?>
|
||||
<div class="divider"><span><?= __('or') ?></span></div>
|
||||
<a href="<?= htmlspecialchars($oidcLoginUrl) ?>" class="btn btn-secondary btn-lg">
|
||||
<i class="fas fa-key"></i> <?= htmlspecialchars($oidcName) ?>
|
||||
<i class="fas fa-key" aria-hidden="true"></i> <?= htmlspecialchars($oidcName) ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($publicAccess): ?>
|
||||
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left"></i> <?= __('login_back') ?></a></div>
|
||||
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('login_back') ?></a></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
373
login_simple.php
|
|
@ -1,189 +1,186 @@
|
|||
<?php
|
||||
// Umfassendes Error Reporting
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('log_errors', 1);
|
||||
ini_set('log_errors', 1);
|
||||
|
||||
// Versuche Dateien zu laden
|
||||
$loadErrors = [];
|
||||
|
||||
try {
|
||||
if (!file_exists(__DIR__ . '/config.php')) {
|
||||
throw new Exception('config.php nicht gefunden');
|
||||
}
|
||||
require_once __DIR__ . '/config.php';
|
||||
} catch (Exception $e) {
|
||||
$loadErrors[] = "Config: " . $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
if (!file_exists(__DIR__ . '/includes/Database.php')) {
|
||||
throw new Exception('includes/Database.php nicht gefunden');
|
||||
}
|
||||
require_once __DIR__ . '/includes/Database.php';
|
||||
} catch (Exception $e) {
|
||||
$loadErrors[] = "Database: " . $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
if (!file_exists(__DIR__ . '/includes/Auth.php')) {
|
||||
throw new Exception('includes/Auth.php nicht gefunden');
|
||||
}
|
||||
require_once __DIR__ . '/includes/Auth.php';
|
||||
} catch (Exception $e) {
|
||||
$loadErrors[] = "Auth: " . $e->getMessage();
|
||||
}
|
||||
|
||||
// Wenn Ladefehler aufgetreten sind, zeige sie an
|
||||
if (!empty($loadErrors)) {
|
||||
die('<h1>Fehler beim Laden der Dateien</h1><ul><li>' . implode('</li><li>', $loadErrors) . '</li></ul>');
|
||||
}
|
||||
|
||||
// Ab hier normal weiter
|
||||
try {
|
||||
$auth = new Auth();
|
||||
} catch (Exception $e) {
|
||||
die('<h1>Fehler bei Auth-Initialisierung</h1><p>' . $e->getMessage() . '</p>');
|
||||
}
|
||||
|
||||
// Wenn bereits eingeloggt, weiterleiten
|
||||
if ($auth->isLoggedIn()) {
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
$success = '';
|
||||
|
||||
// Login-Verarbeitung
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$email = $_POST['email'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
if (empty($email) || empty($password)) {
|
||||
$error = 'Bitte E-Mail und Passwort eingeben';
|
||||
} elseif ($auth->login($email, $password)) {
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
} else {
|
||||
$error = 'Ungültige E-Mail oder Passwort';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$error = 'Login-Fehler: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||
$logoUrl = $db->getSetting('logo_url', '');
|
||||
$m365Enabled = !empty($db->getSetting('m365_client_id')) &&
|
||||
!empty($db->getSetting('m365_client_secret')) &&
|
||||
!empty($db->getSetting('m365_tenant_id'));
|
||||
$publicAccess = $db->getSetting('public_access', 0);
|
||||
|
||||
// M365 OAuth URL generieren falls aktiviert
|
||||
$m365LoginUrl = '';
|
||||
if ($m365Enabled) {
|
||||
$clientId = $db->getSetting('m365_client_id');
|
||||
$tenantId = $db->getSetting('m365_tenant_id');
|
||||
|
||||
// Dynamische Redirect URI basierend auf aktuellem Pfad
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||
$redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
|
||||
|
||||
$params = [
|
||||
'client_id' => $clientId,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_mode' => 'query',
|
||||
'scope' => 'openid profile email User.Read',
|
||||
'state' => bin2hex(random_bytes(16))
|
||||
];
|
||||
|
||||
$_SESSION['m365_state'] = $params['state'];
|
||||
|
||||
$m365LoginUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?" . http_build_query($params);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
die('<h1>Datenbankfehler</h1><p>' . $e->getMessage() . '</p>');
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - <?= htmlspecialchars($appTitle) ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/global.css">
|
||||
<style>
|
||||
/* Diagnose-Ausgabe am Seitenende */
|
||||
.debug-info {
|
||||
margin-top: 22px; padding: 14px;
|
||||
background: var(--bg-subtle); border: 1px solid var(--border-color); border-radius: var(--r-md);
|
||||
font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); text-align: left;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-body focus-page">
|
||||
<div class="focus-card card login-container">
|
||||
<?php if ($logoUrl): ?>
|
||||
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
|
||||
<?php else: ?>
|
||||
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="subtitle">Melden Sie sich an, um fortzufahren</p>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="">
|
||||
<div class="form-group">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<?php if ($m365Enabled): ?>
|
||||
<div class="divider"><span>oder</span></div>
|
||||
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft">
|
||||
<i class="fab fa-microsoft"></i> Mit Microsoft 365 anmelden
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($publicAccess): ?>
|
||||
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left"></i> Zurück zur Code-Erstellung</a></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Debug Info (kann nach erfolgreicher Einrichtung entfernt werden) -->
|
||||
<div class="debug-info">
|
||||
<strong>System-Status:</strong><br>
|
||||
PHP Version: <?= phpversion() ?><br>
|
||||
Session Status: <?= session_status() === PHP_SESSION_ACTIVE ? 'Aktiv' : 'Inaktiv' ?><br>
|
||||
Eingeloggt: <?= $auth->isLoggedIn() ? 'Ja' : 'Nein' ?>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<?php
|
||||
// Umfassendes Error Reporting
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('log_errors', 1);
|
||||
ini_set('log_errors', 1);
|
||||
|
||||
// Versuche Dateien zu laden
|
||||
$loadErrors = [];
|
||||
|
||||
try {
|
||||
if (!file_exists(__DIR__ . '/config.php')) {
|
||||
throw new Exception('config.php nicht gefunden');
|
||||
}
|
||||
require_once __DIR__ . '/config.php';
|
||||
} catch (Exception $e) {
|
||||
$loadErrors[] = "Config: " . $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
if (!file_exists(__DIR__ . '/includes/Database.php')) {
|
||||
throw new Exception('includes/Database.php nicht gefunden');
|
||||
}
|
||||
require_once __DIR__ . '/includes/Database.php';
|
||||
} catch (Exception $e) {
|
||||
$loadErrors[] = "Database: " . $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
if (!file_exists(__DIR__ . '/includes/Auth.php')) {
|
||||
throw new Exception('includes/Auth.php nicht gefunden');
|
||||
}
|
||||
require_once __DIR__ . '/includes/Auth.php';
|
||||
require_once __DIR__ . '/includes/Ui.php';
|
||||
} catch (Exception $e) {
|
||||
$loadErrors[] = "Auth: " . $e->getMessage();
|
||||
}
|
||||
|
||||
// Wenn Ladefehler aufgetreten sind, zeige sie an
|
||||
if (!empty($loadErrors)) {
|
||||
die('<h1>Fehler beim Laden der Dateien</h1><ul><li>' . implode('</li><li>', $loadErrors) . '</li></ul>');
|
||||
}
|
||||
|
||||
// Ab hier normal weiter
|
||||
try {
|
||||
$auth = new Auth();
|
||||
} catch (Exception $e) {
|
||||
die('<h1>Fehler bei Auth-Initialisierung</h1><p>' . $e->getMessage() . '</p>');
|
||||
}
|
||||
|
||||
// Wenn bereits eingeloggt, weiterleiten
|
||||
if ($auth->isLoggedIn()) {
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
$success = '';
|
||||
|
||||
// Login-Verarbeitung
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$email = $_POST['email'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
if (empty($email) || empty($password)) {
|
||||
$error = 'Bitte E-Mail und Passwort eingeben';
|
||||
} elseif ($auth->login($email, $password)) {
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
} else {
|
||||
$error = 'Ungültige E-Mail oder Passwort';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$error = 'Login-Fehler: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||
$logoUrl = $db->getSetting('logo_url', '');
|
||||
$m365Enabled = !empty($db->getSetting('m365_client_id')) &&
|
||||
!empty($db->getSetting('m365_client_secret')) &&
|
||||
!empty($db->getSetting('m365_tenant_id'));
|
||||
$publicAccess = $db->getSetting('public_access', 0);
|
||||
|
||||
// M365 OAuth URL generieren falls aktiviert
|
||||
$m365LoginUrl = '';
|
||||
if ($m365Enabled) {
|
||||
$clientId = $db->getSetting('m365_client_id');
|
||||
$tenantId = $db->getSetting('m365_tenant_id');
|
||||
|
||||
// Dynamische Redirect URI basierend auf aktuellem Pfad
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
|
||||
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
|
||||
$redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
|
||||
|
||||
$params = [
|
||||
'client_id' => $clientId,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_mode' => 'query',
|
||||
'scope' => 'openid profile email User.Read',
|
||||
'state' => bin2hex(random_bytes(16))
|
||||
];
|
||||
|
||||
$_SESSION['m365_state'] = $params['state'];
|
||||
|
||||
$m365LoginUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?" . http_build_query($params);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
die('<h1>Datenbankfehler</h1><p>' . $e->getMessage() . '</p>');
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - <?= htmlspecialchars($appTitle) ?></title>
|
||||
<?= Ui::head($db) ?>
|
||||
<style>
|
||||
/* Diagnose-Ausgabe am Seitenende */
|
||||
.debug-info {
|
||||
margin-top: 22px; padding: 14px;
|
||||
background: var(--bg-subtle); border: 1px solid var(--border-color); border-radius: var(--r-md);
|
||||
font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); text-align: left;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-body focus-page">
|
||||
<div class="focus-card card login-container">
|
||||
<?php if ($logoUrl): ?>
|
||||
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
|
||||
<?php else: ?>
|
||||
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="subtitle">Melden Sie sich an, um fortzufahren</p>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="">
|
||||
<div class="form-group">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<?php if ($m365Enabled): ?>
|
||||
<div class="divider"><span>oder</span></div>
|
||||
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft">
|
||||
<i class="fab fa-microsoft" aria-hidden="true"></i> Mit Microsoft 365 anmelden
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($publicAccess): ?>
|
||||
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> Zurück zur Code-Erstellung</a></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Debug Info (kann nach erfolgreicher Einrichtung entfernt werden) -->
|
||||
<div class="debug-info">
|
||||
<strong>System-Status:</strong><br>
|
||||
PHP Version: <?= phpversion() ?><br>
|
||||
Session Status: <?= session_status() === PHP_SESSION_ACTIVE ? 'Aktiv' : 'Inaktiv' ?><br>
|
||||
Eingeloggt: <?= $auth->isLoggedIn() ? 'Ja' : 'Nein' ?>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -32,8 +32,8 @@ $redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>M365 Debug</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="assets/global.css">
|
||||
<?php require_once __DIR__ . '/includes/Ui.php'; ?>
|
||||
<?= Ui::head($db ?? null) ?>
|
||||
<style>
|
||||
body { padding: 32px 20px; }
|
||||
.section { max-width: 820px; margin: 0 auto 18px; padding: 20px; background: var(--bg-card);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
parameters:
|
||||
level: 5
|
||||
# __() stammt aus der I18n-Klasse und wird global definiert
|
||||
scanFiles:
|
||||
- includes/I18n.php
|
||||
paths:
|
||||
- includes/Totp.php
|
||||
- includes/Crypto.php
|
||||
- includes/ApiKey.php
|
||||
- includes/Ui.php
|
||||
- includes/Upload.php
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ ini_set('log_errors', 1);
|
|||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/includes/Database.php';
|
||||
require_once __DIR__ . '/includes/Auth.php';
|
||||
require_once __DIR__ . '/includes/Ui.php';
|
||||
require_once __DIR__ . '/includes/I18n.php';
|
||||
|
||||
$auth = new Auth();
|
||||
|
|
@ -69,12 +70,7 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= __('reset_new_pw') ?> – <?= htmlspecialchars($appTitle) ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/global.css">
|
||||
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
||||
<?= Ui::head($db) ?>
|
||||
<style>
|
||||
.pw-strength { height: 4px; border-radius: var(--r-pill); margin-top: 8px; background: var(--border-color); transition: width .3s, background-color .3s; }
|
||||
.pw-strength.weak { background: var(--danger); width: 30%; }
|
||||
|
|
@ -85,9 +81,9 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||
<body class="app-body focus-page">
|
||||
<div class="focus-card card">
|
||||
<?php if ($logoUrl): ?>
|
||||
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
|
||||
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
|
||||
<?php else: ?>
|
||||
<div class="focus-icon" style="margin-bottom:14px;"><i class="fas fa-key"></i></div>
|
||||
<div class="focus-icon" style="margin-bottom:14px;"><i class="fas fa-key" aria-hidden="true"></i></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h1><?= __('reset_new_pw') ?></h1>
|
||||
|
|
|
|||
5
test.php
|
|
@ -4,6 +4,8 @@ ini_set('display_errors', 0);
|
|||
ini_set('log_errors', 1);
|
||||
|
||||
// Diagnose-Seite nur fuer angemeldete Admins zugaenglich (verhindert Info-Leak)
|
||||
require_once __DIR__ . '/includes/Ui.php';
|
||||
|
||||
if (file_exists(__DIR__ . '/config.php')) {
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/includes/Database.php';
|
||||
|
|
@ -16,8 +18,7 @@ if (file_exists(__DIR__ . '/config.php')) {
|
|||
echo '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8">'
|
||||
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
|
||||
. '<title>System-Test</title>'
|
||||
. '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">'
|
||||
. '<link rel="stylesheet" href="assets/global.css">'
|
||||
. Ui::head(isset($db) ? $db : null)
|
||||
. '<style>.focus-card h2{font-size:14px;margin:22px 0 8px;} .focus-card h2:first-of-type{margin-top:0;}'
|
||||
. 'pre{white-space:pre-wrap;}</style>'
|
||||
. '</head><body class="app-body focus-page">'
|
||||
|
|
|
|||
2
tests/.htaccess
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
|
||||
Require all denied
|
||||
93
tests/UiTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
require_once __DIR__ . '/../includes/Ui.php';
|
||||
|
||||
/**
|
||||
* Sehr einfache Datenbank-Attrappe: liefert nur Einstellungen zurueck.
|
||||
*/
|
||||
class FakeSettings
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private array $values;
|
||||
|
||||
/** @param array<string, string> $values */
|
||||
public function __construct(array $values = [])
|
||||
{
|
||||
$this->values = $values;
|
||||
}
|
||||
|
||||
public function getSetting(string $key, $default = null)
|
||||
{
|
||||
return $this->values[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
|
||||
class UiTest extends TestCase
|
||||
{
|
||||
public function testAssetUrlCarriesVersionStamp(): void
|
||||
{
|
||||
$url = \Ui::asset('assets/global.css');
|
||||
|
||||
$this->assertStringStartsWith('assets/global.css?v=', $url);
|
||||
$this->assertMatchesRegularExpression('/\?v=\d+$/', $url);
|
||||
}
|
||||
|
||||
public function testAssetUrlRespectsBasePath(): void
|
||||
{
|
||||
$this->assertStringStartsWith('../assets/global.css?v=', \Ui::asset('assets/global.css', '../'));
|
||||
}
|
||||
|
||||
public function testBrandingStyleIsEmptyForDefaults(): void
|
||||
{
|
||||
$db = new FakeSettings([
|
||||
'brand_accent' => \Ui::DEFAULT_ACCENT,
|
||||
'brand_accent_dark' => \Ui::DEFAULT_ACCENT_DARK,
|
||||
'brand_gradient_from' => \Ui::DEFAULT_GRADIENT_FROM,
|
||||
'brand_gradient_to' => \Ui::DEFAULT_GRADIENT_TO,
|
||||
'brand_radius' => (string)\Ui::DEFAULT_RADIUS,
|
||||
]);
|
||||
|
||||
$this->assertSame('', \Ui::brandingStyle($db));
|
||||
$this->assertSame('', \Ui::brandingStyle(null));
|
||||
}
|
||||
|
||||
public function testBrandingStyleUsesCustomColour(): void
|
||||
{
|
||||
$style = \Ui::brandingStyle(new FakeSettings(['brand_accent' => '#0F766E']));
|
||||
|
||||
$this->assertStringContainsString('--accent:#0f766e', $style);
|
||||
$this->assertStringContainsString('[data-theme="dark"]', $style);
|
||||
}
|
||||
|
||||
public function testBrandingStyleIgnoresInvalidColour(): void
|
||||
{
|
||||
$style = \Ui::brandingStyle(new FakeSettings(['brand_accent' => 'rot; background:url(x)']));
|
||||
|
||||
$this->assertSame('', $style, 'Ungueltige Farben duerfen keinen Override erzeugen');
|
||||
}
|
||||
|
||||
public function testBrandingRadiusIsClamped(): void
|
||||
{
|
||||
$style = \Ui::brandingStyle(new FakeSettings(['brand_radius' => '999']));
|
||||
|
||||
$this->assertStringContainsString('--r-lg:28px', $style);
|
||||
}
|
||||
|
||||
public function testMediaUrlKeepsAbsoluteAddresses(): void
|
||||
{
|
||||
$this->assertSame('https://cdn.example.com/logo.svg', \Ui::mediaUrl('https://cdn.example.com/logo.svg', '../'));
|
||||
$this->assertSame('/logo.svg', \Ui::mediaUrl('/logo.svg', '../'));
|
||||
$this->assertSame('', \Ui::mediaUrl('', '../'));
|
||||
}
|
||||
|
||||
public function testMediaUrlPrefixesUploads(): void
|
||||
{
|
||||
$this->assertSame('../uploads/logo.png', \Ui::mediaUrl('uploads/logo.png', '../'));
|
||||
}
|
||||
}
|
||||
72
tests/UploadTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionMethod;
|
||||
use RuntimeException;
|
||||
|
||||
require_once __DIR__ . '/../includes/Upload.php';
|
||||
|
||||
/**
|
||||
* Der SVG-Filter ist die sicherheitskritische Stelle beim Bild-Upload:
|
||||
* hochgeladene Grafiken werden aus der eigenen Domain ausgeliefert, aktive
|
||||
* Inhalte darin waeren damit gespeichertes XSS.
|
||||
*/
|
||||
class UploadTest extends TestCase
|
||||
{
|
||||
private function sanitize(string $svg): string
|
||||
{
|
||||
$method = new ReflectionMethod(\Upload::class, 'sanitizeSvg');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke(null, $svg);
|
||||
}
|
||||
|
||||
public function testRemovesScriptElement(): void
|
||||
{
|
||||
$clean = $this->sanitize('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect/></svg>');
|
||||
|
||||
$this->assertStringNotContainsString('<script', $clean);
|
||||
$this->assertStringNotContainsString('alert(1)', $clean);
|
||||
$this->assertStringContainsString('<rect/>', $clean);
|
||||
}
|
||||
|
||||
public function testRemovesEventHandlers(): void
|
||||
{
|
||||
$clean = $this->sanitize('<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect onclick=\'steal()\'/></svg>');
|
||||
|
||||
$this->assertStringNotContainsString('onload', $clean);
|
||||
$this->assertStringNotContainsString('onclick', $clean);
|
||||
}
|
||||
|
||||
public function testRemovesJavascriptLinks(): void
|
||||
{
|
||||
$clean = $this->sanitize('<svg xmlns="http://www.w3.org/2000/svg"><a xlink:href="javascript:alert(1)">x</a></svg>');
|
||||
|
||||
$this->assertStringNotContainsString('javascript:', $clean);
|
||||
}
|
||||
|
||||
public function testKeepsHarmlessMarkup(): void
|
||||
{
|
||||
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="4" fill="#0f766e"/></svg>';
|
||||
|
||||
$this->assertSame($svg, $this->sanitize($svg));
|
||||
}
|
||||
|
||||
public function testRejectsNonSvgContent(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->sanitize('GIF89a<html>');
|
||||
}
|
||||
|
||||
public function testIsLocalOnlyAcceptsUploadPaths(): void
|
||||
{
|
||||
$this->assertTrue(\Upload::isLocal('uploads/abc.png'));
|
||||
$this->assertFalse(\Upload::isLocal('https://example.com/logo.png'));
|
||||
$this->assertFalse(\Upload::isLocal('uploads/../config.php'));
|
||||
$this->assertFalse(\Upload::isLocal(''));
|
||||
}
|
||||
}
|
||||
2
tools/.htaccess
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
|
||||
Require all denied
|
||||
42
tools/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Werkzeuge
|
||||
|
||||
## Demo-Instanz (`tools/demo/build.py`)
|
||||
|
||||
Baut aus dem Projekt eine lauffähige Kopie **ohne Datenbank**: `Database` und
|
||||
`Auth` werden durch Stubs mit festen Beispieldaten ersetzt (`tools/demo/overlay/`).
|
||||
Das Original bleibt unverändert.
|
||||
|
||||
```bash
|
||||
python3 tools/demo/build.py /tmp/uvt-demo
|
||||
php -S 127.0.0.1:8123 -t /tmp/uvt-demo
|
||||
```
|
||||
|
||||
Nützlich für:
|
||||
|
||||
* **Screenshots** für die Dokumentation – immer mit denselben Daten
|
||||
* **Smoke-Test** – jede Seite einmal rendern, ohne MySQL aufzusetzen
|
||||
|
||||
Demo-spezifische Zustände werden über Query-Parameter erreicht:
|
||||
|
||||
| Parameter | Wirkung |
|
||||
|---|---|
|
||||
| `?demo=result` / `?demo=bulk` | Voucher-Ergebnis bzw. Bulk-Liste auf `index.php` |
|
||||
| `?demo=new` | frisch erzeugter Schlüssel auf `admin/api_keys.php` |
|
||||
| `?anon=1` | nicht angemeldet (für die Login-Seite) |
|
||||
| `?theme=dark` | erzwingt den Dark Mode |
|
||||
| `?brand=custom` / `?brand=nopanel` | Beispiel-Branding der Login-Seite |
|
||||
|
||||
## Screenshots (`tools/screenshots.py`)
|
||||
|
||||
Rendert die Bilder aus `docs/screenshots/` neu. Voraussetzung ist ein
|
||||
headless Chromium; der Pfad kommt aus `$CHROME_BIN` oder wird in den
|
||||
üblichen Verzeichnissen gesucht.
|
||||
|
||||
```bash
|
||||
python3 tools/demo/build.py /tmp/uvt-demo
|
||||
php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
|
||||
CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py
|
||||
```
|
||||
|
||||
Die Skripte sind Hilfsmittel für die Entwicklung – im Betrieb werden sie nicht
|
||||
benötigt und sind per `.htaccess` nicht über HTTP erreichbar.
|
||||
108
tools/demo/build.py
Executable file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Baut eine Demo-Instanz des Voucher-Tools ohne Datenbank.
|
||||
|
||||
Die Demo dient zwei Zwecken:
|
||||
* Screenshots fuer die Dokumentation reproduzierbar erzeugen
|
||||
* Smoke-Test: rendert jede Seite einmal ohne echte Datenbank
|
||||
|
||||
Ablauf: Projekt in ein Zielverzeichnis kopieren, das Overlay darueberlegen
|
||||
(Stubs fuer Database/Auth) und ein paar klar benannte Demo-Zustaende in die
|
||||
Kopie patchen. Das Original bleibt unveraendert.
|
||||
|
||||
python3 tools/demo/build.py /tmp/uvt-demo
|
||||
php -S 127.0.0.1:8123 -t /tmp/uvt-demo
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
OVERLAY = os.path.join(ROOT, 'tools', 'demo', 'overlay')
|
||||
# Nur auf oberster Ebene ueberspringen – assets/vendor gehoert dazu!
|
||||
SKIP_TOP = {'.git', '.github', 'tools', 'docs', 'tests', 'vendor', 'node_modules'}
|
||||
|
||||
|
||||
def copy_project(target: str) -> None:
|
||||
if os.path.exists(target):
|
||||
shutil.rmtree(target)
|
||||
|
||||
def ignore(directory, names):
|
||||
if os.path.abspath(directory) == ROOT:
|
||||
return [n for n in names if n in SKIP_TOP]
|
||||
return []
|
||||
|
||||
shutil.copytree(ROOT, target, ignore=ignore)
|
||||
for name in os.listdir(OVERLAY):
|
||||
src = os.path.join(OVERLAY, name)
|
||||
dst = os.path.join(target, name)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def patch(path: str, anchor: str, replacement: str) -> None:
|
||||
"""Ersetzt einen Anker in der Demo-Kopie. Fehlt der Anker, bricht der
|
||||
Build ab – so faellt auf, wenn sich die Vorlage geaendert hat."""
|
||||
with open(path, encoding='utf-8') as fh:
|
||||
content = fh.read()
|
||||
if anchor not in content:
|
||||
raise SystemExit('Anker nicht gefunden in %s:\n%s' % (path, anchor[:80]))
|
||||
with open(path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(content.replace(anchor, replacement, 1))
|
||||
|
||||
|
||||
def apply_demo_states(target: str) -> None:
|
||||
# Voucher-Ergebnis und Bulk-Ansicht ohne Controller-Zugriff zeigen
|
||||
anchor = "$currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null;"
|
||||
patch(os.path.join(target, 'index.php'), anchor, anchor + """
|
||||
|
||||
// --- Demo-Instanz: Zustaende ohne UniFi-Controller darstellen ---
|
||||
if (($_GET['demo'] ?? '') === 'result') {
|
||||
$voucherCreated = true;
|
||||
$voucherCode = '4829-17364';
|
||||
$voucherData = ['code' => '4829-17364', 'site_name' => 'Hauptstandort Nord', 'max_uses' => 2,
|
||||
'expire_min' => 480, 'expiry_date' => '23.09.2026', 'expiry_time' => '08:00'];
|
||||
}
|
||||
if (($_GET['demo'] ?? '') === 'bulk') {
|
||||
$bulkCreated = true;
|
||||
$bulkVouchers = [];
|
||||
foreach (['4829-17364', '5517-90422', '7731-64508', '2094-38177', '6640-52913'] as $code) {
|
||||
$bulkVouchers[] = ['code' => $code, 'site_name' => 'Hauptstandort Nord', 'max_uses' => 2,
|
||||
'expire_min' => 480, 'expiry_date' => '23.09.2026', 'expiry_time' => '08:00'];
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
# Live-Voucher-Liste automatisch laden (sonst wartet sie auf Site-Auswahl)
|
||||
patch(os.path.join(target, 'admin', 'vouchers.php'), "</script>\n</body>", """</script>
|
||||
<script>
|
||||
// Demo-Instanz: Site vorauswaehlen und Liste laden
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const sel = document.getElementById('siteSelect');
|
||||
if (sel) { sel.value = '1'; loadVouchers(false); }
|
||||
});
|
||||
</script>
|
||||
</body>""")
|
||||
|
||||
# Frisch erzeugten API-Schluessel zeigen
|
||||
patch(os.path.join(target, 'admin', 'api_keys.php'), '$keys = $db->fetchAll(',
|
||||
"if (($_GET['demo'] ?? '') === 'new') { $newKey = 'uvt_3f9a2c7d41e8b60592af18cc4d7e0b3a95f2617c'; }\n$keys = $db->fetchAll(")
|
||||
|
||||
# Theme per Query-Parameter erzwingen (fuer Dark-Mode-Screenshots)
|
||||
ui = os.path.join(target, 'includes', 'Ui.php')
|
||||
patch(ui, 'var s=localStorage.getItem("theme");',
|
||||
'var s=new URLSearchParams(location.search).get("theme")||localStorage.getItem("theme");')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else '/tmp/uvt-demo'
|
||||
copy_project(target)
|
||||
apply_demo_states(target)
|
||||
print('Demo-Instanz gebaut: %s' % target)
|
||||
print('Start: php -S 127.0.0.1:8123 -t %s' % target)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
22
tools/demo/overlay/demo-assets/background.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1400" viewBox="0 0 1200 1400">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#7dd3fc"/><stop offset="55%" stop-color="#38bdf8"/><stop offset="100%" stop-color="#0369a1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="water" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#0c4a6e"/><stop offset="100%" stop-color="#082f49"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="1400" fill="url(#sky)"/>
|
||||
<circle cx="900" cy="260" r="90" fill="#fef3c7" opacity=".85"/>
|
||||
<path d="M0 780 L260 560 L430 780 Z" fill="#134e4a" opacity=".85"/>
|
||||
<path d="M300 800 L560 520 L830 800 Z" fill="#115e59" opacity=".9"/>
|
||||
<path d="M700 800 L980 600 L1200 800 Z" fill="#0f766e" opacity=".85"/>
|
||||
<rect y="800" width="1200" height="600" fill="url(#water)"/>
|
||||
<g fill="#ffffff" opacity=".16">
|
||||
<rect x="120" y="880" width="360" height="6" rx="3"/>
|
||||
<rect x="240" y="960" width="520" height="6" rx="3"/>
|
||||
<rect x="60" y="1060" width="420" height="6" rx="3"/>
|
||||
<rect x="520" y="1140" width="600" height="6" rx="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
9
tools/demo/overlay/demo-assets/logo.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="220" height="42" viewBox="0 0 220 42">
|
||||
<g fill="none" stroke="#ffffff" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M8 30c4-10 10-15 13-15s9 5 13 15"/>
|
||||
<path d="M14 30c2.5-6 5.5-9 7-9s4.5 3 7 9"/>
|
||||
</g>
|
||||
<circle cx="21" cy="30" r="2.6" fill="#ffffff"/>
|
||||
<text x="46" y="21" font-family="Inter, sans-serif" font-size="16" font-weight="700" fill="#ffffff">HOTEL SEEBLICK</text>
|
||||
<text x="46" y="34" font-family="Inter, sans-serif" font-size="10.5" letter-spacing="2" fill="rgba(255,255,255,.75)">GÄSTE-WLAN</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 588 B |
31
tools/demo/overlay/includes/Auth.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
/** Demo-Stub: angemeldeter Admin ohne echte Sitzungspruefung. */
|
||||
require_once __DIR__ . '/Database.php';
|
||||
require_once __DIR__ . '/Totp.php';
|
||||
class Auth {
|
||||
private $user = ['id'=>1,'name'=>'Marie Sander','email'=>'marie.sander@example.com','is_admin'=>1,
|
||||
'role'=>'admin','is_active'=>1,'password_hash'=>'x','totp_enabled'=>0,'auth_source'=>'local'];
|
||||
public function __construct() { if (session_status() === PHP_SESSION_NONE) @session_start(); $_SESSION['user_id'] = 1; }
|
||||
public function clientIp() { return '192.168.10.42'; }
|
||||
public function login($e, $p) { return false; }
|
||||
public function isTotpPending() { return isset($_GET['totp']); }
|
||||
public function verifyTotpLogin($c) { return false; }
|
||||
public function enableTotp($u, $s) { return true; }
|
||||
public function disableTotp($u) { return true; }
|
||||
public function generateBackupCodes($n = 8) { return ['A1B2-C3D4','E5F6-G7H8','J9K0-L1M2','N3P4-Q5R6']; }
|
||||
public function regenerateBackupCodes($u) { return $this->generateBackupCodes(); }
|
||||
public function backupCodesRemaining($u) { return 6; }
|
||||
public function writeAuditLog(...$a) { return true; }
|
||||
public function logout() {}
|
||||
public function isLoggedIn() { return !isset($_GET['anon']); }
|
||||
public function isAdmin() { return $this->isLoggedIn(); }
|
||||
public function getCurrentUser() { return $this->isLoggedIn() ? $this->user : null; }
|
||||
public function hasAccessToSite($id) { return true; }
|
||||
public function validateCsrfToken($t) { return true; }
|
||||
public function getCsrfToken() { return 'demo-csrf-token'; }
|
||||
public function registerUser(...$a) { return true; }
|
||||
public function requireAdmin() {}
|
||||
public function requireLogin() {}
|
||||
public function activeSessionCount() { return 3; }
|
||||
public function logoutOtherSessions() { return 2; }
|
||||
}
|
||||
163
tools/demo/overlay/includes/Database.php
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
/** Demo-Stub: liefert feste Beispieldaten statt echter DB-Zugriffe. */
|
||||
class Database {
|
||||
private static $instance = null;
|
||||
private $settings = [
|
||||
'app_title' => 'UniFi Voucher System',
|
||||
'logo_url' => '', 'favicon_url' => '',
|
||||
'public_access' => '1', 'smtp_enabled' => '1', 'sms_enabled' => '1',
|
||||
'm365_enabled' => '1', 'oidc_enabled' => '0', 'oidc_name' => 'Keycloak',
|
||||
'default_expire_minutes' => '480', 'default_max_uses' => '2', 'max_uses_limit' => '10',
|
||||
'instruction_header' => 'So verbinden Sie sich',
|
||||
'instruction_text' => 'WLAN „Gast-WiFi" wählen, Code eingeben und bestätigen. Bei Fragen hilft der Empfang gerne weiter.',
|
||||
'last_cron_sync' => '2026-09-22 14:35:00',
|
||||
'session_driver' => 'db', 'captcha_mode' => 'math',
|
||||
'enforce_2fa_admins' => '1', 'user_daily_voucher_limit' => '25',
|
||||
'webhook_enabled' => '1', 'webhook_url' => 'https://hooks.slack.com/services/T024/B0197/XYZ',
|
||||
'twilio_sid' => 'AC8f2c1d94e77b40a2', 'twilio_from' => '+4915112345678',
|
||||
'trusted_proxy' => '10.0.0.1, 172.18.0.1',
|
||||
'cleanup_expired_days' => '90', 'cleanup_audit_days' => '365', 'cleanup_login_days' => '30',
|
||||
'last_cleanup' => '2026-09-22 03:00:00',
|
||||
];
|
||||
public function __construct()
|
||||
{
|
||||
// Screenshot-Varianten: ?brand=custom|gradient|nopanel
|
||||
$brand = $_GET['brand'] ?? '';
|
||||
if ($brand === 'custom') {
|
||||
$this->settings = array_merge($this->settings, [
|
||||
'app_title' => 'Hotel Seeblick',
|
||||
'login_brand_name' => 'Hotel Seeblick',
|
||||
'login_logo_url' => '/demo-assets/logo.svg',
|
||||
'login_claim_title' => 'Willkommen im Hotel Seeblick.',
|
||||
'login_claim_text' => 'Gäste-WLAN für Zimmer, Tagungsräume und Restaurant – Zugangscodes direkt an der Rezeption erstellen.',
|
||||
'login_features' => "Code direkt beim Check-in ausdrucken\nTagungsgäste per E-Mail versorgen\nAuswertung je Haus und Etage",
|
||||
'login_footer' => '© 2026 Hotel Seeblick GmbH · Datenschutz · Impressum',
|
||||
'login_bg_image' => '/demo-assets/background.svg',
|
||||
'login_bg_overlay' => '45',
|
||||
'brand_accent' => '#0f766e',
|
||||
'brand_accent_dark' => '#2dd4bf',
|
||||
'brand_gradient_from' => '#0f766e',
|
||||
'brand_gradient_to' => '#0ea5e9',
|
||||
]);
|
||||
} elseif ($brand === 'nopanel') {
|
||||
$this->settings = array_merge($this->settings, [
|
||||
'login_panel_enabled' => '0',
|
||||
'login_brand_name' => 'Stadtwerke Nordheim',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getInstance() { return self::$instance ??= new self(); }
|
||||
public function getConnection() { return null; }
|
||||
public function getSetting($key, $default = null) { return $this->settings[$key] ?? $default; }
|
||||
public function setSetting($key, $value) { $this->settings[$key] = $value; }
|
||||
public function execute($sql, $params = []) { return 1; }
|
||||
public function query($sql, $params = []) { return new DemoStmt(); }
|
||||
public function fetchOne($sql, $params = []) { $r = $this->fetchAll($sql, $params); return $r[0] ?? false; }
|
||||
|
||||
public function fetchAll($sql, $params = []) {
|
||||
$s = preg_replace('/\s+/', ' ', strtolower($sql));
|
||||
|
||||
if (str_contains($s, 'count(*) as count from sites')) return [['count'=>4]];
|
||||
if (str_contains($s, 'count(*) as count from users')) return [['count'=>12]];
|
||||
if (str_contains($s, 'count(*) as count from vouchers where date(created_at)=curdate()')) return [['count'=>18]];
|
||||
if (str_contains($s, 'count(*) as count from vouchers where date(created_at)=')) {
|
||||
$day = $params[0] ?? '';
|
||||
$map = [6,9,5,12,8,4,7,14,11,16,9,6,13,18];
|
||||
$idx = abs((int)((strtotime($day) - strtotime('today')) / 86400));
|
||||
return [['count' => $map[13 - min($idx, 13)]]];
|
||||
}
|
||||
if (str_contains($s, 'from vouchers where site_id=?') && str_contains($s, 'count(*) as total')) {
|
||||
$per = [1=>['total'=>128,'valid'=>74,'used'=>39,'expired'=>15],
|
||||
2=>['total'=>63,'valid'=>41,'used'=>18,'expired'=>4],
|
||||
3=>['total'=>22,'valid'=>14,'used'=>6,'expired'=>2],
|
||||
4=>['total'=>0,'valid'=>0,'used'=>0,'expired'=>0]];
|
||||
return [$per[(int)($params[0] ?? 1)] ?? $per[4]];
|
||||
}
|
||||
if (str_contains($s, 'count(*) as total') && str_contains($s, 'from vouchers')) {
|
||||
return [['total'=>213,'valid'=>129,'used'=>63,'expired'=>21]];
|
||||
}
|
||||
if (str_contains($s, 'from sites')) {
|
||||
return [
|
||||
['id'=>1,'name'=>'Hauptstandort Nord','site_id'=>'default','unifi_controller_url'=>'https://unifi.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>1,'created_at'=>'2026-01-12 09:00:00'],
|
||||
['id'=>2,'name'=>'Campus West','site_id'=>'campus-west','unifi_controller_url'=>'https://unifi.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>1,'created_at'=>'2026-02-03 11:30:00'],
|
||||
['id'=>3,'name'=>'Showroom Berlin','site_id'=>'showroom','unifi_controller_url'=>'https://unifi-b.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>1,'created_at'=>'2026-04-21 15:10:00'],
|
||||
['id'=>4,'name'=>'Lager Süd','site_id'=>'lager','unifi_controller_url'=>'https://unifi-s.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>0,'created_at'=>'2026-06-08 08:45:00'],
|
||||
];
|
||||
}
|
||||
if (str_contains($s, 'count(v.id) as voucher_count') || str_contains($s, 'from users u left join vouchers')) {
|
||||
return [
|
||||
['name'=>'Marie Sander','email'=>'marie.sander@example.com','voucher_count'=>48,'c'=>48],
|
||||
['name'=>'Jonas Weber','email'=>'j.weber@example.com','voucher_count'=>31,'c'=>31],
|
||||
['name'=>'Alina Brandt','email'=>'alina.brandt@example.com','voucher_count'=>27,'c'=>27],
|
||||
['name'=>'Tim Kurz','email'=>'tim.kurz@example.com','voucher_count'=>19,'c'=>19],
|
||||
['name'=>'Sara Vogt','email'=>'sara.vogt@example.com','voucher_count'=>12,'c'=>12],
|
||||
];
|
||||
}
|
||||
if (str_contains($s, 'from vouchers')) {
|
||||
if (str_contains($s, 'group by date')) {
|
||||
$out = []; $vals = [6,9,5,12,8,4,7,14,11,16,9,6,13,18];
|
||||
foreach ($vals as $i => $c) { $out[] = ['date'=>date('d.m', strtotime('-'.(13-$i).' days')), 'count'=>$c]; }
|
||||
return $out;
|
||||
}
|
||||
$codes = [['H7K2-M9QF',1,'Hauptstandort Nord','valid'],['P3RT-8ZXC',2,'Campus West','used'],
|
||||
['QW4E-7TYU',1,'Hauptstandort Nord','valid'],['LM2N-5BVD',3,'Showroom Berlin','expired'],
|
||||
['ZX9C-3PLO',2,'Campus West','valid'],['FG6H-1JKL',1,'Hauptstandort Nord','used']];
|
||||
$names = ['Besuch Agentur Nordlicht','Workshop Raum 2','Empfang Tagesgast','Messe-Stand','Handwerker Haustechnik','Bewerbungsgespräch'];
|
||||
$out = [];
|
||||
foreach ($codes as $i => [$c,$sid,$sname,$st]) {
|
||||
$out[] = ['id'=>$i+1,'voucher_code'=>$c,'site_id'=>$sid,'site_name'=>$sname,'user_name'=>'Marie Sander',
|
||||
'status'=>$st,'created_at'=>date('Y-m-d H:i:s', strtotime("-{$i} hours")),
|
||||
'voucher_name'=>$names[$i],'unifi_voucher_id'=>'65f1a'.$i,
|
||||
'expires_at'=>date('Y-m-d H:i:s', strtotime('+'.(8-$i).' hours')),
|
||||
'max_uses'=>[2,5,1,3,2,1][$i],'used_count'=>[2,1,0,3,0,1][$i],'expire_minutes'=>480];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
if (str_contains($s, 'from users')) {
|
||||
return [
|
||||
['id'=>1,'name'=>'Marie Sander','email'=>'marie.sander@example.com','is_admin'=>1,'is_active'=>1,'created_at'=>'2026-01-10 09:00:00','last_login'=>'2026-09-22 08:12:00','totp_enabled'=>1,'auth_source'=>'local','voucher_count'=>48,'password_hash'=>'x'],
|
||||
['id'=>2,'name'=>'Jonas Weber','email'=>'j.weber@example.com','is_admin'=>0,'is_active'=>1,'created_at'=>'2026-03-02 13:20:00','last_login'=>'2026-09-21 17:40:00','totp_enabled'=>0,'auth_source'=>'m365','voucher_count'=>31,'password_hash'=>''],
|
||||
['id'=>3,'name'=>'Alina Brandt','email'=>'alina.brandt@example.com','is_admin'=>0,'is_active'=>1,'created_at'=>'2026-05-18 10:05:00','last_login'=>'2026-09-20 09:31:00','totp_enabled'=>1,'auth_source'=>'local','voucher_count'=>27,'password_hash'=>'x'],
|
||||
];
|
||||
}
|
||||
if (str_contains($s, 'from api_keys')) {
|
||||
return [
|
||||
['id'=>1,'name'=>'Buchungssystem','key_prefix'=>'3f9a2c','scope'=>'write','rate_limit'=>60,'is_active'=>1,'last_used_at'=>'2026-09-22 09:12:00','creator'=>'Marie Sander','created_at'=>'2026-06-01 10:00:00'],
|
||||
['id'=>2,'name'=>'Terminal Foyer','key_prefix'=>'b71e04','scope'=>'read','rate_limit'=>0,'is_active'=>1,'last_used_at'=>'2026-09-21 17:40:00','creator'=>'Marie Sander','created_at'=>'2026-07-14 16:20:00'],
|
||||
['id'=>3,'name'=>'Altes Kassensystem','key_prefix'=>'c0d5f8','scope'=>'write','rate_limit'=>30,'is_active'=>0,'last_used_at'=>null,'creator'=>'Jonas Weber','created_at'=>'2026-02-09 08:05:00'],
|
||||
];
|
||||
}
|
||||
if (str_contains($s, 'from audit_log')) {
|
||||
if (str_contains($s, 'count(')) return [['total'=>1284,'c'=>1284,'count'=>1284]];
|
||||
if (str_contains($s, 'distinct action')) {
|
||||
return [['action'=>'voucher_created'],['action'=>'user_login'],['action'=>'settings_saved'],['action'=>'user_created'],['action'=>'template_updated']];
|
||||
}
|
||||
$rows = [['2026-09-22 14:32:00','Marie Sander','voucher_created','Hauptstandort Nord · H7K2-M9QF','192.168.10.42'],
|
||||
['2026-09-22 13:58:00','Jonas Weber','user_login','Microsoft 365','83.112.4.17'],
|
||||
['2026-09-22 11:04:00','Marie Sander','settings_saved','Tab: E-Mail (SMTP)','192.168.10.42'],
|
||||
['2026-09-21 17:40:00','Alina Brandt','user_created','tim.kurz@example.com','192.168.10.88'],
|
||||
['2026-09-21 09:12:00','Marie Sander','template_updated','Tagesgast','192.168.10.42'],
|
||||
['2026-09-20 16:03:00','Jonas Weber','voucher_bulk','5 Vouchers · Campus West','83.112.4.17']];
|
||||
$out = [];
|
||||
foreach ($rows as $i => [$d,$u,$a,$det,$ip]) {
|
||||
$out[] = ['id'=>$i+1,'created_at'=>$d,'user_name'=>$u,'user_email'=>'demo@example.com','action'=>$a,'details'=>$det,'ip_address'=>$ip,'entity_type'=>'voucher','entity_id'=>$i+1];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
if (str_contains($s, 'templates')) {
|
||||
return [
|
||||
['id'=>1,'name'=>'Tagesgast','description'=>'Standardprofil für Besucher am Empfang','max_uses'=>2,'expire_minutes'=>480,'qos_rate_max_down'=>20000,'qos_rate_max_up'=>5000,'qos_usage_quota'=>0,'is_active'=>1,'is_default'=>1,'created_at'=>'2026-01-15 10:00:00'],
|
||||
['id'=>2,'name'=>'Konferenz','description'=>'Mehrtägige Veranstaltungen','max_uses'=>5,'expire_minutes'=>4320,'qos_rate_max_down'=>50000,'qos_rate_max_up'=>10000,'qos_usage_quota'=>0,'is_active'=>1,'is_default'=>0,'created_at'=>'2026-02-20 11:30:00'],
|
||||
['id'=>3,'name'=>'Handwerker','description'=>'Kurzzugang für Dienstleister','max_uses'=>1,'expire_minutes'=>240,'qos_rate_max_down'=>10000,'qos_rate_max_up'=>2000,'qos_usage_quota'=>1024,'is_active'=>1,'is_default'=>0,'created_at'=>'2026-03-08 09:15:00'],
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
class DemoStmt {
|
||||
public function fetch($m = null) { return false; }
|
||||
public function fetchAll($m = null) { return []; }
|
||||
public function rowCount() { return 1; }
|
||||
public function execute($p = []) { return true; }
|
||||
}
|
||||