Security-, Bugfix- und UX-Überarbeitung auf Basis des Code-Reviews
Sicherheit: - Bulk-Erstellung serverseitig auf eingeloggte Nutzer beschränkt; expire_minutes wird validiert (anonym: nur Default/Template-Werte, eingeloggt: max. 1 Jahr) - IP-basiertes Rate-Limit über neue Tabelle request_throttle (Voucher-Erstellung + Passwort-Reset-Anfragen), Session-Fallback für Alt-Installationen; Migration 0002 - session_regenerate_id() nach Login, Secure-Cookie-Flag bei HTTPS - Admin-/Aktiv-Status wird pro Request live aus der DB geprüft (Rechteentzug & Deaktivierung wirken sofort); Schutz vor Selbst-Degradierung im Benutzer-Edit - Alle state-ändernden Admin-Aktionen von GET auf POST umgestellt (kein CSRF-Token mehr in URLs) - login_simple.php (Legacy, Debug-Leak) entfernt; cron_test.php nur noch für Admins; .htaccess auf Apache-2.4-Syntax inkl. cron_test.php - M365 Client Secret wird nicht mehr ins Formular zurückgegeben - Updater: Zip-Slip-/Pfad-Traversal-Schutz, Backup vor dem Anwenden mit automatischem Rollback bei Fehlern, AuditLogger-Bug behoben - cron_sync: Token-Vergleich mit hash_equals; login_attempts-Pruning - CSV-Export gegen Excel-Formula-Injection abgesichert Bugfixes: - M365-Login: Fallback auf userPrincipalName, wenn Graph kein 'mail' liefert (Nutzer ohne Exchange-Postfach konnten sich nie anmelden) - PRG-Pattern überall: F5 erzeugt keine Duplikat-Voucher und wiederholt keine Admin-Aktionen (Session-Flash-Messages) - QR-Code nicht mehr invertiert (schwarz auf weiß, scanbar) - Bulk-Erstellung nutzt den UniFi 'n'-Parameter: 1 API-Call statt n× Login + Voucherlisten-Abruf; exaktes Code-Matching per create_time statt "global neuester Voucher" - Mailer: doppelte Zeilenumbrüche behoben, AUTH nur mit Credentials, SMTP-Dot-Stuffing, CLI-sicherer EHLO-Host - forgot_password: System-URL-Auto-Detect (Reset-Link war sonst relativ/kaputt) + Rate-Limit - Audit-Log-Labels an tatsächliche Action-Keys angepasst; Voucher-Erstellung (einzeln & bulk) wird jetzt auditiert - Site-Edit testet die Verbindung auch ohne Passwortänderung UX/UI: - Alert-/Badge-Styles zentral in global.css mit Dark-Mode-Variablen (vorher 7× dupliziert mit hart codierten Hellfarben) - Sticky-Formulare + Tab-Erhalt nach Validierungsfehlern (Bulk), Settings kehren nach dem Speichern zum aktiven Tab zurück - Gültigkeit menschenlesbar (z.B. "8 Stunden" statt "480 Minuten") - Voucher-Name-Default "Gast/Guest" im öffentlichen Modus - Favicon auch auf Login-/öffentlichen Seiten - Verbindungstest-Button pro Site-Karte (Health-Check) - i18n-Pass: Confirm-Dialoge, Toasts, Fehl-/Erfolgsmeldungen in de/en - Sprachumschalter ohne fetch+reload (kein Re-Submit-Dialog) - A11y: Esc schließt Modals, aria-live für Toasts, aria-labels auf Icon-Buttons; APP_KEY-Warnbanner im Dashboard - Dashboard-Sync: set_time_limit passend zur Site-Anzahl; Voucher-Sync mit Map statt SELECT pro Voucher Tooling: - GitHub-Actions-Workflow: PHP-Lint aller Dateien + de/en-Key-Parität https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
This commit is contained in:
parent
f747a3d429
commit
6e19958a37
31 changed files with 1040 additions and 628 deletions
216
index.php
216
index.php
|
|
@ -17,6 +17,7 @@ require_once __DIR__ . '/includes/Auth.php';
|
|||
require_once __DIR__ . '/includes/UniFiController.php';
|
||||
require_once __DIR__ . '/includes/Mailer.php';
|
||||
require_once __DIR__ . '/includes/I18n.php';
|
||||
require_once __DIR__ . '/includes/Helpers.php';
|
||||
|
||||
$auth = new Auth();
|
||||
$db = Database::getInstance();
|
||||
|
|
@ -24,29 +25,71 @@ $mailer = new Mailer();
|
|||
I18n::init();
|
||||
|
||||
/**
|
||||
* Session-basierter Throttle fuer die anonyme oeffentliche Voucher-Erstellung.
|
||||
* Erlaubt max. 10 Erstellungen in 10 Minuten pro Session. Verhindert, dass
|
||||
* der oeffentliche Modus zum Spammen des UniFi-Controllers missbraucht wird.
|
||||
* Throttle fuer die anonyme oeffentliche Voucher-Erstellung:
|
||||
* max. 10 Voucher in 10 Minuten. Primaer IP-basiert ueber die Tabelle
|
||||
* request_throttle (laesst sich nicht per Cookie-Loeschen umgehen);
|
||||
* Fallback auf den Session-Zaehler, falls die Tabelle auf einer alten
|
||||
* Installation noch fehlt (Migration 0002 nicht gelaufen).
|
||||
*/
|
||||
function isVoucherRateLimited() {
|
||||
$window = 600; // 10 Minuten
|
||||
$maxRequests = 10;
|
||||
function isVoucherRateLimited($db, $voucherCount = 1) {
|
||||
$window = 600; // 10 Minuten
|
||||
$maxVouchers = 10;
|
||||
|
||||
$limited = throttleHit($db, 'voucher_create', $maxVouchers, 10, $voucherCount);
|
||||
if ($limited !== null) {
|
||||
return $limited;
|
||||
}
|
||||
// Tabelle existiert noch nicht (Migration 0002 nicht gelaufen)
|
||||
// -> Session-Fallback (Legacy-Verhalten)
|
||||
$now = time();
|
||||
$timestamps = $_SESSION['voucher_create_times'] ?? [];
|
||||
$timestamps = array_values(array_filter($timestamps, function ($t) use ($now, $window) {
|
||||
return ($now - $t) < $window;
|
||||
}));
|
||||
if (count($timestamps) >= $maxRequests) {
|
||||
if (count($timestamps) + $voucherCount > $maxVouchers) {
|
||||
$_SESSION['voucher_create_times'] = $timestamps;
|
||||
return true;
|
||||
}
|
||||
$timestamps[] = $now;
|
||||
for ($i = 0; $i < $voucherCount; $i++) {
|
||||
$timestamps[] = $now;
|
||||
}
|
||||
$_SESSION['voucher_create_times'] = $timestamps;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert die Voucher-Gueltigkeit (Minuten). Anonyme Nutzer duerfen nur den
|
||||
* konfigurierten Default oder Werte aktiver Templates verwenden – das Feld ist
|
||||
* ein Hidden-Input und damit beliebig manipulierbar. Eingeloggte Nutzer werden
|
||||
* auf maximal 1 Jahr begrenzt.
|
||||
*/
|
||||
function sanitizeExpireMinutes($expireMinutes, $isLoggedIn, $templates, $defaultExpire) {
|
||||
$expireMinutes = (int)$expireMinutes;
|
||||
if ($isLoggedIn) {
|
||||
return max(1, min(525600, $expireMinutes));
|
||||
}
|
||||
$allowed = array_map(function ($t) { return (int)$t['expire_minutes']; }, $templates);
|
||||
$allowed[] = $defaultExpire;
|
||||
return in_array($expireMinutes, $allowed, true) ? $expireMinutes : $defaultExpire;
|
||||
}
|
||||
|
||||
/** Minuten menschenlesbar formatieren (z.B. 480 -> "8 Stunden"). */
|
||||
function formatDuration($minutes) {
|
||||
$minutes = (int)$minutes;
|
||||
if ($minutes >= 1440 && $minutes % 1440 === 0) {
|
||||
$days = $minutes / 1440;
|
||||
return $days === 1 ? __('dur_day_one') : __('dur_days', ['n' => $days]);
|
||||
}
|
||||
if ($minutes >= 60 && $minutes % 60 === 0) {
|
||||
$hours = $minutes / 60;
|
||||
return $hours === 1 ? __('dur_hour_one') : __('dur_hours', ['n' => $hours]);
|
||||
}
|
||||
return __('dur_minutes', ['n' => $minutes]);
|
||||
}
|
||||
|
||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||
$logoUrl = $db->getSetting('logo_url', '');
|
||||
$faviconUrl = $db->getSetting('favicon_url', '');
|
||||
$instructionHeader = $db->getSetting('instruction_header', '');
|
||||
$instructionText = $db->getSetting('instruction_text', '');
|
||||
$publicAccess = $db->getSetting('public_access', 0);
|
||||
|
|
@ -128,14 +171,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
|||
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
// CSRF fuer ALLE (auch anonyme oeffentliche Erstellung)
|
||||
$error = __('error_csrf');
|
||||
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
|
||||
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
|
||||
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited($db)) {
|
||||
$error = __('error_rate_limited');
|
||||
} else {
|
||||
try {
|
||||
$siteId = (int)($_POST['site_id'] ?? 0);
|
||||
$voucherName = trim((string)($_POST['voucher_name'] ?? ''));
|
||||
$maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses);
|
||||
$expireMinutes = max(1, (int)($_POST['expire_minutes'] ?? $defaultExpire));
|
||||
$expireMinutes = sanitizeExpireMinutes($_POST['expire_minutes'] ?? $defaultExpire, $auth->isLoggedIn(), $templates, $defaultExpire);
|
||||
$sendEmail = isset($_POST['send_email']) && !empty($_POST['recipient_email']);
|
||||
$recipientEmail= trim((string)($_POST['recipient_email'] ?? ''));
|
||||
|
||||
|
|
@ -156,57 +199,124 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
|||
|
||||
if ($sendEmail && !empty($recipientEmail)) {
|
||||
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
|
||||
$success = 'Voucher erstellt. E-Mail versendet.';
|
||||
$success = __('voucher_created_mail');
|
||||
} else {
|
||||
$success = 'Voucher erfolgreich erstellt!';
|
||||
$success = __('voucher_created_ok');
|
||||
}
|
||||
|
||||
$auth->writeAuditLog($userId, 'voucher_create', 'voucher', null,
|
||||
"Voucher '{$voucherName}' für {$site['name']}" . ($userId === null ? ' (öffentlich)' : ''));
|
||||
|
||||
// PRG-Pattern: Redirect nach erfolgreichem POST, damit ein Reload
|
||||
// (F5) keinen Duplikat-Voucher erzeugt. Ergebnis via Session-Flash.
|
||||
$_SESSION['voucher_flash'] = ['type' => 'single', 'data' => $voucherData, 'success' => $success];
|
||||
header('Location: index.php?created=1');
|
||||
exit;
|
||||
} catch (Exception $e) {
|
||||
$error = 'Fehler: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk voucher creation
|
||||
// Bulk voucher creation – nur fuer eingeloggte Nutzer. Das Formular wird
|
||||
// Anonymen zwar nicht angezeigt, der POST-Endpunkt muss es aber ebenfalls
|
||||
// serverseitig erzwingen (sonst 20 Voucher pro Request im Public-Modus).
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
|
||||
if (!$publicAccess && !$auth->isLoggedIn()) {
|
||||
if (!$auth->isLoggedIn()) {
|
||||
$error = __('error_login_req');
|
||||
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
// CSRF fuer ALLE (auch anonyme oeffentliche Erstellung)
|
||||
$error = __('error_csrf');
|
||||
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
|
||||
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
|
||||
} else {
|
||||
try {
|
||||
$siteId = (int)($_POST['site_id'] ?? 0);
|
||||
$voucherName = trim((string)($_POST['voucher_name'] ?? ''));
|
||||
$maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses);
|
||||
$expireMinutes = max(1, (int)($_POST['expire_minutes'] ?? $defaultExpire));
|
||||
$expireMinutes = sanitizeExpireMinutes($_POST['expire_minutes'] ?? $defaultExpire, true, $templates, $defaultExpire);
|
||||
$bulkCount = max(1, min(20, (int)($_POST['bulk_count'] ?? 1)));
|
||||
|
||||
if (empty($voucherName)) throw new Exception(__('error_name_req'));
|
||||
if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit]));
|
||||
if ($siteId <= 0) throw new Exception(__('error_site_req'));
|
||||
if ($auth->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
|
||||
if (!$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
|
||||
|
||||
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
||||
if (!$site) throw new Exception(__('error_site_not_found'));
|
||||
|
||||
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null;
|
||||
$userId = $_SESSION['user_id'] ?? null;
|
||||
|
||||
for ($i = 0; $i < $bulkCount; $i++) {
|
||||
$bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId);
|
||||
// Alle Voucher in EINEM UniFi-API-Call erstellen ('n'-Parameter)
|
||||
// statt pro Voucher Login + Voucherliste abzurufen.
|
||||
$fullName = date('Y-m-d') . '_' . $voucherName;
|
||||
$controller = new UniFiController(
|
||||
$site['unifi_controller_url'],
|
||||
$site['unifi_username'],
|
||||
Crypto::decrypt($site['unifi_password']),
|
||||
$site['site_id']
|
||||
);
|
||||
$created = $controller->createVouchers($fullName, $maxUses, $expireMinutes, $bulkCount);
|
||||
$expiryTs = time() + ($expireMinutes * 60);
|
||||
|
||||
foreach ($created as $i => $voucher) {
|
||||
$db->execute(
|
||||
"INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[$site['id'], $userId, $voucher['code'], $fullName . '_' . ($i + 1), $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
|
||||
);
|
||||
$bulkVouchers[] = [
|
||||
'code' => $voucher['formatted_code'],
|
||||
'site_name' => $site['name'],
|
||||
'max_uses' => $maxUses,
|
||||
'expire_min' => $expireMinutes,
|
||||
'expiry_date' => date('d.m.Y', $expiryTs),
|
||||
'expiry_time' => date('H:i', $expiryTs),
|
||||
];
|
||||
}
|
||||
|
||||
$bulkCreated = true;
|
||||
$success = str_replace('{count}', $bulkCount, __('bulk_success'));
|
||||
$auth->writeAuditLog($userId, 'voucher_bulk', 'voucher', null,
|
||||
count($created) . " Vouchers '{$voucherName}' für {$site['name']}");
|
||||
|
||||
$success = str_replace('{count}', count($created), __('bulk_success'));
|
||||
|
||||
// PRG-Pattern: Reload darf die Bulk-Erstellung nicht wiederholen.
|
||||
$_SESSION['voucher_flash'] = ['type' => 'bulk', 'data' => $bulkVouchers, 'success' => $success];
|
||||
header('Location: index.php?created=1');
|
||||
exit;
|
||||
} catch (Exception $e) {
|
||||
$error = 'Fehler: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRG: Ergebnis nach Redirect aus dem Session-Flash wiederherstellen.
|
||||
// Der Flash bleibt fuer Reloads der Ergebnisseite erhalten und wird beim
|
||||
// Zurueckkehren zum Formular (GET ohne ?created) verworfen.
|
||||
if (isset($_GET['created']) && !empty($_SESSION['voucher_flash'])) {
|
||||
$flash = $_SESSION['voucher_flash'];
|
||||
if (($flash['type'] ?? '') === 'bulk') {
|
||||
$bulkVouchers = $flash['data'];
|
||||
$bulkCreated = true;
|
||||
} else {
|
||||
$voucherData = $flash['data'];
|
||||
$voucherCode = $voucherData['code'];
|
||||
$voucherCreated = true;
|
||||
}
|
||||
$success = $flash['success'] ?? '';
|
||||
} elseif ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
unset($_SESSION['voucher_flash']);
|
||||
}
|
||||
|
||||
$currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null;
|
||||
|
||||
// Bei Validierungsfehlern: eingegebene Werte und aktiven Tab erhalten
|
||||
$activeMode = ($error && isset($_POST['create_bulk'])) ? 'bulk' : 'single';
|
||||
$stickyName = $error ? trim((string)($_POST['voucher_name'] ?? '')) : '';
|
||||
$stickyMaxUses = $error ? (int)($_POST['max_uses'] ?? $defaultMaxUses) : $defaultMaxUses;
|
||||
$stickyBulkCount = $error ? max(1, min(20, (int)($_POST['bulk_count'] ?? 5))) : 5;
|
||||
$stickySiteId = $error ? (int)($_POST['site_id'] ?? 0) : 0;
|
||||
if ($stickyMaxUses < 1 || $stickyMaxUses > $maxUsesLimit) $stickyMaxUses = $defaultMaxUses;
|
||||
// Anonyme Gaeste wissen oft nicht, was sie als Namen eintragen sollen -> Default
|
||||
if ($stickyName === '' && !$auth->isLoggedIn()) $stickyName = __('voucher_name_default');
|
||||
|
||||
// Build print HTML for each voucher
|
||||
function buildPrintCard($template, $data, $instructionHeader, $instructionText, $appTitle) {
|
||||
$instructions = $instructionHeader || $instructionText
|
||||
|
|
@ -225,6 +335,9 @@ 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>
|
||||
<?php if ($faviconUrl): ?>
|
||||
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="stylesheet" href="assets/global.css">
|
||||
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
||||
<?php if ($voucherCreated): ?>
|
||||
|
|
@ -241,9 +354,6 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
.container { max-width: 600px; margin: 0 auto; background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); padding: 40px; }
|
||||
h1 { text-align: center; color: var(--text-primary); margin-bottom: 30px; font-size: 28px; }
|
||||
.logo { max-width: 250px; display: block; margin: 0 auto 30px; }
|
||||
.alert { padding: 14px; border-radius: 10px; margin-bottom: 25px; font-size: 14px; }
|
||||
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
|
||||
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
|
||||
input[type="text"], input[type="number"], input[type="email"], select { width: 100%; padding: 14px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; transition: all 0.2s; background: var(--bg-input); color: var(--text-primary); }
|
||||
|
|
@ -349,11 +459,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
|
||||
<div class="voucher-result no-print">
|
||||
<div style="font-size:18px;margin-bottom:10px;"><?= __('voucher_success_title') ?></div>
|
||||
<div class="voucher-code" id="voucherCode" onclick="copyCode()" title="Klicken zum Kopieren">
|
||||
<div class="voucher-code" id="voucherCode" onclick="copyCode()" title="<?= __('click_to_copy') ?>">
|
||||
<?= htmlspecialchars($voucherCode) ?>
|
||||
</div>
|
||||
<div class="voucher-info">
|
||||
<?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?>
|
||||
<?= str_replace('{duration}', formatDuration($voucherData['expire_min']), __('voucher_validity')) ?>
|
||||
</div>
|
||||
<div class="qr-wrapper no-print">
|
||||
<div id="qrcode"></div>
|
||||
|
|
@ -401,8 +511,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']) ?>', '<?= addslashes(__('toast_copied')) ?>')"
|
||||
title="<?= __('click_to_copy') ?>"><?= htmlspecialchars($bv['code']) ?></code>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($bv['site_name']) ?></td>
|
||||
<td><?= $bv['expiry_date'] ?> <?= $bv['expiry_time'] ?></td>
|
||||
|
|
@ -410,7 +520,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="copy-hint" style="margin-top:8px;">Code anklicken zum Kopieren</p>
|
||||
<p class="copy-hint" style="margin-top:8px;"><?= __('copy_hint') ?></p>
|
||||
</div>
|
||||
|
||||
<div class="no-print" style="display:flex;gap:10px;margin-top:20px;">
|
||||
|
|
@ -473,13 +583,14 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<div class="form-group">
|
||||
<label for="voucher_name"><?= __('voucher_name_label') ?></label>
|
||||
<input type="text" id="voucher_name" name="voucher_name"
|
||||
value="<?= htmlspecialchars($stickyName) ?>"
|
||||
placeholder="<?= __('voucher_name_hint') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max_uses"><?= __('voucher_devices_label') ?></label>
|
||||
<input type="number" id="max_uses" name="max_uses"
|
||||
min="1" max="<?= $maxUsesLimit ?>" value="<?= $defaultMaxUses ?>" required>
|
||||
min="1" max="<?= $maxUsesLimit ?>" value="<?= $stickyMaxUses ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -489,7 +600,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<option value=""><?= __('voucher_site_select') ?></option>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($sites as $site): ?>
|
||||
<option value="<?= (int)$site['id'] ?>" <?= ($autoSelectSite == $site['id']) ? 'selected' : '' ?>>
|
||||
<option value="<?= (int)$site['id'] ?>" <?= (($stickySiteId ?: $autoSelectSite) == $site['id']) ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($site['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
|
|
@ -520,7 +631,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk creation form -->
|
||||
<!-- Bulk creation form (nur fuer eingeloggte Nutzer, serverseitig erzwungen) -->
|
||||
<?php if ($auth->isLoggedIn()): ?>
|
||||
<div id="mode-bulk" style="display:none;">
|
||||
<form method="post" id="bulkForm">
|
||||
<input type="hidden" name="create_bulk" value="1">
|
||||
|
|
@ -530,20 +642,21 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<div class="form-group">
|
||||
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
|
||||
<input type="number" id="bulk_count" name="bulk_count"
|
||||
min="1" max="20" value="5" required>
|
||||
min="1" max="20" value="<?= $stickyBulkCount ?>" required>
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-top:5px;"><?= __('bulk_quantity_hint') ?></p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bulk_voucher_name"><?= __('bulk_name_prefix') ?></label>
|
||||
<input type="text" id="bulk_voucher_name" name="voucher_name"
|
||||
value="<?= $activeMode === 'bulk' ? htmlspecialchars($stickyName) : '' ?>"
|
||||
placeholder="<?= __('voucher_name_hint') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bulk_max_uses"><?= __('voucher_devices_label') ?></label>
|
||||
<input type="number" id="bulk_max_uses" name="max_uses"
|
||||
min="1" max="<?= $maxUsesLimit ?>" value="<?= $defaultMaxUses ?>" required>
|
||||
min="1" max="<?= $maxUsesLimit ?>" value="<?= $stickyMaxUses ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -553,7 +666,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<option value=""><?= __('voucher_site_select') ?></option>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($sites as $site): ?>
|
||||
<option value="<?= (int)$site['id'] ?>" <?= ($autoSelectSite == $site['id']) ? 'selected' : '' ?>>
|
||||
<option value="<?= (int)$site['id'] ?>" <?= (($stickySiteId ?: $autoSelectSite) == $site['id']) ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($site['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
|
|
@ -561,10 +674,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
</div>
|
||||
|
||||
<button type="submit" class="btn" id="bulkSubmitBtn">
|
||||
<?= str_replace('{count}', '<span id="bulkCountLabel">5</span>', __('bulk_create_btn')) ?>
|
||||
<?= str_replace('{count}', '<span id="bulkCountLabel">' . $stickyBulkCount . '</span>', __('bulk_create_btn')) ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($instructionHeader || $instructionText): ?>
|
||||
<div class="instruction-box" style="margin-top:25px;">
|
||||
|
|
@ -581,16 +695,18 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
<script>
|
||||
<?php if ($voucherCreated): ?>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Dunkle Module auf weissem Grund: invertierte QR-Codes (hell auf
|
||||
// dunkel) werden von vielen Kamera-Apps nicht erkannt.
|
||||
new QRCode(document.getElementById('qrcode'), {
|
||||
text: '<?= addslashes($voucherCode) ?>',
|
||||
width: 160, height: 160,
|
||||
colorDark: '#ffffff', colorLight: 'transparent',
|
||||
colorDark: '#000000', colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
});
|
||||
|
||||
function copyCode() {
|
||||
copyToClipboard('<?= addslashes($voucherCode) ?>', 'Code kopiert!');
|
||||
copyToClipboard('<?= addslashes($voucherCode) ?>', '<?= addslashes(__('toast_copied')) ?>');
|
||||
}
|
||||
<?php endif; ?>
|
||||
|
||||
|
|
@ -613,8 +729,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
}
|
||||
|
||||
function switchMode(mode) {
|
||||
document.getElementById('mode-single').style.display = mode === 'single' ? '' : 'none';
|
||||
document.getElementById('mode-bulk').style.display = mode === 'bulk' ? '' : 'none';
|
||||
const single = document.getElementById('mode-single');
|
||||
const bulk = document.getElementById('mode-bulk');
|
||||
if (!single || !bulk) return; // Bulk existiert nur fuer eingeloggte Nutzer
|
||||
single.style.display = mode === 'single' ? '' : 'none';
|
||||
bulk.style.display = mode === 'bulk' ? '' : 'none';
|
||||
document.getElementById('tab-single').classList.toggle('active', mode === 'single');
|
||||
document.getElementById('tab-bulk').classList.toggle('active', mode === 'bulk');
|
||||
}
|
||||
|
|
@ -624,8 +743,10 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
const expire = opt.value ? parseInt(opt.dataset.expire) : <?= $defaultExpire ?>;
|
||||
const maxUses = opt.value ? parseInt(opt.dataset.maxUses) : <?= $defaultMaxUses ?>;
|
||||
|
||||
document.getElementById('expire_minutes').value = expire;
|
||||
document.getElementById('bulk_expire_minutes').value = expire;
|
||||
const expEl = document.getElementById('expire_minutes');
|
||||
if (expEl) expEl.value = expire;
|
||||
const bexpEl = document.getElementById('bulk_expire_minutes');
|
||||
if (bexpEl) bexpEl.value = expire;
|
||||
const muEl = document.getElementById('max_uses');
|
||||
if (muEl) muEl.value = maxUses;
|
||||
const bmuEl = document.getElementById('bulk_max_uses');
|
||||
|
|
@ -662,6 +783,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleEmailField?.();
|
||||
<?php if ($activeMode === 'bulk'): ?>
|
||||
switchMode('bulk'); // Nach Fehler im Bulk-Formular im Bulk-Tab bleiben
|
||||
<?php endif; ?>
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue