Merge pull request 'Self-Hosting, Branding, i18n, Barrierefreiheit und Werkzeuge' (#2) from feature/self-hosted-assets-branding into main
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run

This commit is contained in:
friloo 2026-09-23 06:54:39 +00:00
commit 7dc60c5bb0
106 changed files with 4073 additions and 1393 deletions

View file

@ -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
View 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).

View file

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

@ -8,7 +8,7 @@
![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white)
![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white)
![License](https://img.shields.io/badge/Lizenz-MIT-green)
![Version](https://img.shields.io/badge/Version-2.5.0-blueviolet)
![Version](https://img.shields.io/badge/Version-2.6.0-blueviolet)
![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg)
</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>

View file

@ -37,7 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) {
);
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt");
$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 &lt;key&gt;</code> oder <code>X-API-Key: &lt;key&gt;</code>.</p>
<h2><?= __('api_usage') ?></h2>
<p class="muted" style="margin-bottom:10px;"><?= __('api_usage_hint') ?> <code>Authorization: Bearer &lt;key&gt;</code> oder <code>X-API-Key: &lt;key&gt;</code>.</p>
<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>

View file

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

View file

@ -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 &amp; 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>

View file

@ -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&#10;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; ?>

View file

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

View file

@ -49,13 +49,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
$db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0)));
$db->setSetting('cleanup_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 &amp; 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>

View file

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

View file

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

View file

@ -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 &nbsp;|&nbsp; 480 Min = 8 Stunden &nbsp;|&nbsp;
1440 Min = 1 Tag &nbsp;|&nbsp; 10080 Min = 1 Woche &nbsp;|&nbsp;
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);
}

View file

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

View file

@ -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')">&times;</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')">&times;</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>

View file

@ -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'); }

View file

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

View file

@ -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
========================================================================= */

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

BIN
assets/vendor/inter/Inter-Bold.woff2 vendored Normal file

Binary file not shown.

BIN
assets/vendor/inter/Inter-Medium.woff2 vendored Normal file

Binary file not shown.

BIN
assets/vendor/inter/Inter-Regular.woff2 vendored Normal file

Binary file not shown.

BIN
assets/vendor/inter/Inter-SemiBold.woff2 vendored Normal file

Binary file not shown.

30
assets/vendor/inter/inter.css vendored Normal file
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

406
assets/vendor/tinymce/langs/de.js vendored Normal file
View 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
View 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.

File diff suppressed because one or more lines are too long

View 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.")}))}();

View 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)}))}();

View 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),{})))}();

File diff suppressed because one or more lines are too long

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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)}))}();

File diff suppressed because one or more lines are too long

View 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}

View 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}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
assets/vendor/tinymce/tinymce.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View 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:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 386 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 379 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

After

Width:  |  Height:  |  Size: 465 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 304 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 412 KiB

Before After
Before After

View file

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

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

View file

@ -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;">'
. '&copy; ' . $year . ' ' . $safeTitle . $footer
. '</td></tr>'
. '</table></td></tr></table></body></html>';
}
/**
* Voucher-Code als hervorgehobene Karte für die E-Mail.
*/
private function voucherCardHtml(string $code, string $siteName, $maxUses): string
{
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
. 'style="margin:18px 0;background:#f8f9fb;border:1px solid #e5e8ee;border-radius:12px;">'
. '<tr><td align="center" style="padding:22px;">'
. '<div style="font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">'
. htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div style="margin:10px 0;font-family:Consolas,Menlo,monospace;font-size:28px;font-weight:700;letter-spacing:.12em;color:#101625;">'
. htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div style="font-size:13px;color:#525c6e;">'
. htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' '
. htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '</div>'
. '</td></tr></table>';
}
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
$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
View 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} &middot; {MAX_USES} ' . $devices . '</div>'
. '<div style="margin-top:16px;padding-top:14px;border-top:1px solid #e5e8ee;font-size:12px;color:#525c6e;text-align:left;">{INSTRUCTIONS}</div>'
. '</div>';
}
/**
* URL eines Bildes aus den Einstellungen.
* Hochgeladene Dateien liegen relativ zur Projektwurzel (uploads/),
* externe Adressen bleiben unveraendert.
*/
public static function mediaUrl(string $value, string $base = ''): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if (preg_match('#^(https?:)?//#i', $value) || strncmp($value, 'data:', 5) === 0 || $value[0] === '/') {
return $value;
}
return $base . $value;
}
/**
* Kompletter Standard-Kopf: Favicon, Schrift, Icons, Design-System,
* Theme-Bootstrap und Branding.
*/
public static function head($db = null, string $base = ''): string
{
$out = [];
$favicon = $db ? self::mediaUrl((string)$db->getSetting('favicon_url', ''), $base) : '';
if ($favicon !== '') {
$out[] = '<link rel="icon" href="' . htmlspecialchars($favicon) . '">';
}
$out[] = '<meta name="color-scheme" content="light dark">';
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/vendor/inter/inter.css', $base)) . '">';
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/vendor/fontawesome/fontawesome.css', $base)) . '">';
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/global.css', $base)) . '">';
$out[] = self::themeScript();
$branding = self::brandingStyle($db);
if ($branding !== '') {
$out[] = $branding;
}
return implode("\n ", $out);
}
}

142
includes/Upload.php Normal file
View 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;
}
}

View file

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

@ -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() {

View file

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

View file

@ -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' => '090 %. 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',

View file

@ -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' => '090%. 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',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

93
tests/UiTest.php Normal file
View 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
View 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
View file

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

42
tools/README.md Normal file
View 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
View 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())

View 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

View 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

View 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; }
}

View 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; }
}

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