Merge origin/main: Review-Branch mit v2.4.0-Features zusammenführen

Konfliktauflösung kombiniert beide Seiten:
- Auth: Secure-Cookie-Flag + DB-Session-Handler (main)
- UniFiController: createVouchers (n-Parameter, 1 API-Call) + QoS-Optionen (main)
- index.php: IP-Rate-Limit/PRG/Sticky-Forms + CAPTCHA/SMS/QoS/Tageslimit (main)
- users.php: 2FA-Reset (main) auf POST+PRG umgestellt wie übrige Aktionen
- forgot_password: Session-Throttle (main) + IP-Throttle kombiniert
- Eigene Migrationen wegen Nummernkollision auf 0005/0006 umbenannt

https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
This commit is contained in:
Claude 2026-06-09 20:03:32 +00:00
commit 1a2f86ed3d
No known key found for this signature in database
65 changed files with 3136 additions and 32 deletions

171
admin/api_keys.php Normal file
View file

@ -0,0 +1,171 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/ApiKey.php';
require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth();
$auth->requireAdmin();
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$error = '';
$success = '';
$newKey = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
$name = trim($_POST['name'] ?? '');
if ($name === '') {
$error = __('error_name_req');
} else {
$scope = ($_POST['scope'] ?? 'write') === 'read' ? 'read' : 'write';
$rate = max(0, (int)($_POST['rate_limit'] ?? 0));
$k = ApiKey::generate();
$db->execute(
"INSERT INTO api_keys (name, key_prefix, key_hash, scope, rate_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)",
[$name, $k['prefix'], $k['hash'], $scope, $rate, $_SESSION['user_id']]
);
$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!';
}
}
}
if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
$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.';
}
}
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.';
}
$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");
$csrf = $auth->getCsrfToken();
$currentPage = 'api_keys';
$adminBase = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API-Schlüssel <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.card { background: var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); }
.card h2 { font-size:16px; margin-bottom:16px; color:var(--text-primary); }
table { width:100%; border-collapse:collapse; }
th,td { text-align:left; padding:11px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
th { color:var(--text-muted); font-weight:600; }
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
.btn { padding:10px 16px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
.btn-primary { background:var(--accent,#667eea); color:#fff; }
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
.keybox { background:#0f1117; color:#7CFFB2; font-family:monospace; padding:14px; border-radius:8px; word-break:break-all; font-size:15px; margin-top:10px; }
.badge { padding:3px 9px; border-radius:6px; font-size:12px; font-weight:600; }
.b-on { background:#e3f6ea; color:#2a7; } .b-off { background:#fdeaea; color:#c33; }
.a-link { color:var(--accent,#667eea); text-decoration:none; margin-right:10px; font-size:13px; }
.muted { color:var(--text-muted); font-size:13px; }
</style>
</head>
<body>
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔑 API-Schlüssel</h1>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<?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>
<div class="keybox"><?= htmlspecialchars($newKey) ?></div>
</div>
<?php endif; ?>
<div class="card">
<h2>Neuen API-Schlüssel erstellen</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>
</div>
<div style="flex:1;min-width:130px;">
<label class="muted" style="display:block;margin-bottom:6px;">Berechtigung</label>
<select class="input" name="scope">
<option value="write">Lesen + Erstellen</option>
<option value="read">Nur Lesen</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">
</div>
<button class="btn btn-primary" type="submit" name="create_key">Erstellen</button>
</form>
</div>
<div class="card">
<h2>Vorhandene Schlüssel</h2>
<?php if (empty($keys)): ?>
<p class="muted">Noch keine API-Schlüssel angelegt.</p>
<?php else: ?>
<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>
<?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'] ? htmlspecialchars($k['last_used_at']) : '' ?></td>
<td class="muted"><?= 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:#e25555;" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('Schlüssel löschen?');">Löschen</a>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</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>
<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_…" \
-H "Content-Type: application/json" \
-d '{"site_id":1,"name":"API Gast","max_uses":1,"expire_minutes":480}'
# 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>
</div>
</div><!-- /main-content -->
<script src="../assets/global.js"></script>
</body>
</html>

159
admin/backup.php Normal file
View file

@ -0,0 +1,159 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth();
$auth->requireAdmin();
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$error = '';
$success = '';
// Export: JSON-Download
if (isset($_GET['export']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
$export = [
'meta' => [
'app' => 'unifi-voucher-tool',
'version' => '2.2.0',
'exported_at'=> date('c'),
'note' => 'Site-Passwörter sind mit dem APP_KEY dieser Installation verschlüsselt.',
],
'settings' => $db->fetchAll("SELECT setting_key, setting_value FROM settings"),
'sites' => $db->fetchAll("SELECT name, site_id, unifi_controller_url, unifi_username, unifi_password, is_active, public_access FROM sites"),
'voucher_templates' => $db->fetchAll("SELECT name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, is_active FROM voucher_templates"),
];
$auth->writeAuditLog($_SESSION['user_id'], 'config_export', 'config', null, 'Konfiguration exportiert');
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="voucher-config-' . date('Y-m-d') . '.json"');
echo json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
// Import
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.';
} 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.';
} else {
$importSites = isset($_POST['import_sites']);
$importTemplates = isset($_POST['import_templates']);
$importSettings = isset($_POST['import_settings']);
$counts = ['settings' => 0, 'sites' => 0, 'templates' => 0];
try {
if ($importSettings && !empty($data['settings'])) {
foreach ($data['settings'] as $s) {
// Cron-Token NICHT überschreiben (Sicherheit der Ziel-Installation)
if (($s['setting_key'] ?? '') === 'cron_token') continue;
$db->setSetting($s['setting_key'], $s['setting_value']);
$counts['settings']++;
}
}
if ($importSites && !empty($data['sites'])) {
foreach ($data['sites'] as $s) {
$exists = $db->fetchOne("SELECT id FROM sites WHERE name = ? AND site_id = ?", [$s['name'], $s['site_id']]);
if ($exists) {
$db->query(
"UPDATE sites SET unifi_controller_url=?, unifi_username=?, unifi_password=?, is_active=?, public_access=? WHERE id=?",
[$s['unifi_controller_url'], $s['unifi_username'], $s['unifi_password'], (int)$s['is_active'], (int)$s['public_access'], $exists['id']]
);
} else {
$db->query(
"INSERT INTO sites (name, site_id, unifi_controller_url, unifi_username, unifi_password, is_active, public_access) VALUES (?,?,?,?,?,?,?)",
[$s['name'], $s['site_id'], $s['unifi_controller_url'], $s['unifi_username'], $s['unifi_password'], (int)$s['is_active'], (int)$s['public_access']]
);
}
$counts['sites']++;
}
}
if ($importTemplates && !empty($data['voucher_templates'])) {
foreach ($data['voucher_templates'] as $t) {
$exists = $db->fetchOne("SELECT id FROM voucher_templates WHERE name = ?", [$t['name']]);
if (!$exists) {
$db->query(
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, is_active) VALUES (?,?,?,?,?,?,?,?)",
[$t['name'], (int)$t['max_uses'], (int)$t['expire_minutes'], $t['description'] ?? null,
$t['qos_rate_max_down'] ?? null, $t['qos_rate_max_up'] ?? null, $t['qos_usage_quota'] ?? null, (int)($t['is_active'] ?? 1)]
);
$counts['templates']++;
}
}
}
$auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert');
$success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile.";
} catch (Exception $e) {
$error = 'Import-Fehler: ' . $e->getMessage();
}
}
}
}
$csrf = $auth->getCsrfToken();
$currentPage = 'backup';
$adminBase = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backup & Restore <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
.card h2 { font-size:16px; margin-bottom:12px; color:var(--text-primary); }
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
.btn-primary { background:var(--accent,#667eea); color:#fff; }
.btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
label.chk { display:flex; align-items:center; gap:9px; margin:8px 0; color:var(--text-primary); font-size:14px; }
input[type=file] { margin:10px 0; color:var(--text-primary); }
</style>
</head>
<body>
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">💾 Backup &amp; Restore</h1>
<?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; ?>
<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>
</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>
<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>
</form>
</div>
</div><!-- /main-content -->
<script src="../assets/global.js"></script>
</body>
</html>

148
admin/import.php Normal file
View file

@ -0,0 +1,148 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/UniFiController.php';
require_once __DIR__ . '/../includes/Notifier.php';
require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth();
$auth->requireAdmin();
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
$defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
$error = '';
$success = '';
$results = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['do_import'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
try {
$siteId = (int)($_POST['site_id'] ?? 0);
$site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]);
if (!$site) throw new Exception('Site nicht gefunden');
// CSV-Quelle: Datei bevorzugt, sonst Textarea
$raw = '';
if (!empty($_FILES['csv']['tmp_name'])) {
$raw = file_get_contents($_FILES['csv']['tmp_name']);
} else {
$raw = (string)($_POST['csv_text'] ?? '');
}
$lines = preg_split('/\r\n|\r|\n/', trim($raw));
if (count($lines) > 200) throw new Exception('Maximal 200 Zeilen pro Import.');
$controller = new UniFiController(
$site['unifi_controller_url'], $site['unifi_username'],
Crypto::decrypt($site['unifi_password']), $site['site_id']
);
$created = 0;
foreach ($lines as $i => $line) {
$line = trim($line);
if ($line === '') continue;
$cols = str_getcsv($line);
$name = trim((string)($cols[0] ?? ''));
if ($name === '' || strtolower($name) === 'name') continue; // Header/leer überspringen
$maxUses = isset($cols[1]) && $cols[1] !== '' ? max(1, (int)$cols[1]) : $defaultMaxUses;
$expire = isset($cols[2]) && $cols[2] !== '' ? max(1, (int)$cols[2]) : $defaultExpire;
try {
$v = $controller->createVoucher(date('Y-m-d') . '_' . $name, $maxUses, $expire);
if (!is_array($v) || empty($v['formatted_code'])) throw new Exception('ungültige Antwort');
$db->execute(
"INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
VALUES (?, ?, ?, ?, ?, ?, ?)",
[$siteId, $_SESSION['user_id'], $v['code'], date('Y-m-d') . '_' . $name, $maxUses, $expire, $v['unifi_id'] ?? null]
);
$results[] = ['name' => $name, 'code' => $v['formatted_code'], 'ok' => true];
$created++;
} catch (Exception $e) {
$results[] = ['name' => $name, 'code' => $e->getMessage(), 'ok' => false];
}
}
if ($created > 0) {
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.";
} catch (Exception $e) {
$error = $e->getMessage();
}
}
}
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
$csrf = $auth->getCsrfToken();
$currentPage = 'import';
$adminBase = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSV-Import <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:20px; box-shadow:0 4px 14px var(--shadow); max-width:760px; }
.card h2 { font-size:15px; margin-bottom:12px; color:var(--text-primary); }
.muted { color:var(--text-muted); font-size:13px; margin-bottom:12px; }
label { display:block; font-size:13px; color:var(--text-secondary); margin:12px 0 6px; }
.input,textarea,select { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; font-family:inherit; }
textarea { min-height:140px; font-family:monospace; }
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; font-size:14px; cursor:pointer; background:var(--accent,#667eea); color:#fff; }
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:760px; }
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:8px; font-size:13px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
</style>
</head>
<body>
<h1 style="font-size:24px;margin-bottom:18px;color:var(--text-primary);">📥 Voucher-Import (CSV)</h1>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php if ($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>
<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>
<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>
<input class="input" type="file" name="csv" accept=".csv,text/csv">
<label> oder direkt einfügen</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>
</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>
<?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; ?>
</table>
</div>
<?php endif; ?>
</div><!-- /main-content -->
<script src="../assets/global.js"></script>
</body>
</html>

204
admin/integrations.php Normal file
View file

@ -0,0 +1,204 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Notifier.php';
require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth();
$auth->requireAdmin();
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$error = '';
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
$db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0');
$db->setSetting('session_driver', ($_POST['session_driver'] ?? 'php') === 'db' ? 'db' : 'php');
$cm = in_array($_POST['captcha_mode'] ?? 'off', ['off','math','hcaptcha'], true) ? $_POST['captcha_mode'] : 'off';
$db->setSetting('captcha_mode', $cm);
$db->setSetting('captcha_site_key', trim($_POST['captcha_site_key'] ?? ''));
if (!empty($_POST['captcha_secret'])) { $db->setSetting('captcha_secret', trim($_POST['captcha_secret'])); }
$db->setSetting('sms_enabled', isset($_POST['sms_enabled']) ? '1' : '0');
$db->setSetting('twilio_sid', trim($_POST['twilio_sid'] ?? ''));
$db->setSetting('twilio_from', trim($_POST['twilio_from'] ?? ''));
if (!empty($_POST['twilio_token'])) { $db->setSetting('twilio_token', trim($_POST['twilio_token'])); }
$db->setSetting('oidc_enabled', isset($_POST['oidc_enabled']) ? '1' : '0');
$db->setSetting('oidc_name', trim($_POST['oidc_name'] ?? 'SSO'));
$db->setSetting('oidc_client_id', trim($_POST['oidc_client_id'] ?? ''));
$db->setSetting('oidc_auth_url', trim($_POST['oidc_auth_url'] ?? ''));
$db->setSetting('oidc_token_url', trim($_POST['oidc_token_url'] ?? ''));
$db->setSetting('oidc_userinfo_url', trim($_POST['oidc_userinfo_url'] ?? ''));
$db->setSetting('oidc_scopes', trim($_POST['oidc_scopes'] ?? 'openid profile email'));
if (!empty($_POST['oidc_client_secret'])) { $db->setSetting('oidc_client_secret', trim($_POST['oidc_client_secret'])); }
$db->setSetting('user_daily_voucher_limit', max(0, (int)($_POST['user_daily_voucher_limit'] ?? 0)));
$db->setSetting('trusted_proxy', trim($_POST['trusted_proxy'] ?? ''));
$db->setSetting('webhook_enabled', isset($_POST['webhook_enabled']) ? '1' : '0');
$db->setSetting('webhook_url', trim($_POST['webhook_url'] ?? ''));
$db->setSetting('cleanup_expired_days', max(0, (int)($_POST['cleanup_expired_days'] ?? 0)));
$db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0)));
$db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30)));
$auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert');
$success = 'Einstellungen gespeichert.';
}
}
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).';
}
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
$sessionDriver = $db->getSetting('session_driver', 'php');
$captchaMode = $db->getSetting('captcha_mode', 'off');
$captchaSiteKey = $db->getSetting('captcha_site_key', '');
$captchaSecretSet = $db->getSetting('captcha_secret', '') !== '';
$smsEnabled = $db->getSetting('sms_enabled', '0') === '1';
$twilioSid = $db->getSetting('twilio_sid', '');
$twilioFrom = $db->getSetting('twilio_from', '');
$twilioTokenSet = $db->getSetting('twilio_token', '') !== '';
$oidcEnabled = $db->getSetting('oidc_enabled', '0') === '1';
$oidcName = $db->getSetting('oidc_name', 'SSO');
$oidcClientId = $db->getSetting('oidc_client_id', '');
$oidcAuthUrl = $db->getSetting('oidc_auth_url', '');
$oidcTokenUrl = $db->getSetting('oidc_token_url', '');
$oidcUserinfoUrl = $db->getSetting('oidc_userinfo_url', '');
$oidcScopes = $db->getSetting('oidc_scopes', 'openid profile email');
$oidcSecretSet = $db->getSetting('oidc_client_secret', '') !== '';
$dailyLimit = (int)$db->getSetting('user_daily_voucher_limit', 0);
$trustedProxy = $db->getSetting('trusted_proxy', '');
$webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1';
$webhookUrl = $db->getSetting('webhook_url', '');
$cleanupExpired = (int)$db->getSetting('cleanup_expired_days', 0);
$cleanupAudit = (int)$db->getSetting('cleanup_audit_days', 0);
$cleanupLogin = (int)$db->getSetting('cleanup_login_days', 30);
$lastCleanup = $db->getSetting('last_cleanup', '');
$csrf = $auth->getCsrfToken();
$currentPage = 'integrations';
$adminBase = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Integration & Wartung <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
.card h2 { font-size:16px; margin-bottom:6px; color:var(--text-primary); }
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
label { display:block; font-size:14px; color:var(--text-secondary); margin:14px 0 6px; }
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
.row { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
.chk { display:flex; align-items:center; gap:9px; margin-top:14px; color:var(--text-primary); font-size:14px; }
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
.btn-primary { background:var(--accent,#667eea); color:#fff; } .btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
</style>
</head>
<body>
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔧 Integration &amp; Wartung</h1>
<?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; ?>
<form method="post">
<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>
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
<label>Session-Speicher</label>
<select class="input" name="session_driver" style="max-width:240px;">
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>>PHP-Standard (Dateien)</option>
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>>Datenbank (ermöglicht „überall abmelden")</option>
</select>
<label>Captcha im öffentlichen Modus</label>
<select class="input" name="captcha_mode" style="max-width:240px;">
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option>
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>>Rechenaufgabe (ohne externen Dienst)</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>
</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>
<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>
<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>
</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>
<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>
</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>
<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>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>Authorization Endpoint</label><input class="input" type="url" name="oidc_auth_url" value="<?= htmlspecialchars($oidcAuthUrl) ?>" placeholder="https://idp/authorize">
<label>Token Endpoint</label><input class="input" type="url" name="oidc_token_url" value="<?= htmlspecialchars($oidcTokenUrl) ?>" placeholder="https://idp/token">
<label>Userinfo Endpoint</label><input class="input" type="url" name="oidc_userinfo_url" value="<?= htmlspecialchars($oidcUserinfoUrl) ?>" placeholder="https://idp/userinfo">
<label>Scopes</label><input class="input" type="text" name="oidc_scopes" value="<?= htmlspecialchars($oidcScopes) ?>">
</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; ?>
</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>
</div>
<button class="btn btn-primary" type="submit" name="save">Speichern</button>
</form>
</div><!-- /main-content -->
<script src="../assets/global.js"></script>
</body>
</html>

174
admin/reports.php Normal file
View file

@ -0,0 +1,174 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth();
$auth->requireAdmin();
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
// Zeitraum (Tage)
$days = max(1, min(365, (int)($_GET['days'] ?? 30)));
// CSV-Export
if (isset($_GET['export'])) {
$auth->requireAdmin();
header('Content-Type: text/csv; charset=utf-8');
$fn = 'report-' . $_GET['export'] . '-' . date('Y-m-d') . '.csv';
header('Content-Disposition: attachment; filename="' . $fn . '"');
$out = fopen('php://output', 'w');
fprintf($out, "\xEF\xBB\xBF"); // UTF-8 BOM für Excel
if ($_GET['export'] === 'per_site') {
fputcsv($out, ['Site', 'Gesamt', 'Gültig', 'Verwendet', 'Abgelaufen']);
$rows = $db->fetchAll(
"SELECT s.name,
COUNT(v.id) total,
SUM(v.status='valid') valid,
SUM(v.status='used') used,
SUM(v.status='expired') expired
FROM sites s LEFT JOIN vouchers v ON v.site_id=s.id
GROUP BY s.id ORDER BY total DESC"
);
foreach ($rows as $r) fputcsv($out, [$r['name'], (int)$r['total'], (int)$r['valid'], (int)$r['used'], (int)$r['expired']]);
} elseif ($_GET['export'] === 'per_user') {
fputcsv($out, ['Benutzer', 'E-Mail', 'Voucher erstellt']);
$rows = $db->fetchAll(
"SELECT u.name, u.email, COUNT(v.id) c FROM users u
LEFT JOIN vouchers v ON v.user_id=u.id GROUP BY u.id ORDER BY c DESC"
);
foreach ($rows as $r) fputcsv($out, [$r['name'], $r['email'], (int)$r['c']]);
} else { // daily
fputcsv($out, ['Datum', 'Erstellte Voucher']);
$rows = $db->fetchAll(
"SELECT DATE(created_at) d, COUNT(*) c FROM vouchers
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY DATE(created_at) ORDER BY d", [$days]
);
foreach ($rows as $r) fputcsv($out, [$r['d'], (int)$r['c']]);
}
fclose($out);
exit;
}
// Kennzahlen
$totals = $db->fetchOne(
"SELECT COUNT(*) total,
SUM(status='valid') valid, SUM(status='used') used, SUM(status='expired') expired
FROM vouchers"
);
$inPeriod = (int)($db->fetchOne(
"SELECT COUNT(*) c FROM vouchers WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)", [$days]
)['c'] ?? 0);
$perSite = $db->fetchAll(
"SELECT s.name,
COUNT(v.id) total, SUM(v.status='valid') valid, SUM(v.status='used') used, SUM(v.status='expired') expired
FROM sites s LEFT JOIN vouchers v ON v.site_id=s.id GROUP BY s.id ORDER BY total DESC"
);
$perUser = $db->fetchAll(
"SELECT u.name, COUNT(v.id) c FROM users u LEFT JOIN vouchers v ON v.user_id=u.id
GROUP BY u.id HAVING c > 0 ORDER BY c DESC LIMIT 10"
);
$daily = $db->fetchAll(
"SELECT DATE(created_at) d, COUNT(*) c FROM vouchers
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY DATE(created_at) ORDER BY d", [$days]
);
$chartLabels = array_map(fn($r) => date('d.m', strtotime($r['d'])), $daily);
$chartData = array_map(fn($r) => (int)$r['c'], $daily);
$csrf = $auth->getCsrfToken();
$currentPage = 'reports';
$adminBase = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<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>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:22px; margin-bottom:20px; box-shadow:0 4px 14px var(--shadow); }
.card h2 { font-size:15px; margin-bottom:14px; color:var(--text-primary); }
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px; }
.stat { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:20px; }
.stat .n { font-size:28px; font-weight:700; color:var(--text-primary); } .stat .l { color:var(--text-muted); font-size:12.5px; margin-top:3px; }
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:10px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); } th { color:var(--text-muted); }
.btn { padding:9px 15px; border:none; border-radius:8px; font-weight:600; font-size:13px; text-decoration:none; display:inline-block; cursor:pointer; }
.btn-s { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:18px; }
select.input { padding:9px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); }
@media print { .sidebar,.header,.toolbar,.no-print { display:none !important; } .main-content { margin:0 !important; } }
</style>
</head>
<body>
<h1 style="font-size:24px;margin-bottom:18px;color:var(--text-primary);">📊 Reporting</h1>
<div class="toolbar no-print">
<form method="get" style="display:flex;gap:8px;align-items:center;">
<label style="color:var(--text-muted);font-size:13px;">Zeitraum:</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>
<?php endforeach; ?>
</select>
</form>
<a class="btn btn-s" href="?export=daily&days=<?= $days ?>">⬇️ CSV (täglich)</a>
<a class="btn btn-s" href="?export=per_site">⬇️ CSV (pro Site)</a>
<a class="btn btn-s" href="?export=per_user">⬇️ CSV (pro Nutzer)</a>
<button class="btn btn-s" onclick="window.print()">🖨️ Drucken/PDF</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>
<div class="card">
<h2>Erstellte Voucher (<?= $days ?> Tage)</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>
<?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; ?>
</table>
</div>
<div class="card">
<h2>Top-Nutzer</h2>
<table><tr><th>Benutzer</th><th>Voucher erstellt</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; ?>
</table>
</div>
</div><!-- /main-content -->
<script src="../assets/global.js"></script>
<script>
new Chart(document.getElementById('chart'), {
type:'line',
data:{ labels: <?= json_encode($chartLabels) ?>, datasets:[{ label:'Voucher', data: <?= json_encode($chartData) ?>, borderColor:'#667eea', backgroundColor:'rgba(102,126,234,.15)', fill:true, tension:.3 }] },
options:{ plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true,ticks:{precision:0}}} }
});
</script>
</body>
</html>

194
admin/security.php Normal file
View file

@ -0,0 +1,194 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
$auth = new Auth();
$auth->requireLogin();
$db = Database::getInstance();
$user = $auth->getCurrentUser();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$error = '';
$success = '';
$backupCodes = []; // nur direkt nach Erzeugung gefüllt
$hasPassword = !empty($user['password_hash']);
$totpEnabled = !empty($user['totp_enabled']);
$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';
} else {
$secret = $_SESSION['totp_setup_secret'] ?? '';
$code = trim($_POST['code'] ?? '');
if ($secret === '') {
$error = 'Setup abgelaufen, bitte erneut starten.';
} elseif (!Totp::verify($secret, $code)) {
$error = 'Code ungültig. Bitte erneut versuchen.';
} 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!';
}
}
}
// Ü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';
} else {
$auth->logoutOtherSessions();
$success = 'Alle anderen Sitzungen wurden beendet.';
}
}
// Recovery-Codes neu erzeugen
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = 'Ungültiges Sicherheits-Token';
} elseif (!empty($user['totp_enabled'])) {
$backupCodes = $auth->regenerateBackupCodes($user['id']);
$user = $auth->getCurrentUser();
$success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.';
}
}
// 2FA deaktivieren
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = 'Ungültiges Sicherheits-Token';
} else {
$auth->disableTotp($user['id']);
$totpEnabled = false;
$success = 'Zwei-Faktor-Authentifizierung wurde deaktiviert.';
}
}
// Für die Setup-Ansicht ein Secret erzeugen (in Session halten bis bestätigt)
$setupSecret = '';
$otpUri = '';
if (!$totpEnabled && $hasPassword) {
$setupSecret = $_SESSION['totp_setup_secret'] ?? Totp::generateSecret();
$_SESSION['totp_setup_secret'] = $setupSecret;
$otpUri = Totp::provisioningUri($setupSecret, $user['email'], $appTitle);
}
$csrf = $auth->getCsrfToken();
$dbSessions = $db->getSetting('session_driver', 'php') === 'db';
$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zwei-Faktor-Authentifizierung <?= htmlspecialchars($appTitle) ?></title>
<?php if (!$totpEnabled && $hasPassword): ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<?php endif; ?>
<style>
* { margin:0; padding:0; box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
body { background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px; }
.card { background:#fff; border-radius:18px; box-shadow:0 20px 60px rgba(0,0,0,.3); max-width:480px; width:100%; padding:36px; }
h1 { font-size:22px; color:#333; margin-bottom:6px; }
.sub { color:#777; font-size:14px; margin-bottom:24px; }
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
.status { display:inline-flex; align-items:center; gap:8px; padding:6px 12px; border-radius:8px; font-size:13px; font-weight:600; margin-bottom:20px; }
.on { background:#e3f6ea; color:#2a7; } .off { background:#fdeaea; color:#c33; }
.qr { display:flex; justify-content:center; margin:18px 0; }
.secret { font-family:monospace; background:#f5f6fa; padding:10px; border-radius:8px; text-align:center; letter-spacing:2px; word-break:break-all; font-size:14px; margin-bottom:18px; }
ol { margin:0 0 18px 18px; color:#555; font-size:14px; line-height:1.7; }
label { display:block; font-size:14px; color:#555; margin-bottom:8px; font-weight:500; }
input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-radius:10px; font-size:18px; letter-spacing:6px; text-align:center; }
.btn { width:100%; padding:14px; border:none; border-radius:10px; font-size:15px; font-weight:600; cursor:pointer; margin-top:14px; }
.btn-primary { background:#667eea; color:#fff; } .btn-danger { background:#e25555; color:#fff; }
.back { display:block; text-align:center; margin-top:20px; color:#667eea; text-decoration:none; font-size:14px; }
.codes-box { background:#fff8e6; border:1px solid #ffe3a3; border-radius:10px; padding:16px; margin-bottom:18px; }
.codes-box strong { font-size:15px; } .codes-box p { color:#8a6d2f; font-size:13px; margin:6px 0 12px; }
.codes { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.codes span { font-family:monospace; background:#fff; border:1px solid #ffe3a3; border-radius:6px; padding:8px; text-align:center; letter-spacing:2px; font-size:14px; }
</style>
</head>
<body>
<div class="card">
<h1>🔐 Zwei-Faktor-Authentifizierung</h1>
<p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p>
<?php if ($setupRequired && !$totpEnabled): ?>
<div class="alert alert-error">Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.</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>🔑 Recovery-Codes</strong>
<p>Bewahren Sie diese sicher auf. Jeder Code funktioniert <em>einmal</em>, falls Sie keinen Zugriff auf Ihre App haben.</p>
<div class="codes">
<?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!$hasPassword): ?>
<div class="status off"> Nicht verfügbar</div>
<p class="sub">Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.</p>
<?php elseif ($totpEnabled): ?>
<div class="status on"> 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>
<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" style="background:#eef0ff;color:#5a63d6;width:100%;">Recovery-Codes neu erzeugen</button>
</form>
<form method="post" onsubmit="return confirm('2FA wirklich deaktivieren?');">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<button type="submit" name="disable_totp" class="btn btn-danger">2FA deaktivieren</button>
</form>
<?php else: ?>
<div class="status off"> Inaktiv</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>
</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>
<input type="text" id="code" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required>
<button type="submit" name="enable_totp" class="btn btn-primary">2FA aktivieren</button>
</form>
<script>
new QRCode(document.getElementById('qrcode'), {
text: <?= json_encode($otpUri) ?>, width: 180, height: 180,
correctLevel: QRCode.CorrectLevel.M
});
</script>
<?php endif; ?>
<?php if ($dbSessions): ?>
<hr style="margin:20px 0;border:none;border-top:1px solid #eee;">
<p class="sub">Aktive Sitzungen: <strong><?= (int)$activeSessions ?></strong></p>
<form method="post" onsubmit="return confirm('Alle anderen Sitzungen abmelden?');">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<button type="submit" name="logout_others" class="btn" style="background:#eef0ff;color:#5a63d6;width:100%;">Auf allen anderen Geräten abmelden</button>
</form>
<?php endif; ?>
<a class="back" href="../index.php"> Zurück</a>
</div>
</body>
</html>

View file

@ -30,13 +30,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) {
$expireMin = (int)($_POST['expire_minutes'] ?? 480);
$description = trim($_POST['description'] ?? '');
$qosDown = max(0, (int)($_POST['qos_rate_max_down'] ?? 0)) ?: null;
$qosUp = max(0, (int)($_POST['qos_rate_max_up'] ?? 0)) ?: null;
$qosQuota = max(0, (int)($_POST['qos_usage_quota'] ?? 0)) ?: null;
if (empty($name)) throw new Exception(__('error_name_req'));
if ($maxUses < 1) $maxUses = 1;
if ($expireMin < 1) $expireMin = 60;
$db->execute(
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, created_by) VALUES (?, ?, ?, ?, ?)",
[$name, $maxUses, $expireMin, $description, $_SESSION['user_id']]
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
);
flashSet(__('templates_added'));
header('Location: templates.php');
@ -60,11 +64,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
$description = trim($_POST['description'] ?? '');
$isActive = isset($_POST['is_active']) ? 1 : 0;
$qosDown = max(0, (int)($_POST['qos_rate_max_down'] ?? 0)) ?: null;
$qosUp = max(0, (int)($_POST['qos_rate_max_up'] ?? 0)) ?: null;
$qosQuota = max(0, (int)($_POST['qos_usage_quota'] ?? 0)) ?: null;
if (empty($name)) throw new Exception(__('error_name_req'));
$db->execute(
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, is_active=? WHERE id=?",
[$name, $maxUses, $expireMin, $description, $isActive, $id]
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?",
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id]
);
flashSet(__('templates_updated'));
header('Location: templates.php');
@ -209,7 +217,7 @@ $adminBase = '';
<?php endif; ?>
</td>
<td>
<button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>)"
<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>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= addslashes(__('confirm_delete_template')) ?>')">
@ -255,6 +263,11 @@ $adminBase = '';
</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 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="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
@ -287,6 +300,11 @@ $adminBase = '';
</div>
</div>
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" id="editDesc" rows="2"></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" id="editQosDown" min="0" placeholder="0 = unbegrenzt"></div>
<div class="form-group"><label>Upload (kbit/s)</label><input type="number" name="qos_rate_max_up" id="editQosUp" min="0" placeholder="0 = unbegrenzt"></div>
<div class="form-group"><label>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" id="editQosQuota" min="0" placeholder="0 = unbegrenzt"></div>
</div>
<div class="checkbox-group" style="margin-bottom:20px;">
<input type="checkbox" name="is_active" id="editActive">
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
@ -304,13 +322,16 @@ $adminBase = '';
<script>
function openAddModal() { document.getElementById('addModal').classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
function openEditModal(id, name, maxUses, expMin, desc, isActive) {
function openEditModal(id, name, maxUses, expMin, desc, isActive, qosDown, qosUp, qosQuota) {
document.getElementById('editId').value = id;
document.getElementById('editName').value = name;
document.getElementById('editMaxUses').value = maxUses;
document.getElementById('editExpireMin').value = expMin;
document.getElementById('editDesc').value = desc;
document.getElementById('editActive').checked = isActive == 1;
document.getElementById('editQosDown').value = qosDown || '';
document.getElementById('editQosUp').value = qosUp || '';
document.getElementById('editQosQuota').value = qosQuota || '';
document.getElementById('editModal').classList.add('active');
}
['addModal','editModal'].forEach(id => {

View file

@ -165,6 +165,17 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_user'])) {
} else { $error = __('error_csrf'); }
}
// 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator)
// POST + PRG wie die uebrigen state-aendernden Aktionen
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['reset_2fa'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$auth->disableTotp((int)$_POST['reset_2fa']);
flashSet('2FA des Benutzers wurde zurückgesetzt.');
header('Location: users.php');
exit;
} else { $error = __('error_csrf'); }
}
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
@ -326,6 +337,17 @@ $currentPage = 'users';
</button>
</form>
<?php endif; ?>
<?php if (!empty($user['totp_enabled'])): ?>
<form method="post" style="display:inline;"
onsubmit="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="reset_2fa" value="<?= $user['id'] ?>">
<button type="submit" class="btn btn-secondary btn-sm"
title="2FA zurücksetzen" aria-label="2FA zurücksetzen">
<i class="fas fa-user-shield"></i>
</button>
</form>
<?php endif; ?>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= addslashes(__('confirm_delete_user')) ?>')">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">

View file

@ -102,6 +102,25 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
exit;
}
// Voucher-Code per E-Mail (erneut) versenden
if (isset($_POST['ajax_resend']) && isset($_POST['voucher_id']) && isset($_POST['site_id'])) {
header('Content-Type: application/json');
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { echo json_encode(['success'=>false,'message'=>__('error_csrf')]); exit; }
require_once __DIR__ . '/../includes/Mailer.php';
$email = trim($_POST['email'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { echo json_encode(['success'=>false,'message'=>'Ungültige E-Mail-Adresse']); exit; }
$siteId = (int)$_POST['site_id'];
$site = $db->fetchOne("SELECT * FROM sites WHERE id=?", [$siteId]);
$v = $db->fetchOne("SELECT * FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$_POST['voucher_id'], $siteId]);
if (!$site || !$v) { echo json_encode(['success'=>false,'message'=>'Voucher nicht gefunden']); exit; }
$mailer = new Mailer();
$code = strpos($v['voucher_code'], '-') !== false ? $v['voucher_code'] : implode('-', str_split($v['voucher_code'], 5));
$ok = $mailer->sendVoucherEmail($email, $code, $site['name'], (int)$v['max_uses']);
$auth->writeAuditLog($_SESSION['user_id'], 'voucher_resend', 'voucher', $v['id'], "Code an $email gesendet");
echo json_encode(['success'=>$ok, 'message'=>$ok ? 'E-Mail versendet.' : 'Versand fehlgeschlagen.']);
exit;
}
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
$siteStats = [];
foreach ($sites as $site) {
@ -384,7 +403,10 @@ function renderVouchers() {
<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><button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button></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 btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button>
</td>
</tr>`;
});
@ -408,6 +430,20 @@ function renderVouchers() {
function escapeHtml(t) { const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
async function resendVoucher(voucherId, code) {
const email = prompt('Code ' + code + ' senden an (E-Mail):');
if (!email) return;
const fd = new FormData();
fd.append('ajax_resend','1'); fd.append('voucher_id',voucherId);
fd.append('site_id', currentSiteId); fd.append('email', email);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch('vouchers.php', {method:'POST', body:fd});
const d = await r.json();
(window.showToast ? showToast(d.message, d.success?'success':'error') : alert(d.message));
} catch(e){ alert('Fehler: '+e.message); }
}
async function deleteVoucher(voucherId) {
if (!confirm('<?= addslashes(__('confirm_delete_voucher')) ?>')) return;
const row = document.getElementById(`voucher-${voucherId}`);