feat: comprehensive UI/UX and feature improvements

- Dark mode: CSS custom properties (global.css) + toggle button, persisted in localStorage
- i18n: German/English language switcher (lang/de.php, lang/en.php, includes/I18n.php)
- Mobile-responsive admin layout: hamburger menu, sidebar overlay (global.js + global.css)
- Shared admin navigation include (includes/admin_nav.php) used across all admin pages
- Toast notifications system globally available via global.js
- Voucher templates/profiles: CRUD UI at admin/templates.php with voucher_templates DB table
- Bulk voucher creation: create 1-20 vouchers at once with multi-print layout on index.php
- Configurable voucher defaults: expire time, device limit, max limit in admin settings
- Template quick-select on voucher form: auto-fills max_uses and expire_minutes
- Password reset flow: forgot_password.php + reset_password.php with token-based reset
- Audit log UI: admin/audit_log.php with filter, pagination, audit_log DB table
- Audit logging on login, user create/edit/delete, site create/edit/delete
- Admin pages updated: index, vouchers, users, sites all use admin_nav.php + dark mode + i18n
- Voucher admin: live search input added alongside existing status filter + pagination
- Users admin: password-reset-link button per user row (when SMTP enabled)
- Login page: i18n, dark mode, language switcher, forgot password link

https://claude.ai/code/session_01YN6Bcm1VSi8mpDeyKpyrdJ
This commit is contained in:
Claude 2026-05-08 17:59:17 +00:00
parent bf3e55a967
commit a1021a0f84
No known key found for this signature in database
20 changed files with 5281 additions and 5471 deletions

226
admin/audit_log.php Normal file
View file

@ -0,0 +1,226 @@
<?php
error_reporting(E_ALL);
ini_set('display_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');
$filterAction = trim($_GET['action'] ?? '');
$filterUser = trim($_GET['user_id'] ?? '');
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 50;
$offset = ($page - 1) * $perPage;
$where = [];
$params = [];
if ($filterAction !== '') { $where[] = 'a.action = ?'; $params[] = $filterAction; }
if ($filterUser !== '') { $where[] = 'a.user_id = ?'; $params[] = (int)$filterUser; }
$whereStr = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$total = (int)$db->fetchOne("SELECT COUNT(*) as c FROM audit_log a $whereStr", $params)['c'];
$pages = max(1, (int)ceil($total / $perPage));
$logs = $db->fetchAll(
"SELECT a.*, u.name as user_name, u.email as user_email
FROM audit_log a LEFT JOIN users u ON a.user_id = u.id
$whereStr ORDER BY a.created_at DESC LIMIT ? OFFSET ?",
array_merge($params, [$perPage, $offset])
);
// Distinct actions for filter
$actions = $db->fetchAll("SELECT DISTINCT action FROM audit_log ORDER BY action");
// User list for filter
$users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY name");
$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',
];
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.page-header { margin-bottom: 25px; }
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.card { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 20px; }
.card-header { padding: 18px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 11px 15px; background: var(--bg-table-head); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.table td { padding: 12px 15px; border-bottom: 1px solid var(--border-color); font-size: 13px; color: var(--text-primary); }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: var(--bg-hover); }
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; }
.filter-bar { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
.filter-bar select { padding: 9px 12px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 13px; background: var(--bg-input); color: var(--text-primary); }
.filter-bar select:focus { outline: none; border-color: var(--accent); }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-small { padding: 6px 12px; font-size: 12px; }
.ip-cell { font-family: monospace; font-size: 12px; color: var(--text-muted); }
.details-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); }
.pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 25px; border-top: 1px solid var(--border-color); flex-wrap: wrap; gap: 10px; }
.page-info { font-size: 13px; color: var(--text-muted); }
.page-btns { display: flex; gap: 5px; flex-wrap: wrap; }
.page-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-decoration: none; transition: all 0.2s; }
.page-btn:hover { border-color: var(--accent); color: var(--accent); }
.page-btn.active { background: var(--accent); color: white; border-color: var(--accent); }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
.empty-state i { font-size: 42px; margin-bottom: 15px; display: block; opacity: 0.3; }
.action-chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; background: var(--bg-hover); color: var(--text-secondary); }
</style>
</head>
<div class="page-header">
<h1 class="page-title"><?= __('audit_title') ?></h1>
<p style="color: var(--text-muted); font-size: 14px;"><?= __('audit_subtitle') ?></p>
</div>
<!-- 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 style="padding: 20px 25px;">
<form method="get" class="filter-bar">
<div>
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:5px;"><?= __('audit_action') ?></label>
<select name="action">
<option value=""><?= __('audit_filter_all') ?></option>
<?php foreach ($actions as $a): ?>
<option value="<?= htmlspecialchars($a['action']) ?>" <?= $filterAction === $a['action'] ? 'selected' : '' ?>>
<?= htmlspecialchars($actionLabels[$a['action']] ?? $a['action']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<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>
<?php foreach ($users as $u): ?>
<option value="<?= $u['id'] ?>" <?= (string)$filterUser === (string)$u['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($u['name']) ?>
</option>
<?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>
</form>
</div>
</div>
<!-- Log-Tabelle -->
<div class="card">
<div class="card-header">
<span class="card-title"><i class="fas fa-history"></i> <?= __('audit_title') ?></span>
<span style="font-size:13px;color:var(--text-muted);"><?= number_format($total) ?> Einträge</span>
</div>
<?php if (empty($logs)): ?>
<div class="empty-state"><i class="fas fa-history"></i><p><?= __('audit_none') ?></p></div>
<?php else: ?>
<div style="overflow-x:auto;">
<table class="table">
<thead>
<tr>
<th><?= __('audit_time') ?></th>
<th><?= __('audit_action') ?></th>
<th><?= __('audit_user') ?></th>
<th><?= __('audit_entity') ?></th>
<th><?= __('audit_details') ?></th>
<th><?= __('audit_ip') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $log): ?>
<tr>
<td 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>
<span class="action-chip">
<?= htmlspecialchars($actionLabels[$log['action']] ?? $log['action']) ?>
</span>
</td>
<td>
<?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>
<?php endif; ?>
</td>
<td 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'] ?? '') ?>">
<?= htmlspecialchars(mb_strimwidth($log['details'] ?? '-', 0, 80, '…')) ?>
</td>
<td class="ip-cell"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($pages > 1): ?>
<div class="pagination">
<span class="page-info">Seite <?= $page ?> von <?= $pages ?> (<?= $total ?> Einträge)</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>
<?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>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div><!-- main-content -->
<script src="../assets/global.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -6,20 +6,21 @@ require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/UniFiController.php'; require_once __DIR__ . '/../includes/UniFiController.php';
require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
$db = Database::getInstance(); $db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
I18n::init();
$error = ''; $error = '';
$success = ''; $success = '';
// Site bearbeiten // Edit site
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) {
$error = 'Ungültiges Sicherheits-Token'; $error = __('error_csrf');
} else { } else {
try { try {
$siteId = (int)$_POST['site_id']; $siteId = (int)$_POST['site_id'];
@ -29,43 +30,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_site'])) {
$username = trim($_POST['username']); $username = trim($_POST['username']);
$password = $_POST['password']; $password = $_POST['password'];
$publicAccess = isset($_POST['public_access']) ? 1 : 0; $publicAccess = isset($_POST['public_access']) ? 1 : 0;
if (empty($name)||empty($siteIdStr)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
if (empty($name) || empty($siteIdStr) || empty($controllerUrl) || empty($username)) {
throw new Exception('Bitte füllen Sie alle Pflichtfelder aus');
}
// Wenn neues Passwort, Verbindung testen
if (!empty($password)) { if (!empty($password)) {
$testResult = UniFiController::testConnection($controllerUrl, $username, $password, $siteIdStr); $test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr);
if ($testResult !== true) { if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test);
throw new Exception('Verbindung fehlgeschlagen: ' . $testResult); $db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=? WHERE id=?",
} [$name,$siteIdStr,$controllerUrl,$username,$password,$publicAccess,$siteId]);
// Mit neuem Passwort aktualisieren
$db->execute(
"UPDATE sites SET name = ?, site_id = ?, unifi_controller_url = ?, unifi_username = ?, unifi_password = ?, public_access = ? WHERE id = ?",
[$name, $siteIdStr, $controllerUrl, $username, $password, $publicAccess, $siteId]
);
} else { } else {
// Ohne Passwort-Änderung $db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=? WHERE id=?",
$db->execute( [$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$siteId]);
"UPDATE sites SET name = ?, site_id = ?, unifi_controller_url = ?, unifi_username = ?, public_access = ? WHERE id = ?",
[$name, $siteIdStr, $controllerUrl, $username, $publicAccess, $siteId]
);
}
$success = 'Site erfolgreich aktualisiert!';
} catch (Exception $e) {
$error = $e->getMessage();
} }
$auth->writeAuditLog($_SESSION['user_id'],'site_edit','site',$siteId,"Site {$name} aktualisiert");
$success = __('sites_updated');
} catch (Exception $e) { $error = $e->getMessage(); }
} }
} }
// Site hinzufügen // Add site
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_site'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_site'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) {
$error = 'Ungültiges Sicherheits-Token'; $error = __('error_csrf');
} else { } else {
try { try {
$name = trim($_POST['name']); $name = trim($_POST['name']);
@ -74,376 +58,111 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_site'])) {
$username = trim($_POST['username']); $username = trim($_POST['username']);
$password = $_POST['password']; $password = $_POST['password'];
$publicAccess = isset($_POST['public_access']) ? 1 : 0; $publicAccess = isset($_POST['public_access']) ? 1 : 0;
if (empty($name)||empty($siteId)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
if (empty($name) || empty($siteId) || empty($controllerUrl) || empty($username)) { $test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId);
throw new Exception('Bitte füllen Sie alle Pflichtfelder aus'); if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test);
} $newId = $db->execute("INSERT INTO sites (name,site_id,unifi_controller_url,unifi_username,unifi_password,public_access) VALUES (?,?,?,?,?,?)",
[$name,$siteId,$controllerUrl,$username,$password,$publicAccess]);
// Verbindung testen $auth->writeAuditLog($_SESSION['user_id'],'site_create','site',$newId,"Site {$name} erstellt");
$testResult = UniFiController::testConnection($controllerUrl, $username, $password, $siteId); $success = __('sites_added');
if ($testResult !== true) { } catch (Exception $e) { $error = $e->getMessage(); }
throw new Exception('Verbindung fehlgeschlagen: ' . $testResult);
}
$db->execute(
"INSERT INTO sites (name, site_id, unifi_controller_url, unifi_username, unifi_password, public_access)
VALUES (?, ?, ?, ?, ?, ?)",
[$name, $siteId, $controllerUrl, $username, $password, $publicAccess]
);
$success = 'Site erfolgreich hinzugefügt!';
} catch (Exception $e) {
$error = $e->getMessage();
}
} }
} }
// Site löschen // Delete site
if (isset($_GET['delete']) && isset($_GET['token'])) { if (isset($_GET['delete']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_GET['token'])) {
$db->query("DELETE FROM sites WHERE id = ?", [(int)$_GET['delete']]); $delId = (int)$_GET['delete'];
$success = 'Site erfolgreich gelöscht!'; $db->query("DELETE FROM sites WHERE id=?", [$delId]);
} else { $auth->writeAuditLog($_SESSION['user_id'],'site_delete','site',$delId,'Site gelöscht');
$error = 'Ungültiges Sicherheits-Token'; $success = __('sites_deleted');
} } else { $error = __('error_csrf'); }
} }
// Site aktivieren/deaktivieren // Toggle site
if (isset($_GET['toggle']) && isset($_GET['token'])) { if (isset($_GET['toggle']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_GET['token'])) {
$site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_GET['toggle']]); $site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_GET['toggle']]);
if ($site) { if ($site) {
$newStatus = $site['is_active'] ? 0 : 1; $db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_GET['toggle']]);
$db->query("UPDATE sites SET is_active = ? WHERE id = ?", [$newStatus, (int)$_GET['toggle']]);
$success = 'Site-Status aktualisiert!'; $success = 'Site-Status aktualisiert!';
} }
} } else { $error = __('error_csrf'); }
} }
// Alle Sites abrufen
$sites = $db->fetchAll("SELECT * FROM sites ORDER BY name"); $sites = $db->fetchAll("SELECT * FROM sites ORDER BY name");
$currentUser = $auth->getCurrentUser(); $currentPage = 'sites';
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="<?= I18n::getLanguage() ?>">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sites verwalten - <?= htmlspecialchars($appTitle) ?></title> <title><?= __('sites_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; }
body { .page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; .alert { padding: 13px 18px; border-radius: 10px; font-size: 14px; margin-bottom: 20px; }
background: #f5f7fa; .alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
} .alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.header { .sites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 18px; }
background: white; .site-card { background: var(--bg-card); border: 2px solid var(--border-color); border-radius: 14px; padding: 20px; transition: border-color .2s, box-shadow .2s; }
border-bottom: 1px solid #e0e0e0; .site-card:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(102,126,234,.15); }
padding: 0 30px; .site-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 14px; }
height: 70px; .site-name { font-size: 17px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
display: flex; .site-id-label { font-size: 12px; color: var(--text-muted); font-family: monospace; }
align-items: center; .site-info { margin: 14px 0; font-size: 13px; color: var(--text-secondary); }
justify-content: space-between; .site-info-item { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
position: sticky; .site-actions { display: flex; gap: 7px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color); flex-wrap: wrap; }
top: 0; .badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; margin: 2px; }
z-index: 100; .badge-success { background: #d4edda; color: #155724; }
box-shadow: 0 2px 10px rgba(0,0,0,0.05); .badge-warning { background: #fff3cd; color: #856404; }
} .badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
.header-title { font-size: 20px; font-weight: 600; color: #333; } .btn { padding: 8px 15px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
.sidebar { .btn-primary { background: var(--accent); color: white; }
position: fixed; .btn-primary:hover { background: var(--accent-hover); }
left: 0; .btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
top: 70px; .btn-secondary:hover { background: var(--border-color); }
bottom: 0; .btn-danger { background: var(--danger); color: white; }
width: 260px; .btn-success { background: var(--success); color: white; }
background: white; .btn-sm { padding: 6px 11px; font-size: 12px; }
border-right: 1px solid #e0e0e0; .modal { display: none; position: fixed; inset: 0; background: var(--modal-overlay); z-index: 1000; align-items: center; justify-content: center; }
padding: 30px 0;
}
.sidebar-nav { list-style: none; }
.sidebar-nav a {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 30px;
color: #666;
text-decoration: none;
transition: all 0.2s;
font-size: 15px;
}
.sidebar-nav a:hover,
.sidebar-nav a.active {
background: #f8f9fa;
color: #667eea;
}
.sidebar-nav i { width: 20px; text-align: center; }
.main-content {
margin-left: 260px;
padding: 30px;
min-height: calc(100vh - 70px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.page-title { font-size: 28px; font-weight: 600; color: #333; }
.btn {
padding: 10px 20px;
border-radius: 8px;
border: none;
font-weight: 500;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
font-size: 14px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover { background: #5568d3; }
.btn-secondary {
background: #f8f9fa;
color: #666;
border: 1px solid #e0e0e0;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-small {
padding: 6px 12px;
font-size: 13px;
}
.card {
background: white;
border-radius: 15px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
border: 1px solid #e0e0e0;
margin-bottom: 20px;
}
.card-header {
padding: 20px 25px;
border-bottom: 1px solid #e0e0e0;
}
.card-title { font-size: 18px; font-weight: 600; color: #333; }
.card-body { padding: 25px; }
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.form-group { margin-bottom: 20px; }
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 14px;
}
input[type="text"],
input[type="password"],
input[type="url"] {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox-group input {
width: auto;
}
.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;
}
.sites-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.site-card {
background: white;
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
transition: all 0.3s;
}
.site-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
}
.site-card-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 15px;
}
.site-name {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.site-id {
font-size: 12px;
color: #999;
font-family: monospace;
}
.site-info {
margin: 15px 0;
font-size: 13px;
color: #666;
}
.site-info-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.site-actions {
display: flex;
gap: 8px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #f0f0f0;
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
}
.badge-success {
background: #d4edda;
color: #155724;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.badge-info {
background: #d1ecf1;
color: #0c5460;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active { display: flex; } .modal.active { display: flex; }
.modal-content { .modal-content { background: var(--bg-card); border-radius: 14px; max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border-color); }
background: white; .modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
border-radius: 15px; .modal-title { font-size: 19px; font-weight: 600; color: var(--text-primary); }
max-width: 600px; .modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
width: 90%; .modal-body { padding: 22px 25px; }
max-height: 90vh; .form-group { margin-bottom: 17px; }
overflow-y: auto; .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
} label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
.modal-header { input[type="text"], input[type="password"], input[type="url"] { width: 100%; padding: 11px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color .2s; }
padding: 25px; input:focus { outline: none; border-color: var(--accent); }
border-bottom: 1px solid #e0e0e0; .checkbox-group { display: flex; align-items: center; gap: 10px; }
display: flex; .checkbox-group input { width: auto; }
justify-content: space-between; .empty-card { background: var(--bg-card); border-radius: 14px; border: 1px solid var(--border-color); padding: 60px 20px; text-align: center; color: var(--text-muted); }
align-items: center; @media(max-width:768px){ .main-content{ margin-left:0!important; } .form-grid{ grid-template-columns:1fr; } }
}
.modal-title { font-size: 20px; font-weight: 600; }
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
}
.modal-body { padding: 25px; }
</style> </style>
</head>
<body>
<div class="header">
<div class="header-title">
<i class="fas fa-shield-alt"></i> Administration
</div>
<a href="../index.php" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Zurück
</a>
</div>
<div class="sidebar">
<nav class="sidebar-nav">
<ul>
<li><a href="index.php"><i class="fas fa-home"></i> Dashboard</a></li>
<li><a href="sites.php" class="active"><i class="fas fa-map-marker-alt"></i> Sites verwalten</a></li>
<li><a href="users.php"><i class="fas fa-users"></i> Benutzer verwalten</a></li>
<li><a href="vouchers.php"><i class="fas fa-ticket-alt"></i> Voucher-Historie</a></li>
<li><a href="settings.php"><i class="fas fa-cog"></i> Einstellungen</a></li>
</ul>
</nav>
</div>
<div class="main-content">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Sites verwalten</h1> <h1 class="page-title"><?= __('sites_title') ?></h1>
<button onclick="openModal()" class="btn btn-primary"> <button onclick="openModal()" class="btn btn-primary">
<i class="fas fa-plus"></i> Neue Site hinzufügen <i class="fas fa-plus"></i> <?= __('sites_add') ?>
</button> </button>
</div> </div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div> <div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($success): ?> <?php if ($success): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div> <div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (empty($sites)): ?> <?php if (empty($sites)): ?>
<div class="card"> <div class="empty-card">
<div class="card-body" style="text-align: center; padding: 60px 20px; color: #999;"> <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: 0.3;"></i> <p><?= __('sites_none') ?></p>
<p>Noch keine Sites konfiguriert.<br>Fügen Sie Ihre erste Site hinzu!</p>
</div>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="sites-grid"> <div class="sites-grid">
@ -452,131 +171,111 @@ $currentUser = $auth->getCurrentUser();
<div class="site-card-header"> <div class="site-card-header">
<div> <div>
<div class="site-name"><?= htmlspecialchars($site['name']) ?></div> <div class="site-name"><?= htmlspecialchars($site['name']) ?></div>
<div class="site-id">ID: <?= htmlspecialchars($site['site_id']) ?></div> <div class="site-id-label">ID: <?= htmlspecialchars($site['site_id']) ?></div>
</div> </div>
<div> <div>
<?php if ($site['is_active']): ?> <?php if ($site['is_active']): ?>
<span class="badge badge-success"><i class="fas fa-check"></i> Aktiv</span> <span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span>
<?php else: ?> <?php else: ?>
<span class="badge badge-warning"><i class="fas fa-pause"></i> Inaktiv</span> <span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span>
<?php endif; ?> <?php endif; ?>
<?php if ($site['public_access']): ?> <?php if ($site['public_access']): ?>
<span class="badge badge-info"><i class="fas fa-globe"></i> Öffentlich</span> <span class="badge badge-info"><i class="fas fa-globe"></i> <?= __('status_public') ?></span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<div class="site-info"> <div class="site-info">
<div class="site-info-item"> <div class="site-info-item">
<i class="fas fa-server" style="color: #667eea;"></i> <i class="fas fa-server" style="color:var(--accent);width:16px;"></i>
<span><?= htmlspecialchars($site['unifi_controller_url']) ?></span> <span style="word-break:break-all;"><?= htmlspecialchars($site['unifi_controller_url']) ?></span>
</div> </div>
<div class="site-info-item"> <div class="site-info-item">
<i class="fas fa-user" style="color: #667eea;"></i> <i class="fas fa-user" style="color:var(--accent);width:16px;"></i>
<span><?= htmlspecialchars($site['unifi_username']) ?></span> <span><?= htmlspecialchars($site['unifi_username']) ?></span>
</div> </div>
<div class="site-info-item"> <div class="site-info-item">
<i class="fas fa-clock" style="color: #999;"></i> <i class="fas fa-clock" style="color:var(--text-muted);width:16px;"></i>
<span>Erstellt: <?= date('d.m.Y', strtotime($site['created_at'])) ?></span> <span style="color:var(--text-muted);"><?= date('d.m.Y', strtotime($site['created_at'])) ?></span>
</div> </div>
</div> </div>
<div class="site-actions"> <div class="site-actions">
<button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>)" <button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>)"
class="btn btn-secondary btn-small"> class="btn btn-secondary btn-sm">
<i class="fas fa-edit"></i> Bearbeiten <i class="fas fa-edit"></i> <?= __('btn_edit') ?>
</button> </button>
<a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-small"> 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' ?>"></i>
<?= $site['is_active'] ? 'Deaktivieren' : 'Aktivieren' ?> <?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
</a> </a>
<a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-danger btn-small" class="btn btn-danger btn-sm"
onclick="return confirm('Möchten Sie diese Site wirklich löschen?')"> onclick="return confirm('Möchten Sie diese Site wirklich löschen?')">
<i class="fas fa-trash"></i> Löschen <i class="fas fa-trash"></i>
</a> </a>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div>
<!-- Modal für neue Site --> </div><!-- /main-content -->
<!-- Add Site Modal -->
<div id="addSiteModal" class="modal"> <div id="addSiteModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Neue Site hinzufügen</h2> <h2 class="modal-title"><?= __('sites_add_title') ?></h2>
<button class="modal-close" onclick="closeModal('addSiteModal')">&times;</button> <button class="modal-close" onclick="closeModal('addSiteModal')">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form method="post" id="addSiteForm"> <form method="post" id="addSiteForm">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="add_site" value="1"> <input type="hidden" name="add_site" value="1">
<div class="form-group"> <div class="form-group">
<label for="name">Site-Name *</label> <label><?= __('sites_name') ?></label>
<input type="text" id="name" name="name" required <input type="text" name="name" required placeholder="z.B. Hauptgebäude">
placeholder="z.B. Hauptgebäude">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="site_id">UniFi Site ID *</label> <label><?= __('sites_site_id') ?></label>
<input type="text" id="site_id" name="site_id" required <input type="text" name="site_id" required placeholder="z.B. default">
placeholder="z.B. default"> <small style="color:var(--text-muted);font-size:12px;">Zu finden in der UniFi Controller URL</small>
<small style="color: #999; font-size: 12px;">
Zu finden in der UniFi Controller URL oder in den Site-Einstellungen
</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="controller_url">Controller URL *</label> <label><?= __('sites_controller') ?></label>
<input type="url" id="controller_url" name="controller_url" required <input type="url" name="controller_url" required placeholder="https://unifi.example.com:11443">
placeholder="https://unifi.example.com:11443"> <small style="color:var(--text-muted);font-size:12px;">Vollständige URL inkl. Port</small>
<small style="color: #999; font-size: 12px;">
Vollständige URL inklusive Port (meist 11443 für UniFi OS)
</small>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="username">Benutzername *</label> <label><?= __('sites_username') ?></label>
<input type="text" id="username" name="username" required <input type="text" name="username" required placeholder="admin">
placeholder="admin">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Passwort *</label> <label><?= __('sites_password') ?></label>
<input type="password" id="password" name="password" required> <input type="password" name="password" required>
</div> </div>
</div> </div>
<div class="form-group checkbox-group"> <div class="form-group checkbox-group">
<input type="checkbox" id="public_access" name="public_access"> <input type="checkbox" id="add_public" name="public_access">
<label for="public_access" style="margin: 0;"> <label for="add_public" style="margin:0;"><?= __('sites_public') ?></label>
Öffentlicher Zugriff (ohne Login nutzbar)
</label>
</div> </div>
<div style="display:flex;gap:10px;margin-top:20px;">
<div style="display: flex; gap: 10px; margin-top: 25px;">
<button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn"> <button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn">
<i class="fas fa-save"></i> Site hinzufügen <i class="fas fa-save"></i> <?= __('sites_add') ?>
</button>
<button type="button" onclick="closeModal('addSiteModal')" class="btn btn-secondary">
Abbrechen
</button> </button>
<button type="button" onclick="closeModal('addSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal für Site bearbeiten --> <!-- Edit Site Modal -->
<div id="editSiteModal" class="modal"> <div id="editSiteModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Site bearbeiten</h2> <h2 class="modal-title"><?= __('sites_edit_title') ?></h2>
<button class="modal-close" onclick="closeModal('editSiteModal')">&times;</button> <button class="modal-close" onclick="closeModal('editSiteModal')">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -584,65 +283,49 @@ $currentUser = $auth->getCurrentUser();
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="edit_site" value="1"> <input type="hidden" name="edit_site" value="1">
<input type="hidden" name="site_id" id="edit_site_id"> <input type="hidden" name="site_id" id="edit_site_id">
<div class="form-group"> <div class="form-group">
<label for="edit_name">Site-Name *</label> <label><?= __('sites_name') ?></label>
<input type="text" id="edit_name" name="name" required> <input type="text" id="edit_name" name="name" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="edit_site_id_str">UniFi Site ID *</label> <label><?= __('sites_site_id') ?></label>
<input type="text" id="edit_site_id_str" name="site_id_str" required> <input type="text" id="edit_site_id_str" name="site_id_str" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="edit_controller_url">Controller URL *</label> <label><?= __('sites_controller') ?></label>
<input type="url" id="edit_controller_url" name="controller_url" required> <input type="url" id="edit_controller_url" name="controller_url" required>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="edit_username">Benutzername *</label> <label><?= __('sites_username') ?></label>
<input type="text" id="edit_username" name="username" required> <input type="text" id="edit_username" name="username" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="edit_password">Neues Passwort</label> <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="Leer lassen = nicht ändern">
<small style="color: #999; font-size: 12px;"> <small style="color:var(--text-muted);font-size:12px;"><?= __('sites_password_hint') ?></small>
Nur ausfüllen wenn Sie das Passwort ändern möchten
</small>
</div> </div>
</div> </div>
<div class="form-group checkbox-group"> <div class="form-group checkbox-group">
<input type="checkbox" id="edit_public_access" name="public_access"> <input type="checkbox" id="edit_public_access" name="public_access">
<label for="edit_public_access" style="margin: 0;"> <label for="edit_public_access" style="margin:0;"><?= __('sites_public') ?></label>
Öffentlicher Zugriff (ohne Login nutzbar)
</label>
</div> </div>
<div style="display:flex;gap:10px;margin-top:20px;">
<div style="display: flex; gap: 10px; margin-top: 25px;">
<button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn"> <button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn">
<i class="fas fa-save"></i> Änderungen speichern <i class="fas fa-save"></i> <?= __('btn_save') ?>
</button>
<button type="button" onclick="closeModal('editSiteModal')" class="btn btn-secondary">
Abbrechen
</button> </button>
<button type="button" onclick="closeModal('editSiteModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<div id="toast-container"></div>
<script src="../assets/global.js"></script>
<script> <script>
function openModal() { function openModal() { document.getElementById('addSiteModal').classList.add('active'); }
document.getElementById('addSiteModal').classList.add('active'); function closeModal(id) { document.getElementById(id).classList.remove('active'); }
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('active');
}
function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAccess) { function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAccess) {
document.getElementById('edit_site_id').value = id; document.getElementById('edit_site_id').value = id;
@ -652,37 +335,24 @@ $currentUser = $auth->getCurrentUser();
document.getElementById('edit_username').value = username; document.getElementById('edit_username').value = username;
document.getElementById('edit_password').value = ''; document.getElementById('edit_password').value = '';
document.getElementById('edit_public_access').checked = publicAccess == 1; document.getElementById('edit_public_access').checked = publicAccess == 1;
document.getElementById('editSiteModal').classList.add('active'); document.getElementById('editSiteModal').classList.add('active');
} }
// Loading-State bei Formular-Absenden
document.getElementById('addSiteForm').addEventListener('submit', function() { document.getElementById('addSiteForm').addEventListener('submit', function() {
const btn = document.getElementById('addSiteSubmitBtn'); const btn = document.getElementById('addSiteSubmitBtn');
if (btn) {
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verbindung wird getestet...'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>';
}
}); });
document.getElementById('editSiteForm').addEventListener('submit', function() { document.getElementById('editSiteForm').addEventListener('submit', function() {
const btn = document.getElementById('editSiteSubmitBtn'); const btn = document.getElementById('editSiteSubmitBtn');
if (btn) {
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verbindung wird getestet...'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <?= addslashes(__('sites_testing')) ?>';
}
}); });
// Modal schließen bei Klick außerhalb ['addSiteModal','editSiteModal'].forEach(id => {
document.getElementById('addSiteModal').addEventListener('click', function(e) { document.getElementById(id).addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) closeModal(id);
closeModal('addSiteModal');
}
}); });
document.getElementById('editSiteModal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal('editSiteModal');
}
}); });
</script> </script>
</body> </body>

311
admin/templates.php Normal file
View file

@ -0,0 +1,311 @@
<?php
error_reporting(E_ALL);
ini_set('display_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 = '';
// Profil hinzufügen
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
try {
$name = trim($_POST['name'] ?? '');
$maxUses = (int)($_POST['max_uses'] ?? 1);
$expireMin = (int)($_POST['expire_minutes'] ?? 480);
$description = trim($_POST['description'] ?? '');
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']]
);
$success = __('templates_added');
} catch (Exception $e) {
$error = $e->getMessage();
}
}
}
// Profil bearbeiten
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
try {
$id = (int)$_POST['template_id'];
$name = trim($_POST['name'] ?? '');
$maxUses = (int)($_POST['max_uses'] ?? 1);
$expireMin = (int)($_POST['expire_minutes'] ?? 480);
$description = trim($_POST['description'] ?? '');
$isActive = isset($_POST['is_active']) ? 1 : 0;
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]
);
$success = __('templates_updated');
} catch (Exception $e) {
$error = $e->getMessage();
}
}
}
// Profil löschen
if (isset($_GET['delete']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_GET['delete']]);
$success = __('templates_deleted');
} else {
$error = __('error_csrf');
}
}
$templates = $db->fetchAll("SELECT t.*, u.name as creator FROM voucher_templates t LEFT JOIN users u ON t.created_by = u.id ORDER BY t.is_active DESC, t.name");
$currentPage = 'templates';
$adminBase = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('templates_title') ?> - <?= htmlspecialchars($appTitle) ?></title>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; flex-wrap: wrap; gap: 15px; }
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); }
.alert { padding: 14px 20px; 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; }
.card { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 25px; }
.card-header { padding: 20px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.card-title { font-size: 18px; font-weight: 600; color: var(--text-primary); }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 12px 15px; background: var(--bg-table-head); color: var(--text-secondary); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.table td { padding: 14px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: var(--bg-hover); }
.badge { display: inline-block; padding: 3px 10px; border-radius: 6px; font-size: 11px; font-weight: 500; }
.badge-success { background: #d4edda; color: #155724; }
.badge-secondary { background: var(--bg-hover); color: var(--text-muted); }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: white; }
.btn-small { padding: 6px 12px; font-size: 12px; }
.modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--modal-overlay); z-index: 500; align-items: center; justify-content: center; }
.modal.active { display: flex; }
.modal-content { background: var(--bg-card); border-radius: 15px; max-width: 520px; width: 90%; max-height: 90vh; overflow-y: auto; }
.modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.modal-title { font-size: 18px; font-weight: 600; color: var(--text-primary); }
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
.modal-body { padding: 25px; }
.form-group { margin-bottom: 18px; }
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
input[type="text"], input[type="number"], textarea, select { width: 100%; padding: 11px 14px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color 0.2s; font-family: inherit; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
.checkbox-group { display: flex; align-items: center; gap: 10px; }
.checkbox-group input { width: auto; accent-color: var(--accent); }
.help-text { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
.empty-state i { font-size: 48px; margin-bottom: 20px; opacity: 0.3; display: block; }
.duration-badge { display: inline-flex; align-items: center; gap: 5px; background: var(--bg-hover); padding: 3px 10px; border-radius: 20px; font-size: 12px; color: var(--text-secondary); }
</style>
</head>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('templates_title') ?></h1>
<p style="color: var(--text-muted); font-size: 14px; margin-top: 5px;"><?= __('templates_subtitle') ?></p>
</div>
<button onclick="openAddModal()" class="btn btn-primary">
<i class="fas fa-plus"></i> <?= __('templates_add') ?>
</button>
</div>
<?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<div class="card">
<div class="card-header">
<h2 class="card-title"><?= __('templates_title') ?></h2>
<span style="font-size: 13px; color: var(--text-muted);"><?= count($templates) ?> Profile</span>
</div>
<?php if (empty($templates)): ?>
<div class="empty-state">
<i class="fas fa-layer-group"></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>
</div>
<?php else: ?>
<div style="overflow-x: auto;">
<table class="table">
<thead>
<tr>
<th><?= __('templates_name') ?></th>
<th><?= __('templates_devices') ?></th>
<th><?= __('templates_duration') ?></th>
<th><?= __('templates_desc') ?></th>
<th><?= __('label_status') ?></th>
<th><?= __('label_actions') ?></th>
</tr>
</thead>
<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>
<td>
<?php
$m = (int)$t['expire_minutes'];
if ($m >= 1440 && $m % 1440 === 0) {
$durLabel = ($m / 1440) . ' Tag' . ($m / 1440 > 1 ? 'e' : '');
} elseif ($m >= 60 && $m % 60 === 0) {
$durLabel = ($m / 60) . ' Std.';
} else {
$durLabel = $m . ' Min.';
}
?>
<span class="duration-badge"><i class="fas fa-clock"></i> <?= $durLabel ?></span>
</td>
<td style="color: var(--text-secondary);"><?= htmlspecialchars($t['description'] ?? '-') ?></td>
<td>
<?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>
<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'] ?>)"
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
onclick="return confirm('Profil wirklich löschen?')"
class="btn btn-danger btn-small"><i class="fas fa-trash"></i></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div><!-- main-content -->
<!-- Modal: Hinzufügen -->
<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>
<button class="modal-close" onclick="closeModal('addModal')">&times;</button>
</div>
<div class="modal-body">
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<div class="form-group"><label><?= __('templates_name') ?> *</label><input type="text" name="name" required placeholder="z.B. Tagespass, Event 4h"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label><?= __('templates_devices') ?></label>
<input type="number" name="max_uses" value="1" min="1" max="100">
<div class="help-text">Max. gleichzeitige Geräte</div>
</div>
<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>
</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: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>
</div>
</form>
</div>
</div>
</div>
<!-- Modal: Bearbeiten -->
<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>
<button class="modal-close" onclick="closeModal('editModal')">&times;</button>
</div>
<div class="modal-body">
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="template_id" id="editId">
<div class="form-group"><label><?= __('templates_name') ?> *</label><input type="text" name="name" id="editName" required></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label><?= __('templates_devices') ?></label>
<input type="number" name="max_uses" id="editMaxUses" min="1" max="100">
</div>
<div class="form-group">
<label><?= __('templates_duration') ?></label>
<input type="number" name="expire_minutes" id="editExpireMin" min="1">
</div>
</div>
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" id="editDesc" rows="2"></textarea></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>
</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="button" onclick="closeModal('editModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div>
</form>
</div>
</div>
</div>
<script src="../assets/global.js"></script>
<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) {
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('editModal').classList.add('active');
}
['addModal','editModal'].forEach(id => {
document.getElementById(id).addEventListener('click', function(e) {
if (e.target === this) closeModal(id);
});
});
</script>
</body>
</html>

View file

@ -6,98 +6,92 @@ require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Mailer.php'; require_once __DIR__ . '/../includes/Mailer.php';
require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
$db = Database::getInstance(); $db = Database::getInstance();
$mailer = new Mailer(); $mailer = new Mailer();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1';
I18n::init();
$error = ''; $error = '';
$success = ''; $success = '';
// Benutzer bearbeiten // Send password reset link
if (isset($_GET['send_reset']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$targetUser = $db->fetchOne("SELECT * FROM users WHERE id=? AND is_active=1 AND password_hash IS NOT NULL", [(int)$_GET['send_reset']]);
if ($targetUser) {
try {
$db->execute("DELETE FROM password_reset_tokens WHERE user_id=?", [$targetUser['id']]);
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour'));
$db->execute("INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?,?,?)", [$targetUser['id'], $token, $expiresAt]);
$systemUrl = rtrim($db->getSetting('system_url', ''), '/');
if (empty($systemUrl)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS']==='on' ? 'https' : 'http';
$scriptPath = dirname(dirname($_SERVER['SCRIPT_NAME']));
$systemUrl = $protocol . '://' . $_SERVER['HTTP_HOST'] . ($scriptPath==='/'?'':$scriptPath);
}
$resetUrl = $systemUrl . '/reset_password.php?token=' . $token;
$mailer->sendRaw($targetUser['email'], $appTitle . ' Passwort zurücksetzen',
"Hallo {$targetUser['name']},\n\nEin Administrator hat für Sie einen Passwort-Reset-Link erstellt:\n\n{$resetUrl}\n\n(Gültig für 1 Stunde)\n\n{$appTitle}");
$success = 'Passwort-Reset-Link wurde an ' . htmlspecialchars($targetUser['email']) . ' gesendet.';
} catch (Exception $e) {
$error = 'Fehler beim Senden: ' . $e->getMessage();
}
} else {
$error = 'Benutzer nicht gefunden oder kein lokales Passwort.';
}
} else {
$error = __('error_csrf');
}
}
// Edit user
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) {
$error = 'Ungültiges Sicherheits-Token'; $error = __('error_csrf');
} else { } else {
try { try {
$userId = (int)$_POST['user_id']; $userId = (int)$_POST['user_id'];
$isAdmin = isset($_POST['is_admin']) ? 1 : 0; $isAdmin = isset($_POST['is_admin']) ? 1 : 0;
$siteIds = $_POST['site_ids'] ?? []; $siteIds = $_POST['site_ids'] ?? [];
// Alten Status abrufen
$oldUser = $db->fetchOne("SELECT * FROM users WHERE id=?", [$userId]); $oldUser = $db->fetchOne("SELECT * FROM users WHERE id=?", [$userId]);
$oldIsAdmin = $oldUser['is_admin'];
$oldSites= $db->fetchAll("SELECT s.name FROM sites s INNER JOIN user_site_access usa ON s.id=usa.site_id WHERE usa.user_id=?", [$userId]); $oldSites= $db->fetchAll("SELECT s.name FROM sites s INNER JOIN user_site_access usa ON s.id=usa.site_id WHERE usa.user_id=?", [$userId]);
// Admin-Status aktualisieren
$db->query("UPDATE users SET is_admin=? WHERE id=?", [$isAdmin, $userId]); $db->query("UPDATE users SET is_admin=? WHERE id=?", [$isAdmin, $userId]);
// Alte Site-Zugriffe löschen (nur wenn nicht Admin)
$db->query("DELETE FROM user_site_access WHERE user_id=?", [$userId]); $db->query("DELETE FROM user_site_access WHERE user_id=?", [$userId]);
// Neue Site-Zugriffe zuweisen (nur wenn nicht Admin)
$newSites = []; $newSites = [];
if (!$isAdmin && !empty($siteIds)) { if (!$isAdmin && !empty($siteIds)) {
foreach ($siteIds as $siteId) { foreach ($siteIds as $siteId) {
$db->execute( $db->execute("INSERT INTO user_site_access (user_id, site_id) VALUES (?,?)", [$userId, $siteId]);
"INSERT INTO user_site_access (user_id, site_id) VALUES (?, ?)",
[$userId, $siteId]
);
$site = $db->fetchOne("SELECT name FROM sites WHERE id=?", [$siteId]); $site = $db->fetchOne("SELECT name FROM sites WHERE id=?", [$siteId]);
if ($site) { if ($site) $newSites[] = $site['name'];
$newSites[] = $site['name'];
} }
} }
}
// E-Mail-Benachrichtigung vorbereiten
$changes = []; $changes = [];
if ($oldUser['is_admin'] != $isAdmin) {
if ($oldIsAdmin != $isAdmin) { $changes[] = $isAdmin ? 'Sie wurden zum Administrator ernannt' : 'Ihre Administrator-Rechte wurden entfernt';
if ($isAdmin) {
$changes[] = "Sie wurden zum Administrator ernannt";
} else {
$changes[] = "Ihre Administrator-Rechte wurden entfernt";
} }
}
// Site-Änderungen erkennen
$oldSiteNames = array_column($oldSites,'name'); $oldSiteNames = array_column($oldSites,'name');
$addedSites = array_diff($newSites, $oldSiteNames); $addedSites = array_diff($newSites, $oldSiteNames);
$removedSites = array_diff($oldSiteNames, $newSites); $removedSites = array_diff($oldSiteNames, $newSites);
if (!empty($addedSites)) $changes[] = 'Zugriff gewährt auf: ' . implode(', ', $addedSites);
if (!empty($addedSites)) { if (!empty($removedSites)) $changes[] = 'Zugriff entfernt von: ' . implode(', ', $removedSites);
$changes[] = "Zugriff gewährt auf: " . implode(', ', $addedSites); if ($isAdmin && !$oldUser['is_admin']) $changes[] = 'Sie haben nun Zugriff auf alle Sites';
} if (!empty($changes)) $mailer->sendUserNotification($oldUser['email'], $oldUser['name'], $changes);
$success = __('users_updated') . (!empty($changes) ? ' '.__('users_notified') : '');
if (!empty($removedSites)) { $auth->writeAuditLog($_SESSION['user_id'], 'user_edit', 'user', $userId, implode('; ', $changes) ?: 'Keine Änderungen');
$changes[] = "Zugriff entfernt von: " . implode(', ', $removedSites); } catch (Exception $e) { $error = $e->getMessage(); }
}
if ($isAdmin && !$oldIsAdmin) {
$changes[] = "Sie haben nun Zugriff auf alle Sites";
}
// E-Mail senden wenn Änderungen vorliegen
if (!empty($changes)) {
$mailer->sendUserNotification($oldUser['email'], $oldUser['name'], $changes);
}
$success = 'Benutzer erfolgreich aktualisiert!' . (!empty($changes) ? ' Benachrichtigung wurde versendet.' : '');
} catch (Exception $e) {
$error = $e->getMessage();
}
} }
} }
// Benutzer hinzufügen // Add user
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_user'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_user'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) {
$error = 'Ungültiges Sicherheits-Token'; $error = __('error_csrf');
} else { } else {
try { try {
$email = trim($_POST['email']); $email = trim($_POST['email']);
@ -105,74 +99,42 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_user'])) {
$password= $_POST['password']; $password= $_POST['password'];
$isAdmin = isset($_POST['is_admin']) ? 1 : 0; $isAdmin = isset($_POST['is_admin']) ? 1 : 0;
$siteIds = $_POST['site_ids'] ?? []; $siteIds = $_POST['site_ids'] ?? [];
if (empty($email)||empty($name)||empty($password)) throw new Exception(__('error_fill_all'));
if (empty($email) || empty($name) || empty($password)) { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
throw new Exception('Bitte füllen Sie alle Pflichtfelder aus'); if (strlen($password) < 8) throw new Exception(__('settings_pw_minlength'));
} if ($db->fetchOne("SELECT id FROM users WHERE email=?", [$email])) throw new Exception('E-Mail bereits vorhanden');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Ungültige E-Mail-Adresse');
}
if (strlen($password) < 8) {
throw new Exception('Passwort muss mindestens 8 Zeichen lang sein');
}
// Prüfen ob E-Mail bereits existiert
$existing = $db->fetchOne("SELECT id FROM users WHERE email = ?", [$email]);
if ($existing) {
throw new Exception('Ein Benutzer mit dieser E-Mail existiert bereits');
}
// Benutzer anlegen
$userId = $auth->registerUser($email, $name, $password, $isAdmin); $userId = $auth->registerUser($email, $name, $password, $isAdmin);
if (!$userId) throw new Exception('Benutzer konnte nicht erstellt werden');
if (!$userId) {
throw new Exception('Benutzer konnte nicht erstellt werden');
}
// Site-Zugriffe zuweisen (nur wenn nicht Admin)
if (!$isAdmin && !empty($siteIds)) { if (!$isAdmin && !empty($siteIds)) {
foreach ($siteIds as $siteId) { foreach ($siteIds as $siteId) {
$db->execute( $db->execute("INSERT INTO user_site_access (user_id, site_id) VALUES (?,?)", [$userId, $siteId]);
"INSERT INTO user_site_access (user_id, site_id) VALUES (?, ?)", }
[$userId, $siteId] }
); $auth->writeAuditLog($_SESSION['user_id'], 'user_create', 'user', $userId, "Benutzer {$name} erstellt");
$success = __('users_added');
} catch (Exception $e) { $error = $e->getMessage(); }
} }
} }
$success = 'Benutzer erfolgreich erstellt!'; // Delete user
} catch (Exception $e) {
$error = $e->getMessage();
}
}
}
// Benutzer löschen
if (isset($_GET['delete']) && isset($_GET['token'])) { if (isset($_GET['delete']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_GET['token'])) {
$deleteId = (int)$_GET['delete']; $deleteId = (int)$_GET['delete'];
$currentUserId = $_SESSION['user_id']; if ($deleteId === (int)$_SESSION['user_id']) {
if ($deleteId === $currentUserId) {
$error = 'Sie können sich nicht selbst löschen'; $error = 'Sie können sich nicht selbst löschen';
} else { } else {
$db->query("DELETE FROM users WHERE id=?", [$deleteId]); $db->query("DELETE FROM users WHERE id=?", [$deleteId]);
$success = 'Benutzer erfolgreich gelöscht!'; $auth->writeAuditLog($_SESSION['user_id'], 'user_delete', 'user', $deleteId, 'Benutzer gelöscht');
} $success = __('users_deleted');
} else {
$error = 'Ungültiges Sicherheits-Token';
} }
} else { $error = __('error_csrf'); }
} }
// Benutzer aktivieren/deaktivieren // Toggle user active
if (isset($_GET['toggle']) && isset($_GET['token'])) { if (isset($_GET['toggle']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_GET['token'])) {
$toggleId = (int)$_GET['toggle']; $toggleId = (int)$_GET['toggle'];
$currentUserId = $_SESSION['user_id']; if ($toggleId === (int)$_SESSION['user_id']) {
if ($toggleId === $currentUserId) {
$error = 'Sie können sich nicht selbst deaktivieren'; $error = 'Sie können sich nicht selbst deaktivieren';
} else { } else {
$user = $db->fetchOne("SELECT is_active FROM users WHERE id=?", [$toggleId]); $user = $db->fetchOne("SELECT is_active FROM users WHERE id=?", [$toggleId]);
@ -182,585 +144,313 @@ if (isset($_GET['toggle']) && isset($_GET['token'])) {
$success = 'Benutzer-Status aktualisiert!'; $success = 'Benutzer-Status aktualisiert!';
} }
} }
} } else { $error = __('error_csrf'); }
} }
// Alle Benutzer und Sites abrufen
$users = $db->fetchAll("SELECT * FROM users ORDER BY name"); $users = $db->fetchAll("SELECT * FROM users ORDER BY name");
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name"); $sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
// Site-Zugriffe für jeden Benutzer abrufen
$userSiteAccess = []; $userSiteAccess = [];
foreach ($users as $user) { foreach ($users as $user) {
$userSiteAccess[$user['id']] = $db->fetchAll( $userSiteAccess[$user['id']] = $db->fetchAll("SELECT s.id, s.name FROM sites s INNER JOIN user_site_access usa ON s.id=usa.site_id WHERE usa.user_id=?", [$user['id']]);
"SELECT s.name FROM sites s
INNER JOIN user_site_access usa ON s.id = usa.site_id
WHERE usa.user_id = ?",
[$user['id']]
);
} }
$currentPage = 'users';
$currentUser = $auth->getCurrentUser();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="<?= I18n::getLanguage() ?>">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Benutzer verwalten - <?= htmlspecialchars($appTitle) ?></title> <title><?= __('users_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; }
body { .page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; .card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 24px; }
background: #f5f7fa; .card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); }
} .card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.header { .alert { padding: 13px 18px; border-radius: 10px; font-size: 14px; margin-bottom: 20px; }
background: white; .alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
border-bottom: 1px solid #e0e0e0; .alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
padding: 0 30px; .table { width: 100%; border-collapse: collapse; }
height: 70px; .table th { text-align: left; padding: 12px 15px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
display: flex; .table td { padding: 13px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
align-items: center; .table tr:last-child td { border-bottom: none; }
justify-content: space-between; .table tr:hover { background: var(--bg-hover); }
position: sticky; .badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; margin: 2px; }
top: 0; .badge-success { background: #d4edda; color: #155724; }
z-index: 100; .badge-warning { background: #fff3cd; color: #856404; }
box-shadow: 0 2px 10px rgba(0,0,0,0.05); .badge-danger { background: #f8d7da; color: #721c24; }
} .badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
.header-title { font-size: 20px; font-weight: 600; color: #333; } .btn { padding: 8px 16px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
.sidebar { .btn-primary { background: var(--accent); color: white; }
position: fixed; .btn-primary:hover { background: var(--accent-hover); }
left: 0; .btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
top: 70px; .btn-secondary:hover { background: var(--border-color); }
bottom: 0; .btn-danger { background: var(--danger); color: white; }
width: 260px; .btn-warning { background: var(--warning); color: #333; }
background: white; .btn-sm { padding: 5px 10px; font-size: 12px; }
border-right: 1px solid #e0e0e0; .modal { display: none; position: fixed; inset: 0; background: var(--modal-overlay); z-index: 1000; align-items: center; justify-content: center; }
padding: 30px 0;
}
.sidebar-nav { list-style: none; }
.sidebar-nav a {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 30px;
color: #666;
text-decoration: none;
transition: all 0.2s;
font-size: 15px;
}
.sidebar-nav a:hover,
.sidebar-nav a.active {
background: #f8f9fa;
color: #667eea;
}
.sidebar-nav i { width: 20px; text-align: center; }
.main-content {
margin-left: 260px;
padding: 30px;
min-height: calc(100vh - 70px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.page-title { font-size: 28px; font-weight: 600; color: #333; }
.btn {
padding: 10px 20px;
border-radius: 8px;
border: none;
font-weight: 500;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
font-size: 14px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover { background: #5568d3; }
.btn-secondary {
background: #f8f9fa;
color: #666;
border: 1px solid #e0e0e0;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-small {
padding: 6px 12px;
font-size: 13px;
}
.card {
background: white;
border-radius: 15px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
border: 1px solid #e0e0e0;
overflow: hidden;
}
.card-header {
padding: 20px 25px;
border-bottom: 1px solid #e0e0e0;
}
.card-title { font-size: 18px; font-weight: 600; color: #333; }
.card-body { padding: 0; }
.alert {
padding: 14px 25px;
margin: 0 25px 20px 25px;
border-radius: 10px;
font-size: 14px;
}
.alert-error {
background: #fee;
border: 1px solid #fcc;
color: #c33;
}
.alert-success {
background: #efe;
border: 1px solid #cfc;
color: #3c3;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
padding: 15px;
background: #f8f9fa;
color: #666;
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.table td {
padding: 15px;
border-bottom: 1px solid #f0f0f0;
color: #333;
}
.table tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
margin-right: 5px;
}
.badge-success {
background: #d4edda;
color: #155724;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.badge-danger {
background: #f8d7da;
color: #721c24;
}
.badge-info {
background: #d1ecf1;
color: #0c5460;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active { display: flex; } .modal.active { display: flex; }
.modal-content { .modal-content { background: var(--bg-card); border-radius: 14px; max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border-color); }
background: white; .modal-header { padding: 22px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
border-radius: 15px; .modal-title { font-size: 19px; font-weight: 600; color: var(--text-primary); }
max-width: 600px; .modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); }
width: 90%; .modal-body { padding: 22px 25px; }
max-height: 90vh; .form-group { margin-bottom: 18px; }
overflow-y: auto; label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
} input[type="text"], input[type="email"], input[type="password"] { width: 100%; padding: 11px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 14px; background: var(--bg-input); color: var(--text-primary); transition: border-color .2s; }
.modal-header { input:focus { outline: none; border-color: var(--accent); }
padding: 25px; .checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
border-bottom: 1px solid #e0e0e0; .checkbox-group input { width: auto; }
display: flex; .site-selection { border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; max-height: 180px; overflow-y: auto; background: var(--bg-hover); }
justify-content: space-between; .empty-state { text-align: center; padding: 50px 20px; color: var(--text-muted); }
align-items: center; .empty-state i { font-size: 40px; margin-bottom: 15px; opacity: .3; display: block; }
} .action-btns { display: flex; gap: 5px; flex-wrap: wrap; }
.modal-title { font-size: 20px; font-weight: 600; } @media(max-width:768px){ .main-content{ margin-left:0!important; } .table th:nth-child(5),.table td:nth-child(5){ display:none; } }
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
}
.modal-body { padding: 25px; }
.form-group { margin-bottom: 20px; }
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 14px;
}
input[type="text"],
input[type="email"],
input[type="password"] {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.checkbox-group input {
width: auto;
}
.site-selection {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
max-height: 200px;
overflow-y: auto;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state i {
font-size: 48px;
margin-bottom: 20px;
opacity: 0.3;
}
</style> </style>
</head>
<body>
<div class="header">
<div class="header-title">
<i class="fas fa-shield-alt"></i> Administration
</div>
<a href="../index.php" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Zurück
</a>
</div>
<div class="sidebar">
<nav class="sidebar-nav">
<ul>
<li><a href="index.php"><i class="fas fa-home"></i> Dashboard</a></li>
<li><a href="sites.php"><i class="fas fa-map-marker-alt"></i> Sites verwalten</a></li>
<li><a href="users.php" class="active"><i class="fas fa-users"></i> Benutzer verwalten</a></li>
<li><a href="vouchers.php"><i class="fas fa-ticket-alt"></i> Voucher-Historie</a></li>
<li><a href="settings.php"><i class="fas fa-cog"></i> Einstellungen</a></li>
</ul>
</nav>
</div>
<div class="main-content">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Benutzer verwalten</h1> <h1 class="page-title"><?= __('users_title') ?></h1>
<button onclick="openModal()" class="btn btn-primary"> <button onclick="openModal()" class="btn btn-primary">
<i class="fas fa-plus"></i> Neuer Benutzer <i class="fas fa-plus"></i> <?= __('users_add') ?>
</button> </button>
</div> </div>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div> <div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($success): ?> <?php if ($success): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div> <div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?></div>
<?php endif; ?> <?php endif; ?>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header"><h2 class="card-title"><?= __('users_all') ?></h2></div>
<h2 class="card-title">Alle Benutzer</h2> <div class="card-body" style="padding:0;">
</div>
<div class="card-body">
<?php if (empty($users)): ?> <?php if (empty($users)): ?>
<div class="empty-state"> <div class="empty-state"><i class="fas fa-users"></i><p><?= __('users_none_found') ?></p></div>
<i class="fas fa-users"></i>
<p>Noch keine Benutzer vorhanden</p>
</div>
<?php else: ?> <?php else: ?>
<div style="overflow-x:auto;">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th><?= __('label_name') ?></th>
<th>E-Mail</th> <th><?= __('label_email') ?></th>
<th>Rolle</th> <th><?= __('label_role') ?></th>
<th>Status</th> <th><?= __('label_status') ?></th>
<th>Site-Zugriffe</th> <th><?= __('users_site_access') ?></th>
<th>Letzter Login</th> <th><?= __('users_last_login') ?></th>
<th>Aktionen</th> <th><?= __('label_actions') ?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($users as $user): ?> <?php foreach ($users as $user): ?>
<?php $userSiteIds = array_column($userSiteAccess[$user['id']]??[], 'id'); ?>
<tr> <tr>
<td> <td>
<strong><?= htmlspecialchars($user['name']) ?></strong> <strong><?= htmlspecialchars($user['name']) ?></strong>
<?php if ($user['id'] == $_SESSION['user_id']): ?> <?php if ($user['id'] == $_SESSION['user_id']): ?>
<span class="badge badge-info">Sie</span> <span class="badge badge-info"><?= __('users_you') ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td><?= htmlspecialchars($user['email']) ?></td> <td><?= htmlspecialchars($user['email']) ?></td>
<td> <td>
<?php if ($user['is_admin']): ?> <?php if ($user['is_admin']): ?>
<span class="badge badge-danger"><i class="fas fa-crown"></i> Admin</span> <span class="badge badge-danger"><i class="fas fa-crown"></i> <?= __('status_admin') ?></span>
<?php else: ?> <?php else: ?>
<span class="badge badge-info">Benutzer</span> <span class="badge badge-info"><?= __('status_user') ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<?php if ($user['is_active']): ?> <?php if ($user['is_active']): ?>
<span class="badge badge-success"><i class="fas fa-check"></i> Aktiv</span> <span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_active') ?></span>
<?php else: ?> <?php else: ?>
<span class="badge badge-warning"><i class="fas fa-pause"></i> Inaktiv</span> <span class="badge badge-warning"><i class="fas fa-pause"></i> <?= __('status_inactive') ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<?php if ($user['is_admin']): ?> <?php if ($user['is_admin']): ?>
<em style="color: #999;">Alle Sites</em> <em style="color:var(--text-muted);"><?= __('users_all_sites') ?></em>
<?php elseif (!empty($userSiteAccess[$user['id']])): ?> <?php elseif (!empty($userSiteAccess[$user['id']])): ?>
<?php foreach ($userSiteAccess[$user['id']] as $site): ?> <?php foreach ($userSiteAccess[$user['id']] as $s): ?>
<span class="badge badge-info"><?= htmlspecialchars($site['name']) ?></span> <span class="badge badge-info"><?= htmlspecialchars($s['name']) ?></span>
<?php endforeach; ?> <?php endforeach; ?>
<?php else: ?> <?php else: ?>
<em style="color: #999;">Keine</em> <em style="color:var(--text-muted);"><?= __('users_none') ?></em>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td style="font-size:13px;">
<?php if ($user['last_login']): ?> <?php if ($user['last_login']): ?>
<?= date('d.m.Y H:i', strtotime($user['last_login'])) ?> <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?>
<?php else: ?> <?php else: ?>
<em style="color: #999;">Noch nie</em> <em style="color:var(--text-muted);"><?= __('users_never') ?></em>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<?php if ($user['id'] != $_SESSION['user_id']): ?> <div class="action-btns">
<button onclick="openEditModal(<?= $user['id'] ?>, '<?= htmlspecialchars($user['name'], ENT_QUOTES) ?>', <?= $user['is_admin'] ?>, [<?= implode(',', array_map(function($s) { return $s['site_id'] ?? 0; }, $db->fetchAll("SELECT site_id FROM user_site_access WHERE user_id = ?", [$user['id']]))) ?>])" <button onclick="openEditModal(<?= $user['id'] ?>, '<?= htmlspecialchars($user['name'], ENT_QUOTES) ?>', <?= $user['is_admin'] ?>, [<?= implode(',', array_map('intval', $userSiteIds)) ?>])"
class="btn btn-secondary btn-small"> class="btn btn-secondary btn-sm" title="<?= __('btn_edit') ?>">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
<?php if ($user['id'] != $_SESSION['user_id']): ?>
<a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-small"> 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' ?>"></i>
</a> </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>
</a>
<?php endif; ?>
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-danger btn-small" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"
onclick="return confirm('Benutzer wirklich löschen?')"> onclick="return confirm('Benutzer wirklich löschen?')">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</a> </a>
<?php else: ?>
<button onclick="openEditModal(<?= $user['id'] ?>, '<?= htmlspecialchars($user['name'], ENT_QUOTES) ?>', <?= $user['is_admin'] ?>, [<?= implode(',', array_map(function($s) { return $s['site_id'] ?? 0; }, $db->fetchAll("SELECT site_id FROM user_site_access WHERE user_id = ?", [$user['id']]))) ?>])"
class="btn btn-secondary btn-small">
<i class="fas fa-edit"></i>
</button>
<?php endif; ?> <?php endif; ?>
</div>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
</div>
<!-- Modal für neuen Benutzer --> </div><!-- /main-content -->
<!-- Add User Modal -->
<div id="addUserModal" class="modal"> <div id="addUserModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Neuen Benutzer anlegen</h2> <h2 class="modal-title"><?= __('users_add_title') ?></h2>
<button class="modal-close" onclick="closeModal('addUserModal')">&times;</button> <button class="modal-close" onclick="closeModal('addUserModal')">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form method="post" id="addUserForm"> <form method="post" id="addUserForm">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<div class="form-group"> <div class="form-group">
<label for="name">Name *</label> <label><?= __('label_name') ?> *</label>
<input type="text" id="name" name="name" required> <input type="text" name="name" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email">E-Mail *</label> <label><?= __('label_email') ?> *</label>
<input type="email" id="email" name="email" required> <input type="email" name="email" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Passwort *</label> <label><?= __('label_password') ?> *</label>
<input type="password" id="password" name="password" required minlength="8"> <input type="password" name="password" required minlength="8">
<small style="color: #999; font-size: 12px;">Mindestens 8 Zeichen</small> <small style="color:var(--text-muted);font-size:12px;"><?= __('users_password_hint') ?></small>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="checkbox-group"> <div class="checkbox-group">
<input type="checkbox" id="is_admin" name="is_admin" onchange="toggleSiteSelection('add')"> <input type="checkbox" id="add_is_admin" name="is_admin" onchange="toggleSiteSelection('add')">
<label for="is_admin" style="margin: 0;">Administrator-Rechte</label> <label for="add_is_admin" style="margin:0;"><?= __('users_admin_check') ?></label>
</div> </div>
<small style="color: #999; font-size: 12px;">Admins haben Zugriff auf alle Sites und Einstellungen</small> <small style="color:var(--text-muted);font-size:12px;"><?= __('users_admin_hint') ?></small>
</div> </div>
<div class="form-group" id="siteSelectionGroup"> <div class="form-group" id="siteSelectionGroup">
<label>Site-Zugriffe</label> <label><?= __('users_site_access') ?></label>
<div class="site-selection"> <div class="site-selection">
<?php if (empty($sites)): ?> <?php if (empty($sites)): ?>
<em style="color: #999;">Keine Sites verfügbar. Bitte zuerst Sites anlegen.</em> <em style="color:var(--text-muted);"><?= __('users_no_sites') ?></em>
<?php else: ?> <?php else: ?>
<?php foreach ($sites as $site): ?> <?php foreach ($sites as $site): ?>
<div class="checkbox-group"> <div class="checkbox-group">
<input type="checkbox" name="site_ids[]" value="<?= $site['id'] ?>" id="site_<?= $site['id'] ?>"> <input type="checkbox" name="site_ids[]" value="<?= $site['id'] ?>" id="add_site_<?= $site['id'] ?>">
<label for="site_<?= $site['id'] ?>" style="margin: 0;"><?= htmlspecialchars($site['name']) ?></label> <label for="add_site_<?= $site['id'] ?>" style="margin:0;"><?= htmlspecialchars($site['name']) ?></label>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
<small style="color: #999; font-size: 12px;">Wählen Sie die Sites, auf die dieser Benutzer Zugriff haben soll</small> <small style="color:var(--text-muted);font-size:12px;"><?= __('users_site_hint') ?></small>
</div> </div>
<div style="display:flex;gap:10px;margin-top:20px;">
<div style="display: flex; gap: 10px; margin-top: 25px;">
<button type="submit" name="add_user" class="btn btn-primary" style="flex:1;"> <button type="submit" name="add_user" class="btn btn-primary" style="flex:1;">
<i class="fas fa-save"></i> Benutzer anlegen <i class="fas fa-save"></i> <?= __('users_save') ?>
</button>
<button type="button" onclick="closeModal('addUserModal')" class="btn btn-secondary">
Abbrechen
</button> </button>
<button type="button" onclick="closeModal('addUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal für Benutzer bearbeiten --> <!-- Edit User Modal -->
<div id="editUserModal" class="modal"> <div id="editUserModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Benutzer bearbeiten</h2> <h2 class="modal-title"><?= __('users_edit_title') ?></h2>
<button class="modal-close" onclick="closeModal('editUserModal')">&times;</button> <button class="modal-close" onclick="closeModal('editUserModal')">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form method="post" id="editUserForm"> <form method="post" id="editUserForm">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="user_id" id="edit_user_id"> <input type="hidden" name="user_id" id="edit_user_id">
<div class="form-group"> <div class="form-group">
<label>Name</label> <label><?= __('label_name') ?></label>
<input type="text" id="edit_name" readonly style="background: #f5f5f5;"> <input type="text" id="edit_name" readonly style="background:var(--bg-hover);">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="checkbox-group"> <div class="checkbox-group">
<input type="checkbox" id="edit_is_admin" name="is_admin" onchange="toggleSiteSelection('edit')"> <input type="checkbox" id="edit_is_admin" name="is_admin" onchange="toggleSiteSelection('edit')">
<label for="edit_is_admin" style="margin: 0;">Administrator-Rechte</label> <label for="edit_is_admin" style="margin:0;"><?= __('users_admin_check') ?></label>
</div> </div>
<small style="color: #999; font-size: 12px;">Admins haben Zugriff auf alle Sites und Einstellungen</small> <small style="color:var(--text-muted);font-size:12px;"><?= __('users_admin_hint') ?></small>
</div> </div>
<div class="form-group" id="editSiteSelectionGroup"> <div class="form-group" id="editSiteSelectionGroup">
<label>Site-Zugriffe</label> <label><?= __('users_site_access') ?></label>
<div class="site-selection" id="editSitesList"> <div class="site-selection" id="editSitesList">
<?php if (!empty($sites)): ?>
<?php foreach ($sites as $site): ?> <?php foreach ($sites as $site): ?>
<div class="checkbox-group"> <div class="checkbox-group">
<input type="checkbox" name="site_ids[]" value="<?= $site['id'] ?>" id="edit_site_<?= $site['id'] ?>"> <input type="checkbox" name="site_ids[]" value="<?= $site['id'] ?>" id="edit_site_<?= $site['id'] ?>">
<label for="edit_site_<?= $site['id'] ?>" style="margin:0;"><?= htmlspecialchars($site['name']) ?></label> <label for="edit_site_<?= $site['id'] ?>" style="margin:0;"><?= htmlspecialchars($site['name']) ?></label>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?>
</div> </div>
</div> </div>
<div style="display:flex;gap:10px;margin-top:20px;">
<div style="display: flex; gap: 10px; margin-top: 25px;">
<button type="submit" name="edit_user" class="btn btn-primary" style="flex:1;"> <button type="submit" name="edit_user" class="btn btn-primary" style="flex:1;">
<i class="fas fa-save"></i> Änderungen speichern <i class="fas fa-save"></i> <?= __('users_save_edit') ?>
</button>
<button type="button" onclick="closeModal('editUserModal')" class="btn btn-secondary">
Abbrechen
</button> </button>
<button type="button" onclick="closeModal('editUserModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<div id="toast-container"></div>
<script src="../assets/global.js"></script>
<script> <script>
function openModal() { function openModal() { document.getElementById('addUserModal').classList.add('active'); }
document.getElementById('addUserModal').classList.add('active'); function closeModal(id) { document.getElementById(id).classList.remove('active'); }
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('active');
}
function openEditModal(userId, userName, isAdmin, siteIds) { function openEditModal(userId, userName, isAdmin, siteIds) {
document.getElementById('edit_user_id').value = userId; document.getElementById('edit_user_id').value = userId;
document.getElementById('edit_name').value = userName; document.getElementById('edit_name').value = userName;
document.getElementById('edit_is_admin').checked = isAdmin == 1; document.getElementById('edit_is_admin').checked = isAdmin == 1;
document.querySelectorAll('#editSitesList input[type="checkbox"]').forEach(cb => cb.checked = false);
// Alle Checkboxen erst deaktivieren siteIds.forEach(id => { const cb = document.getElementById('edit_site_' + id); if (cb) cb.checked = true; });
document.querySelectorAll('#editSitesList input[type="checkbox"]').forEach(cb => {
cb.checked = false;
});
// Ausgewählte Sites aktivieren
siteIds.forEach(siteId => {
const checkbox = document.getElementById('edit_site_' + siteId);
if (checkbox) checkbox.checked = true;
});
toggleSiteSelection('edit'); toggleSiteSelection('edit');
document.getElementById('editUserModal').classList.add('active'); document.getElementById('editUserModal').classList.add('active');
} }
function toggleSiteSelection(mode) { function toggleSiteSelection(mode) {
const isAdmin = document.getElementById(mode + '_is_admin').checked; const isAdmin = document.getElementById(mode + '_is_admin').checked;
const siteSelection = document.getElementById(mode === 'add' ? 'siteSelectionGroup' : 'editSiteSelectionGroup'); const group = document.getElementById(mode === 'add' ? 'siteSelectionGroup' : 'editSiteSelectionGroup');
siteSelection.style.display = isAdmin ? 'none' : 'block'; if (group) group.style.display = isAdmin ? 'none' : 'block';
} }
// Initial state
toggleSiteSelection('add'); toggleSiteSelection('add');
// Modal schließen bei Klick außerhalb ['addUserModal','editUserModal'].forEach(id => {
document.getElementById('addUserModal').addEventListener('click', function(e) { document.getElementById(id).addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) closeModal(id);
closeModal('addUserModal');
}
}); });
document.getElementById('editUserModal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal('editUserModal');
}
}); });
</script> </script>
</body> </body>

File diff suppressed because it is too large Load diff

471
assets/global.css Normal file
View file

@ -0,0 +1,471 @@
/* === DARK MODE CSS VARIABLES === */
:root {
--bg-body: #f5f7fa;
--bg-card: #ffffff;
--bg-header: #ffffff;
--bg-sidebar: #ffffff;
--bg-hover: #f8f9fa;
--bg-input: #ffffff;
--bg-table-head: #f8f9fa;
--bg-badge-info: #d1ecf1;
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--text-badge-info: #0c5460;
--border-color: #e0e0e0;
--border-hover: #667eea;
--shadow: rgba(0,0,0,0.05);
--shadow-lg: rgba(0,0,0,0.3);
--accent: #667eea;
--accent-hover: #5568d3;
--danger: #dc3545;
--success: #28a745;
--warning: #ffc107;
--code-bg: #f8f9fa;
--input-border: #e0e0e0;
--input-focus: #667eea;
--modal-overlay: rgba(0,0,0,0.5);
--scrollbar-track: #f1f1f1;
--scrollbar-thumb: #c1c1c1;
--toast-bg: #ffffff;
--stat-sub: #f0f0f0;
}
[data-theme="dark"] {
--bg-body: #0f1117;
--bg-card: #1a1d27;
--bg-header: #1a1d27;
--bg-sidebar: #1a1d27;
--bg-hover: #22273a;
--bg-input: #22273a;
--bg-table-head: #22273a;
--bg-badge-info: #0c3d47;
--text-primary: #e8eaf0;
--text-secondary: #a0a8b8;
--text-muted: #6b7280;
--text-badge-info: #7dd3eb;
--border-color: #2e3347;
--border-hover: #667eea;
--shadow: rgba(0,0,0,0.3);
--shadow-lg: rgba(0,0,0,0.6);
--accent: #7c8ff5;
--accent-hover: #667eea;
--danger: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
--code-bg: #22273a;
--input-border: #2e3347;
--input-focus: #7c8ff5;
--modal-overlay: rgba(0,0,0,0.7);
--scrollbar-track: #1a1d27;
--scrollbar-thumb: #3a3f5a;
--toast-bg: #1a1d27;
--stat-sub: #22273a;
}
/* === DARK MODE OVERRIDES FOR COMMON ELEMENTS === */
[data-theme="dark"] body {
background: var(--bg-body);
color: var(--text-primary);
}
[data-theme="dark"] .header {
background: var(--bg-header) !important;
border-color: var(--border-color) !important;
}
[data-theme="dark"] .sidebar {
background: var(--bg-sidebar) !important;
border-color: var(--border-color) !important;
}
[data-theme="dark"] .sidebar-nav a {
color: var(--text-secondary) !important;
}
[data-theme="dark"] .sidebar-nav a:hover,
[data-theme="dark"] .sidebar-nav a.active {
background: var(--bg-hover) !important;
color: var(--accent) !important;
}
[data-theme="dark"] .card,
[data-theme="dark"] .stat-card,
[data-theme="dark"] .site-status-card,
[data-theme="dark"] .site-card {
background: var(--bg-card) !important;
border-color: var(--border-color) !important;
}
[data-theme="dark"] .card-header,
[data-theme="dark"] .modal-header {
border-color: var(--border-color) !important;
}
[data-theme="dark"] .card-title,
[data-theme="dark"] .page-title,
[data-theme="dark"] .modal-title,
[data-theme="dark"] .stat-card-value,
[data-theme="dark"] h1, [data-theme="dark"] h2, [data-theme="dark"] h3 {
color: var(--text-primary) !important;
}
[data-theme="dark"] .page-subtitle,
[data-theme="dark"] .stat-card-title,
[data-theme="dark"] .stat-card-sub,
[data-theme="dark"] label {
color: var(--text-secondary) !important;
}
[data-theme="dark"] .table th {
background: var(--bg-table-head) !important;
color: var(--text-secondary) !important;
}
[data-theme="dark"] .table td {
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
}
[data-theme="dark"] .table tr:hover {
background: var(--bg-hover) !important;
}
[data-theme="dark"] input[type="text"],
[data-theme="dark"] input[type="email"],
[data-theme="dark"] input[type="password"],
[data-theme="dark"] input[type="number"],
[data-theme="dark"] input[type="url"],
[data-theme="dark"] select,
[data-theme="dark"] textarea {
background: var(--bg-input) !important;
border-color: var(--input-border) !important;
color: var(--text-primary) !important;
}
[data-theme="dark"] input::placeholder,
[data-theme="dark"] textarea::placeholder {
color: var(--text-muted) !important;
}
[data-theme="dark"] input:focus,
[data-theme="dark"] select:focus,
[data-theme="dark"] textarea:focus {
border-color: var(--input-focus) !important;
}
[data-theme="dark"] .btn-secondary {
background: var(--bg-hover) !important;
color: var(--text-secondary) !important;
border-color: var(--border-color) !important;
}
[data-theme="dark"] .btn-secondary:hover {
background: var(--border-color) !important;
color: var(--text-primary) !important;
}
[data-theme="dark"] code {
background: var(--code-bg) !important;
color: var(--accent) !important;
}
[data-theme="dark"] .modal-content {
background: var(--bg-card) !important;
}
[data-theme="dark"] .badge-info {
background: var(--bg-badge-info) !important;
color: var(--text-badge-info) !important;
}
[data-theme="dark"] .stat-card-icon {
filter: brightness(0.8);
}
[data-theme="dark"] .info-box {
background: #0c2340 !important;
border-color: #1a4a7a !important;
}
[data-theme="dark"] .info-box h4 { color: #60a5fa !important; }
[data-theme="dark"] .info-box p { color: #93c5fd !important; }
[data-theme="dark"] .placeholder-info {
background: #1a1500 !important;
border-color: #44390a !important;
}
[data-theme="dark"] .placeholder-info h4 { color: #d4a017 !important; }
[data-theme="dark"] .placeholder-info code { background: #2a2000 !important; }
[data-theme="dark"] .alert-error {
background: #2d0a0a !important;
border-color: #7f1d1d !important;
color: #fca5a5 !important;
}
[data-theme="dark"] .alert-success {
background: #052e16 !important;
border-color: #14532d !important;
color: #86efac !important;
}
[data-theme="dark"] .user-menu {
background: var(--bg-hover) !important;
}
[data-theme="dark"] .filter-btn {
background: var(--bg-card) !important;
border-color: var(--border-color) !important;
color: var(--text-primary) !important;
}
[data-theme="dark"] .filter-btn.active {
background: var(--accent) !important;
border-color: var(--accent) !important;
color: white !important;
}
[data-theme="dark"] .tab-navigation {
background: var(--bg-table-head) !important;
border-color: var(--border-color) !important;
}
[data-theme="dark"] .tab-button {
color: var(--text-secondary) !important;
}
[data-theme="dark"] .tab-button.active {
background: var(--bg-card) !important;
color: var(--accent) !important;
}
[data-theme="dark"] .section-divider {
border-color: var(--border-color) !important;
}
[data-theme="dark"] .site-stat {
background: var(--bg-hover) !important;
}
[data-theme="dark"] .live-badge {
background: #052e16 !important;
color: #86efac !important;
}
[data-theme="dark"] .live-badge.loading {
background: #1a0e00 !important;
color: #fcd34d !important;
}
[data-theme="dark"] .live-badge.error {
background: #2d0a0a !important;
color: #fca5a5 !important;
}
/* === DARK MODE TOGGLE BUTTON === */
.dark-mode-toggle {
background: none;
border: 2px solid var(--border-color);
border-radius: 8px;
width: 38px;
height: 38px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.2s;
color: var(--text-secondary);
flex-shrink: 0;
}
.dark-mode-toggle:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--bg-hover);
}
/* === LANGUAGE SWITCHER === */
.lang-switcher {
display: flex;
align-items: center;
gap: 4px;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 4px;
flex-shrink: 0;
}
.lang-btn {
padding: 4px 8px;
border: none;
background: none;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
transition: all 0.2s;
}
.lang-btn.active {
background: var(--accent);
color: white;
}
.lang-btn:hover:not(.active) {
color: var(--accent);
}
/* === TOAST NOTIFICATIONS === */
#toast-container {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
background: var(--toast-bg);
padding: 14px 20px;
border-radius: 12px;
box-shadow: 0 10px 40px var(--shadow-lg);
display: flex;
align-items: center;
gap: 12px;
min-width: 300px;
max-width: 400px;
transform: translateX(120%);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
pointer-events: all;
border: 1px solid var(--border-color);
}
.toast.show { transform: translateX(0); }
.toast.success { border-left: 4px solid var(--success); }
.toast.error { border-left: 4px solid var(--danger); }
.toast.info { border-left: 4px solid var(--accent); }
.toast.warning { border-left: 4px solid var(--warning); }
.toast-icon { font-size: 18px; flex-shrink: 0; }
.toast.success .toast-icon { color: var(--success); }
.toast.error .toast-icon { color: var(--danger); }
.toast.info .toast-icon { color: var(--accent); }
.toast.warning .toast-icon { color: var(--warning); }
.toast-body { flex: 1; }
.toast-title { font-weight: 600; font-size: 14px; color: var(--text-primary); }
.toast-msg { font-size: 13px; color: var(--text-secondary); margin-top: 2px; }
.toast-close {
background: none; border: none; cursor: pointer;
color: var(--text-muted); font-size: 18px; padding: 0; line-height: 1;
transition: color 0.2s;
}
.toast-close:hover { color: var(--text-primary); }
/* === MOBILE RESPONSIVE === */
.mobile-menu-btn {
display: none;
background: none;
border: 2px solid var(--border-color);
border-radius: 8px;
width: 38px;
height: 38px;
cursor: pointer;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--text-secondary);
transition: all 0.2s;
flex-shrink: 0;
}
.mobile-menu-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.sidebar-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 199;
}
.sidebar-overlay.active { display: block; }
@media (max-width: 768px) {
.mobile-menu-btn { display: flex; }
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 200;
}
.sidebar.mobile-open { transform: translateX(0); }
.main-content {
margin-left: 0 !important;
padding: 15px !important;
}
.header {
padding: 0 15px !important;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.site-status {
grid-template-columns: 1fr !important;
}
.sites-grid {
grid-template-columns: 1fr !important;
}
.page-header {
flex-direction: column;
align-items: flex-start !important;
gap: 15px;
}
.table-container {
overflow-x: auto;
}
.header-right {
gap: 8px !important;
}
.user-menu span { display: none; }
.form-grid {
grid-template-columns: 1fr !important;
}
.tab-navigation {
flex-wrap: wrap;
}
.filter-row {
flex-wrap: wrap;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr !important;
}
.lang-switcher { display: none; }
}
/* === SCROLLBAR STYLING === */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--scrollbar-track); }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* === SMOOTH TRANSITIONS === */
body, .card, .sidebar, .header, input, select, textarea, .btn {
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
}

117
assets/global.js Normal file
View file

@ -0,0 +1,117 @@
/* === DARK MODE === */
(function() {
const saved = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
})();
function toggleDarkMode() {
const html = document.documentElement;
const current = html.getAttribute('data-theme') || 'light';
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
updateDarkModeBtn();
}
function updateDarkModeBtn() {
const btn = document.getElementById('darkModeBtn');
if (!btn) return;
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
btn.textContent = isDark ? '☀️' : '🌙';
btn.title = isDark ? 'Light Mode' : 'Dark Mode';
}
document.addEventListener('DOMContentLoaded', updateDarkModeBtn);
/* === TOAST NOTIFICATIONS === */
(function() {
let container = null;
function getContainer() {
if (!container) {
container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
document.body.appendChild(container);
}
}
return container;
}
const icons = {
success: '✓',
error: '✕',
info: '',
warning: '⚠'
};
window.showToast = function(type, title, message, duration) {
duration = duration || 4000;
const c = getContainer();
const el = document.createElement('div');
el.className = 'toast ' + type;
el.innerHTML = `
<span class="toast-icon">${icons[type] || ''}</span>
<div class="toast-body">
<div class="toast-title">${title}</div>
${message ? `<div class="toast-msg">${message}</div>` : ''}
</div>
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
`;
c.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add('show'));
});
setTimeout(() => {
el.classList.remove('show');
setTimeout(() => el.remove(), 350);
}, duration);
return el;
};
})();
/* === MOBILE SIDEBAR === */
function toggleMobileSidebar() {
const sidebar = document.querySelector('.sidebar');
const overlay = document.querySelector('.sidebar-overlay');
if (!sidebar) return;
sidebar.classList.toggle('mobile-open');
if (overlay) overlay.classList.toggle('active');
}
function closeMobileSidebar() {
const sidebar = document.querySelector('.sidebar');
const overlay = document.querySelector('.sidebar-overlay');
if (sidebar) sidebar.classList.remove('mobile-open');
if (overlay) overlay.classList.remove('active');
}
document.addEventListener('DOMContentLoaded', function() {
const overlay = document.querySelector('.sidebar-overlay');
if (overlay) overlay.addEventListener('click', closeMobileSidebar);
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeMobileSidebar();
});
});
/* === LANGUAGE SWITCHER === */
function switchLanguage(lang) {
fetch('?set_lang=' + lang, { method: 'GET' }).then(() => location.reload());
}
/* === CLIPBOARD === */
function copyToClipboard(text, successMsg) {
navigator.clipboard.writeText(text).then(() => {
showToast('success', successMsg || 'Kopiert!', '');
}).catch(() => {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('success', successMsg || 'Kopiert!', '');
});
}

View file

@ -47,6 +47,19 @@ CREATE TABLE IF NOT EXISTS `user_site_access` (
UNIQUE KEY `unique_user_site` (`user_id`, `site_id`) UNIQUE KEY `unique_user_site` (`user_id`, `site_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `voucher_templates` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`max_uses` INT NOT NULL DEFAULT 1,
`expire_minutes` INT NOT NULL DEFAULT 480,
`description` VARCHAR(500),
`is_active` TINYINT(1) DEFAULT 1,
`created_by` INT,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `vouchers` ( CREATE TABLE IF NOT EXISTS `vouchers` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL, `site_id` INT NOT NULL,
@ -70,15 +83,6 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
INDEX `idx_status` (`status`) INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migration für bestehende Tabellen (falls bereits vorhanden):
-- ALTER TABLE vouchers ADD COLUMN `status` ENUM('valid', 'used', 'expired') DEFAULT 'valid';
-- ALTER TABLE vouchers ADD COLUMN `used_count` INT DEFAULT 0;
-- ALTER TABLE vouchers ADD COLUMN `expires_at` TIMESTAMP NULL;
-- ALTER TABLE vouchers ADD COLUMN `synced_from_unifi` TINYINT(1) DEFAULT 0;
-- ALTER TABLE vouchers ADD COLUMN `last_sync` TIMESTAMP NULL;
-- ALTER TABLE vouchers ADD INDEX `idx_unifi_id` (`unifi_voucher_id`);
-- ALTER TABLE vouchers ADD INDEX `idx_status` (`status`);
CREATE TABLE IF NOT EXISTS `sessions` ( CREATE TABLE IF NOT EXISTS `sessions` (
`id` VARCHAR(128) PRIMARY KEY, `id` VARCHAR(128) PRIMARY KEY,
`user_id` INT NOT NULL, `user_id` INT NOT NULL,
@ -114,5 +118,21 @@ CREATE TABLE IF NOT EXISTS `audit_log` (
INDEX `idx_created` (`created_at`) INDEX `idx_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migration für bestehende Installationen: CREATE TABLE IF NOT EXISTS `password_reset_tokens` (
-- Neue Tabellen werden automatisch erstellt (CREATE TABLE IF NOT EXISTS) `id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`token` VARCHAR(128) NOT NULL,
`expires_at` TIMESTAMP NOT NULL,
`used` TINYINT(1) DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
UNIQUE KEY `unique_token` (`token`),
INDEX `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations für bestehende Installationen:
-- ALTER TABLE vouchers ADD COLUMN IF NOT EXISTS `status` ENUM('valid', 'used', 'expired') DEFAULT 'valid';
-- ALTER TABLE vouchers ADD COLUMN IF NOT EXISTS `used_count` INT DEFAULT 0;
-- ALTER TABLE vouchers ADD COLUMN IF NOT EXISTS `expires_at` TIMESTAMP NULL;
-- ALTER TABLE vouchers ADD COLUMN IF NOT EXISTS `synced_from_unifi` TINYINT(1) DEFAULT 0;
-- ALTER TABLE vouchers ADD COLUMN IF NOT EXISTS `last_sync` TIMESTAMP NULL;

132
forgot_password.php Normal file
View file

@ -0,0 +1,132 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
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/I18n.php';
$auth = new Auth();
if ($auth->isLoggedIn()) { header('Location: index.php'); exit; }
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$systemUrl = rtrim($db->getSetting('system_url', ''), '/');
$error = '';
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = __('error_email_invalid');
} else {
$user = $db->fetchOne("SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL", [$email]);
// Always show success (don't reveal whether email exists)
if ($user) {
try {
// Delete old tokens for this user
$db->execute("DELETE FROM password_reset_tokens WHERE user_id = ?", [$user['id']]);
// Generate token
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour'));
$db->execute(
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
[$user['id'], $token, $expiresAt]
);
// Send email
$resetUrl = $systemUrl . '/reset_password.php?token=' . $token;
$mailer = new Mailer();
$subject = $appTitle . ' Passwort zurücksetzen';
$body = "Hallo {$user['name']},\n\n" .
"Sie haben eine Passwort-Rücksetzung angefordert.\n\n" .
"Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen (gültig für 1 Stunde):\n\n" .
$resetUrl . "\n\n" .
"Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail.\n\n" .
$appTitle;
$mailer->sendRaw($user['email'], $subject, $body);
// Audit log
$db->execute(
"INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address) VALUES (?, 'password_reset', 'user', ?, 'Reset-Link angefordert', ?)",
[$user['id'], $user['id'], $_SERVER['REMOTE_ADDR'] ?? '']
);
} catch (Exception $e) {
// Silent don't reveal errors to user
}
}
$success = __('reset_success');
}
}
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('reset_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.box { background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); max-width: 420px; width: 100%; padding: 45px 40px; text-align: center; }
.logo { max-width: 180px; height: auto; margin-bottom: 25px; }
h1 { color: var(--text-primary); font-size: 26px; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 28px; line-height: 1.5; }
.form-group { margin-bottom: 18px; text-align: left; }
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
input[type="email"] { width: 100%; padding: 13px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; background: var(--bg-input); color: var(--text-primary); transition: border-color 0.2s; }
input:focus { outline: none; border-color: var(--accent); }
.btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 8px; }
.btn:hover { background: var(--accent-hover); transform: translateY(-2px); }
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: left; }
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.back-link { display: block; margin-top: 22px; color: var(--accent); text-decoration: none; font-size: 14px; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="box">
<?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<?php else: ?>
<h1><?= htmlspecialchars($appTitle) ?></h1>
<?php endif; ?>
<h1><?= __('reset_title') ?></h1>
<p class="subtitle"><?= __('reset_subtitle') ?></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; ?>
<?php if (!$success): ?>
<form method="post">
<div class="form-group">
<label for="email"><?= __('reset_email_label') ?></label>
<input type="email" id="email" name="email" required autofocus placeholder="name@example.com">
</div>
<button type="submit" class="btn"><?= __('reset_send_btn') ?></button>
</form>
<?php endif; ?>
<a href="login.php" class="back-link"><?= __('reset_back_login') ?></a>
</div>
<script src="assets/global.js"></script>
</body>
</html>

View file

@ -38,6 +38,7 @@ class Auth {
$this->clearLoginAttempts($ip, $email); $this->clearLoginAttempts($ip, $email);
$this->setUserSession($user); $this->setUserSession($user);
$this->updateLastLogin($user['id']); $this->updateLastLogin($user['id']);
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich');
return true; return true;
} }
@ -45,6 +46,17 @@ class Auth {
return false; return false;
} }
public function writeAuditLog($userId, $action, $entityType = null, $entityId = null, $details = null) {
try {
$this->db->execute(
"INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)",
[$userId, $action, $entityType, $entityId !== null ? (string)$entityId : null, $details, $_SERVER['REMOTE_ADDR'] ?? '']
);
} catch (\Exception $e) {
// audit_log table may not exist on old installs
}
}
private function isRateLimited($ip, $email) { private function isRateLimited($ip, $email) {
try { try {
$count = $this->db->fetchOne( $count = $this->db->fetchOne(

53
includes/I18n.php Normal file
View file

@ -0,0 +1,53 @@
<?php
class I18n {
private static array $translations = [];
private static string $language = 'de';
private static bool $initialized = false;
public static function init(): void {
if (self::$initialized) return;
if (!isset($_SESSION)) {
if (session_status() === PHP_SESSION_NONE) session_start();
}
if (isset($_GET['set_lang']) && in_array($_GET['set_lang'], ['de', 'en'], true)) {
$_SESSION['language'] = $_GET['set_lang'];
}
self::$language = $_SESSION['language'] ?? 'de';
$langFile = __DIR__ . '/../lang/' . self::$language . '.php';
if (file_exists($langFile)) {
self::$translations = require $langFile;
} else {
$fallback = __DIR__ . '/../lang/de.php';
if (file_exists($fallback)) {
self::$translations = require $fallback;
}
}
self::$initialized = true;
}
public static function t(string $key, array $replace = []): string {
$text = self::$translations[$key] ?? $key;
foreach ($replace as $k => $v) {
$text = str_replace('{' . $k . '}', (string)$v, $text);
}
return $text;
}
public static function getLanguage(): string {
return self::$language;
}
public static function getAvailable(): array {
return ['de' => 'Deutsch', 'en' => 'English'];
}
}
function __($key, array $replace = []): string {
return I18n::t($key, $replace);
}

View file

@ -26,6 +26,10 @@ class Mailer {
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System')); $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) { public function send($to, $subject, $body, $isHtml = false) {
if (!$this->smtpEnabled || empty($this->smtpHost)) { if (!$this->smtpEnabled || empty($this->smtpHost)) {
// Fallback auf PHP mail() // Fallback auf PHP mail()

124
includes/admin_nav.php Normal file
View file

@ -0,0 +1,124 @@
<?php
/**
* Shared admin navigation/header snippet.
* Expects $currentPage (string), $appTitle (string), $auth, $db to be set before include.
* Expects I18n to be initialized.
*/
$currentPage = $currentPage ?? '';
$faviconUrl = isset($db) ? $db->getSetting('favicon_url', '') : '';
$currentUser = isset($auth) ? $auth->getCurrentUser() : null;
$lang = I18n::getLanguage();
?>
<?php if ($faviconUrl): ?>
<link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($faviconUrl) ?>">
<?php endif; ?>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="<?= $adminBase ?? '../' ?>assets/global.css">
<script>
(function(){
const t = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
})();
</script>
<style>
/* Base admin layout using CSS variables */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: var(--bg-body); color: var(--text-primary); }
.header { background: var(--bg-header); border-bottom: 1px solid var(--border-color); padding: 0 30px; height: 70px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 150; box-shadow: 0 2px 10px var(--shadow); }
.header-left { display: flex; align-items: center; gap: 12px; }
.header-title { font-size: 20px; font-weight: 600; color: var(--text-primary); }
.header-right { display: flex; align-items: center; gap: 10px; }
.sidebar { position: fixed; left: 0; top: 70px; bottom: 0; width: 260px; background: var(--bg-sidebar); border-right: 1px solid var(--border-color); padding: 20px 0; overflow-y: auto; z-index: 100; }
.sidebar-nav { list-style: none; }
.sidebar-nav li { margin-bottom: 2px; }
.sidebar-nav a { display: flex; align-items: center; gap: 12px; padding: 11px 25px; color: var(--text-secondary); text-decoration: none; transition: all 0.2s; font-size: 14px; border-radius: 0 8px 8px 0; margin-right: 12px; }
.sidebar-nav a:hover, .sidebar-nav a.active { background: var(--bg-hover); color: var(--accent); }
.sidebar-nav i { width: 18px; text-align: center; font-size: 15px; }
.sidebar-section { padding: 16px 25px 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
.main-content { margin-left: 260px; padding: 30px; min-height: calc(100vh - 70px); }
.user-menu { display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: var(--bg-hover); border-radius: 10px; }
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 14px; flex-shrink: 0; }
.user-name { font-weight: 500; font-size: 13px; color: var(--text-primary); }
.btn { padding: 8px 16px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all 0.2s; font-size: 13px; }
.btn-secondary { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border-color); }
.btn-secondary:hover { background: var(--border-color); color: var(--text-primary); }
</style>
</head>
<body>
<!-- Sidebar overlay for mobile -->
<div class="sidebar-overlay" onclick="closeMobileSidebar()"></div>
<div class="header">
<div class="header-left">
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" title="Menu">
<i class="fas fa-bars"></i>
</button>
<div class="header-title">
<i class="fas fa-shield-alt" style="color: var(--accent);"></i>
<?= __('nav_administration') ?>
</div>
</div>
<div class="header-right">
<!-- Language switcher -->
<div class="lang-switcher">
<?php foreach (I18n::getAvailable() as $code => $label): ?>
<button class="lang-btn <?= $lang === $code ? 'active' : '' ?>"
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
<?php endforeach; ?>
</div>
<!-- Dark mode toggle -->
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">🌙</button>
<!-- Back link -->
<a href="<?= $adminBase ?? '../' ?>index.php" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
<span class="hide-mobile"><?= __('nav_back') ?></span>
</a>
<!-- User menu -->
<?php if ($currentUser): ?>
<div class="user-menu">
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
<div class="user-name hide-mobile"><?= htmlspecialchars($currentUser['name']) ?></div>
</div>
<?php endif; ?>
</div>
</div>
<div class="sidebar" id="adminSidebar">
<nav>
<div class="sidebar-section">Main</div>
<ul class="sidebar-nav">
<li><a href="<?= $adminBase ?? '' ?>index.php" class="<?= $currentPage === 'dashboard' ? 'active' : '' ?>">
<i class="fas fa-home"></i> <?= __('nav_dashboard') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>sites.php" class="<?= $currentPage === 'sites' ? 'active' : '' ?>">
<i class="fas fa-map-marker-alt"></i> <?= __('nav_sites') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>users.php" class="<?= $currentPage === 'users' ? 'active' : '' ?>">
<i class="fas fa-users"></i> <?= __('nav_users') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>vouchers.php" class="<?= $currentPage === 'vouchers' ? 'active' : '' ?>">
<i class="fas fa-ticket-alt"></i> <?= __('nav_vouchers') ?>
</a></li>
</ul>
<div class="sidebar-section" style="margin-top: 10px;">Tools</div>
<ul class="sidebar-nav">
<li><a href="<?= $adminBase ?? '' ?>templates.php" class="<?= $currentPage === 'templates' ? 'active' : '' ?>">
<i class="fas fa-layer-group"></i> <?= __('nav_templates') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>audit_log.php" class="<?= $currentPage === 'audit_log' ? 'active' : '' ?>">
<i class="fas fa-history"></i> <?= __('nav_audit_log') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
</a></li>
</ul>
</nav>
</div>
<div class="main-content">
<style>
.hide-mobile { }
@media(max-width:600px) { .hide-mobile { display: none; } }
</style>

906
index.php

File diff suppressed because it is too large Load diff

288
lang/de.php Normal file
View file

@ -0,0 +1,288 @@
<?php
return [
// Navigation
'nav_dashboard' => 'Dashboard',
'nav_sites' => 'Sites verwalten',
'nav_users' => 'Benutzer verwalten',
'nav_vouchers' => 'Live Vouchers',
'nav_templates' => 'Voucher-Profile',
'nav_audit_log' => 'Audit-Log',
'nav_settings' => 'Einstellungen',
'nav_back' => 'Zurück zur Startseite',
'nav_administration'=> 'Administration',
// Common buttons
'btn_save' => 'Speichern',
'btn_cancel' => 'Abbrechen',
'btn_add' => 'Hinzufügen',
'btn_edit' => 'Bearbeiten',
'btn_delete' => 'Löschen',
'btn_create' => 'Erstellen',
'btn_close' => 'Schließen',
'btn_back' => 'Zurück',
'btn_login' => 'Anmelden',
'btn_logout' => 'Abmelden',
'btn_refresh' => 'Aktualisieren',
'btn_export_csv' => 'CSV exportieren',
'btn_print' => 'Ausdrucken',
'btn_send' => 'Senden',
'btn_test' => 'Testen',
'btn_generate' => 'Generieren',
'btn_copy' => 'Kopieren',
'btn_new_code' => 'Weiteren Code erstellen',
// Common labels
'label_name' => 'Name',
'label_email' => 'E-Mail',
'label_password' => 'Passwort',
'label_status' => 'Status',
'label_actions' => 'Aktionen',
'label_created' => 'Erstellt',
'label_site' => 'Standort',
'label_sites' => 'Sites',
'label_role' => 'Rolle',
'label_code' => 'Code',
'label_note' => 'Notiz',
'label_usage' => 'Nutzung',
'label_expires' => 'Gültigkeit',
'label_devices' => 'Geräte',
'label_duration' => 'Dauer',
'label_description' => 'Beschreibung',
'label_language' => 'Sprache',
// Status texts
'status_active' => 'Aktiv',
'status_inactive' => 'Inaktiv',
'status_valid' => 'Gültig',
'status_used' => 'Verwendet',
'status_expired' => 'Abgelaufen',
'status_admin' => 'Admin',
'status_user' => 'Benutzer',
'status_public' => 'Öffentlich',
'status_all' => 'Alle',
// Dashboard
'dashboard_title' => 'Dashboard',
'dashboard_subtitle'=> 'Übersicht über Ihr UniFi Voucher System',
'dashboard_active_sites' => 'Aktive Sites',
'dashboard_users' => 'Benutzer',
'dashboard_valid' => 'Gültige Vouchers',
'dashboard_used' => 'Verwendet',
'dashboard_expired' => 'Abgelaufen',
'dashboard_total' => 'Gesamt',
'dashboard_live_refresh' => 'Live aktualisieren',
'dashboard_trend' => 'Voucher-Trend (Letzte 7 Tage)',
'dashboard_top_users'=> 'Top 5 Benutzer (Letzte 30 Tage)',
'dashboard_recent' => 'Letzte Vouchers (aus Datenbank)',
'dashboard_creator' => 'Ersteller',
'dashboard_public' => 'Öffentlich',
'dashboard_vouchers_per_site' => 'Live: Vouchers pro Site',
'dashboard_no_data' => 'Noch keine Daten verfügbar',
'dashboard_no_vouchers' => 'Noch keine Vouchers erstellt',
// Voucher creation
'voucher_name_label' => 'Voucher-Name *',
'voucher_name_hint' => 'z.B. Besprechung UL, Vertretername Firma XY',
'voucher_devices_label' => 'Wie viele Geräte dürfen sich einloggen? *',
'voucher_site_label' => 'Standort *',
'voucher_site_select' => 'Bitte wählen...',
'voucher_email_send' => 'Code per E-Mail versenden',
'voucher_email_label' => 'E-Mail-Adresse des Empfängers',
'voucher_email_hint' => 'gast@example.com',
'voucher_create_btn' => 'Voucher erstellen',
'voucher_creating' => 'Erstelle Voucher...',
'voucher_success_title' => '✓ Ihr Zugangs-Code',
'voucher_validity' => 'Gültig für {minutes} Minuten ab Erstellung',
'voucher_qr_label' => 'QR-Code scannen zum Verbinden',
'voucher_print_btn' => 'Code ausdrucken',
'voucher_no_sites' => 'Keine verfügbaren Sites gefunden.',
'voucher_no_sites_admin'=> 'Klicken Sie hier, um Sites anzulegen',
'voucher_no_sites_user' => 'Bitte kontaktieren Sie Ihren Administrator.',
'voucher_template_select'=> '-- Kein Profil (manuell) --',
'voucher_template_label'=> 'Schnellprofil (optional)',
// Bulk creation
'bulk_tab' => 'Einzeln',
'bulk_tab_bulk' => 'Mehrere auf einmal',
'bulk_quantity' => 'Anzahl Vouchers',
'bulk_quantity_hint'=> 'Wie viele Vouchers sollen erstellt werden?',
'bulk_name_prefix' => 'Name / Präfix',
'bulk_create_btn' => '{count} Vouchers erstellen',
'bulk_creating' => 'Erstelle {count} Vouchers...',
'bulk_success' => '{count} Vouchers erfolgreich erstellt!',
'bulk_print_all' => 'Alle ausdrucken',
'bulk_results' => 'Erstellte Vouchers ({count})',
// Templates
'templates_title' => 'Voucher-Profile',
'templates_subtitle'=> 'Vordefinierte Voucher-Konfigurationen',
'templates_add' => 'Neues Profil',
'templates_name' => 'Profilname',
'templates_devices' => 'Max. Geräte',
'templates_duration'=> 'Gültigkeit (Minuten)',
'templates_desc' => 'Beschreibung (optional)',
'templates_none' => 'Noch keine Profile vorhanden.',
'templates_add_hint'=> 'Erstellen Sie Profile für häufig verwendete Voucher-Konfigurationen.',
'templates_edit' => 'Profil bearbeiten',
'templates_added' => 'Profil erfolgreich erstellt!',
'templates_updated' => 'Profil erfolgreich aktualisiert!',
'templates_deleted' => 'Profil erfolgreich gelöscht!',
// Users
'users_title' => 'Benutzer verwalten',
'users_add' => 'Neuer Benutzer',
'users_all' => 'Alle Benutzer',
'users_site_access' => 'Site-Zugriffe',
'users_last_login' => 'Letzter Login',
'users_never' => 'Noch nie',
'users_you' => 'Sie',
'users_all_sites' => 'Alle Sites',
'users_none' => 'Keine',
'users_add_title' => 'Neuen Benutzer anlegen',
'users_edit_title' => 'Benutzer bearbeiten',
'users_admin_check' => 'Administrator-Rechte',
'users_admin_hint' => 'Admins haben Zugriff auf alle Sites und Einstellungen',
'users_password_hint'=> 'Mindestens 8 Zeichen',
'users_site_hint' => 'Wählen Sie die Sites, auf die dieser Benutzer Zugriff haben soll',
'users_no_sites' => 'Keine Sites verfügbar. Bitte zuerst Sites anlegen.',
'users_added' => 'Benutzer erfolgreich erstellt!',
'users_updated' => 'Benutzer erfolgreich aktualisiert!',
'users_deleted' => 'Benutzer erfolgreich gelöscht!',
'users_notified' => ' Benachrichtigung wurde versendet.',
'users_none_found' => 'Noch keine Benutzer vorhanden',
'users_save' => 'Benutzer anlegen',
'users_save_edit' => 'Änderungen speichern',
'users_reset_pw' => 'Passwort-Reset-Link senden',
// Sites
'sites_title' => 'Sites verwalten',
'sites_add' => 'Neue Site hinzufügen',
'sites_add_title' => 'Neue Site hinzufügen',
'sites_edit_title' => 'Site bearbeiten',
'sites_name' => 'Site-Name *',
'sites_site_id' => 'UniFi Site ID *',
'sites_controller' => 'Controller URL *',
'sites_username' => 'Benutzername *',
'sites_password' => 'Passwort *',
'sites_password_edit'=> 'Neues Passwort',
'sites_password_hint'=> 'Nur ausfüllen wenn Sie das Passwort ändern möchten',
'sites_public' => 'Öffentlicher Zugriff (ohne Login nutzbar)',
'sites_none' => 'Noch keine Sites konfiguriert. Fügen Sie Ihre erste Site hinzu!',
'sites_added' => 'Site erfolgreich hinzugefügt!',
'sites_updated' => 'Site erfolgreich aktualisiert!',
'sites_deleted' => 'Site erfolgreich gelöscht!',
'sites_testing' => 'Verbindung wird getestet...',
'sites_deactivate' => 'Deaktivieren',
'sites_activate' => 'Aktivieren',
// Voucher admin list
'vouchers_title' => 'Live Voucher-Verwaltung',
'vouchers_subtitle' => 'Vouchers werden direkt vom UniFi Controller abgerufen',
'vouchers_select_site'=> 'Site auswählen',
'vouchers_select_hint'=> '-- Site auswählen --',
'vouchers_last_sync'=> 'Letzte Sync',
'vouchers_no_sites' => 'Keine aktiven Sites vorhanden',
'vouchers_search' => 'Suchen (Code, Name...)',
'vouchers_filter_all'=> 'Alle',
'vouchers_filter_valid'=> 'Gültig',
'vouchers_filter_used'=> 'Verwendet',
'vouchers_filter_expired'=> 'Abgelaufen',
'vouchers_none' => 'Keine Vouchers gefunden.',
'vouchers_per_page' => 'pro Seite',
'vouchers_page_of' => 'Seite {current} von {total}',
// Audit Log
'audit_title' => 'Audit-Log',
'audit_subtitle' => 'Alle System-Aktionen im Überblick',
'audit_action' => 'Aktion',
'audit_user' => 'Benutzer',
'audit_details' => 'Details',
'audit_ip' => 'IP-Adresse',
'audit_time' => 'Zeitpunkt',
'audit_entity' => 'Objekt',
'audit_filter_all' => 'Alle Aktionen',
'audit_none' => 'Keine Einträge gefunden.',
'audit_filter' => 'Filter',
// Settings
'settings_title' => 'Einstellungen',
'settings_subtitle' => 'System-Konfiguration und Personalisierung',
'settings_tab_general' => 'Allgemein',
'settings_tab_defaults' => 'Voucher-Standards',
'settings_tab_cron' => 'Cron-Sync',
'settings_tab_m365' => 'Microsoft 365',
'settings_tab_smtp' => 'SMTP',
'settings_tab_templates_email' => 'Templates',
'settings_tab_system' => 'System',
'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_favicon_hint' => 'Icon im Browser-Tab (.ico, .png, .svg)',
'settings_instr_header' => 'Anleitung - Überschrift',
'settings_instr_text' => 'Anleitung - Text',
'settings_public_access'=> 'Öffentlicher Zugriff',
'settings_default_expire' => 'Standard-Gültigkeit (Minuten)',
'settings_default_expire_hint'=> 'Standard-Ablaufzeit für neue Vouchers (480 = 8 Stunden)',
'settings_default_devices' => 'Standard-Geräteanzahl',
'settings_default_devices_hint'=> 'Vorausgefüllter Wert im Voucher-Formular',
'settings_max_devices' => 'Maximale Geräteanzahl',
'settings_max_devices_hint' => 'Maximaler Wert, den ein Benutzer auswählen kann',
'settings_pw_current' => 'Aktuelles Passwort',
'settings_pw_new' => 'Neues Passwort',
'settings_pw_confirm' => 'Passwort bestätigen',
'settings_pw_changed' => 'Passwort erfolgreich geändert!',
'settings_pw_minlength' => 'Mindestens 8 Zeichen',
// Login / Auth
'login_title' => 'Anmelden',
'login_subtitle' => 'Melden Sie sich an, um fortzufahren',
'login_email' => 'E-Mail',
'login_password' => 'Passwort',
'login_btn' => 'Anmelden',
'login_ms' => 'Mit Microsoft anmelden',
'login_local' => 'Mit Benutzername und Passwort anmelden',
'login_back' => '← Zurück zur Code-Erstellung',
'login_forgot' => 'Passwort vergessen?',
'login_error_empty' => 'Bitte E-Mail und Passwort eingeben',
'login_error_rate' => 'Zu viele Fehlversuche. Bitte warten Sie 10 Minuten.',
'login_error_creds' => 'Ungültige E-Mail oder Passwort',
// Password Reset
'reset_title' => 'Passwort vergessen',
'reset_subtitle' => 'Geben Sie Ihre E-Mail ein wir senden Ihnen einen Reset-Link.',
'reset_email_label' => 'E-Mail-Adresse',
'reset_send_btn' => 'Reset-Link senden',
'reset_success' => 'Falls ein Konto mit dieser E-Mail existiert, erhalten Sie in Kürze eine E-Mail.',
'reset_back_login' => '← Zurück zum Login',
'reset_new_pw' => 'Neues Passwort festlegen',
'reset_new_pw_label'=> 'Neues Passwort',
'reset_confirm_label'=> 'Passwort bestätigen',
'reset_set_btn' => 'Passwort festlegen',
'reset_invalid' => 'Ungültiger oder abgelaufener Reset-Link.',
'reset_done' => 'Ihr Passwort wurde erfolgreich geändert.',
// Errors
'error_csrf' => 'Ungültiges Sicherheits-Token',
'error_login_req' => 'Sie müssen angemeldet sein',
'error_no_permission'=> 'Keine Berechtigung für diese Aktion',
'error_not_found' => 'Nicht gefunden',
'error_name_req' => 'Bitte geben Sie einen Namen ein',
'error_site_req' => 'Bitte wählen Sie einen Standort',
'error_devices_range'=> 'Anzahl der Geräte muss zwischen 1 und {max} liegen',
'error_email_invalid'=> 'Ungültige E-Mail-Adresse',
'error_site_no_perm'=> 'Keine Berechtigung für diese Site',
'error_site_not_found'=> 'Site nicht gefunden',
'error_voucher_invalid'=> 'UniFi hat keinen gültigen Voucher zurückgegeben',
'error_connection' => 'Verbindung zum UniFi Controller fehlgeschlagen',
'error_fill_all' => 'Bitte füllen Sie alle Pflichtfelder aus',
// Hello / User
'hello' => 'Hallo, {name}',
'minutes_short' => 'Min.',
'hours_short' => 'Std.',
'never' => 'Noch nie',
'unknown' => 'Unbekannt',
'or' => 'oder',
];

288
lang/en.php Normal file
View file

@ -0,0 +1,288 @@
<?php
return [
// Navigation
'nav_dashboard' => 'Dashboard',
'nav_sites' => 'Manage Sites',
'nav_users' => 'Manage Users',
'nav_vouchers' => 'Live Vouchers',
'nav_templates' => 'Voucher Profiles',
'nav_audit_log' => 'Audit Log',
'nav_settings' => 'Settings',
'nav_back' => 'Back to Home',
'nav_administration'=> 'Administration',
// Common buttons
'btn_save' => 'Save',
'btn_cancel' => 'Cancel',
'btn_add' => 'Add',
'btn_edit' => 'Edit',
'btn_delete' => 'Delete',
'btn_create' => 'Create',
'btn_close' => 'Close',
'btn_back' => 'Back',
'btn_login' => 'Sign In',
'btn_logout' => 'Sign Out',
'btn_refresh' => 'Refresh',
'btn_export_csv' => 'Export CSV',
'btn_print' => 'Print',
'btn_send' => 'Send',
'btn_test' => 'Test',
'btn_generate' => 'Generate',
'btn_copy' => 'Copy',
'btn_new_code' => 'Create Another Code',
// Common labels
'label_name' => 'Name',
'label_email' => 'Email',
'label_password' => 'Password',
'label_status' => 'Status',
'label_actions' => 'Actions',
'label_created' => 'Created',
'label_site' => 'Location',
'label_sites' => 'Sites',
'label_role' => 'Role',
'label_code' => 'Code',
'label_note' => 'Note',
'label_usage' => 'Usage',
'label_expires' => 'Expires',
'label_devices' => 'Devices',
'label_duration' => 'Duration',
'label_description' => 'Description',
'label_language' => 'Language',
// Status texts
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'status_valid' => 'Valid',
'status_used' => 'Used',
'status_expired' => 'Expired',
'status_admin' => 'Admin',
'status_user' => 'User',
'status_public' => 'Public',
'status_all' => 'All',
// Dashboard
'dashboard_title' => 'Dashboard',
'dashboard_subtitle'=> 'Overview of your UniFi Voucher System',
'dashboard_active_sites' => 'Active Sites',
'dashboard_users' => 'Users',
'dashboard_valid' => 'Valid Vouchers',
'dashboard_used' => 'Used',
'dashboard_expired' => 'Expired',
'dashboard_total' => 'Total',
'dashboard_live_refresh' => 'Live Refresh',
'dashboard_trend' => 'Voucher Trend (Last 7 Days)',
'dashboard_top_users'=> 'Top 5 Users (Last 30 Days)',
'dashboard_recent' => 'Recent Vouchers (from Database)',
'dashboard_creator' => 'Creator',
'dashboard_public' => 'Public',
'dashboard_vouchers_per_site' => 'Live: Vouchers per Site',
'dashboard_no_data' => 'No data available yet',
'dashboard_no_vouchers' => 'No vouchers created yet',
// Voucher creation
'voucher_name_label' => 'Voucher Name *',
'voucher_name_hint' => 'e.g. Meeting Room 3, Guest John Doe',
'voucher_devices_label' => 'How many devices may connect? *',
'voucher_site_label' => 'Location *',
'voucher_site_select' => 'Please select...',
'voucher_email_send' => 'Send code via email',
'voucher_email_label' => 'Recipient email address',
'voucher_email_hint' => 'guest@example.com',
'voucher_create_btn' => 'Create Voucher',
'voucher_creating' => 'Creating Voucher...',
'voucher_success_title' => '✓ Your Access Code',
'voucher_validity' => 'Valid for {minutes} minutes from creation',
'voucher_qr_label' => 'Scan QR code to connect',
'voucher_print_btn' => 'Print Code',
'voucher_no_sites' => 'No available sites found.',
'voucher_no_sites_admin'=> 'Click here to create sites',
'voucher_no_sites_user' => 'Please contact your administrator.',
'voucher_template_select'=> '-- No Profile (manual) --',
'voucher_template_label'=> 'Quick Profile (optional)',
// Bulk creation
'bulk_tab' => 'Single',
'bulk_tab_bulk' => 'Create Multiple',
'bulk_quantity' => 'Number of Vouchers',
'bulk_quantity_hint'=> 'How many vouchers should be created?',
'bulk_name_prefix' => 'Name / Prefix',
'bulk_create_btn' => 'Create {count} Vouchers',
'bulk_creating' => 'Creating {count} vouchers...',
'bulk_success' => '{count} vouchers created successfully!',
'bulk_print_all' => 'Print All',
'bulk_results' => 'Created Vouchers ({count})',
// Templates
'templates_title' => 'Voucher Profiles',
'templates_subtitle'=> 'Predefined voucher configurations',
'templates_add' => 'New Profile',
'templates_name' => 'Profile Name',
'templates_devices' => 'Max. Devices',
'templates_duration'=> 'Validity (Minutes)',
'templates_desc' => 'Description (optional)',
'templates_none' => 'No profiles found.',
'templates_add_hint'=> 'Create profiles for frequently used voucher configurations.',
'templates_edit' => 'Edit Profile',
'templates_added' => 'Profile created successfully!',
'templates_updated' => 'Profile updated successfully!',
'templates_deleted' => 'Profile deleted successfully!',
// Users
'users_title' => 'Manage Users',
'users_add' => 'New User',
'users_all' => 'All Users',
'users_site_access' => 'Site Access',
'users_last_login' => 'Last Login',
'users_never' => 'Never',
'users_you' => 'You',
'users_all_sites' => 'All Sites',
'users_none' => 'None',
'users_add_title' => 'Create New User',
'users_edit_title' => 'Edit User',
'users_admin_check' => 'Administrator Rights',
'users_admin_hint' => 'Admins have access to all sites and settings',
'users_password_hint'=> 'At least 8 characters',
'users_site_hint' => 'Select the sites this user should have access to',
'users_no_sites' => 'No sites available. Please create sites first.',
'users_added' => 'User created successfully!',
'users_updated' => 'User updated successfully!',
'users_deleted' => 'User deleted successfully!',
'users_notified' => ' Notification sent.',
'users_none_found' => 'No users found',
'users_save' => 'Create User',
'users_save_edit' => 'Save Changes',
'users_reset_pw' => 'Send Password Reset Link',
// Sites
'sites_title' => 'Manage Sites',
'sites_add' => 'Add New Site',
'sites_add_title' => 'Add New Site',
'sites_edit_title' => 'Edit Site',
'sites_name' => 'Site Name *',
'sites_site_id' => 'UniFi Site ID *',
'sites_controller' => 'Controller URL *',
'sites_username' => 'Username *',
'sites_password' => 'Password *',
'sites_password_edit'=> 'New Password',
'sites_password_hint'=> 'Leave blank to keep current password',
'sites_public' => 'Public Access (usable without login)',
'sites_none' => 'No sites configured yet. Add your first site!',
'sites_added' => 'Site added successfully!',
'sites_updated' => 'Site updated successfully!',
'sites_deleted' => 'Site deleted successfully!',
'sites_testing' => 'Testing connection...',
'sites_deactivate' => 'Deactivate',
'sites_activate' => 'Activate',
// Voucher admin list
'vouchers_title' => 'Live Voucher Management',
'vouchers_subtitle' => 'Vouchers are fetched directly from the UniFi Controller',
'vouchers_select_site'=> 'Select Site',
'vouchers_select_hint'=> '-- Select Site --',
'vouchers_last_sync'=> 'Last Sync',
'vouchers_no_sites' => 'No active sites available',
'vouchers_search' => 'Search (code, name...)',
'vouchers_filter_all'=> 'All',
'vouchers_filter_valid'=> 'Valid',
'vouchers_filter_used'=> 'Used',
'vouchers_filter_expired'=> 'Expired',
'vouchers_none' => 'No vouchers found.',
'vouchers_per_page' => 'per page',
'vouchers_page_of' => 'Page {current} of {total}',
// Audit Log
'audit_title' => 'Audit Log',
'audit_subtitle' => 'All system actions at a glance',
'audit_action' => 'Action',
'audit_user' => 'User',
'audit_details' => 'Details',
'audit_ip' => 'IP Address',
'audit_time' => 'Time',
'audit_entity' => 'Entity',
'audit_filter_all' => 'All Actions',
'audit_none' => 'No entries found.',
'audit_filter' => 'Filter',
// Settings
'settings_title' => 'Settings',
'settings_subtitle' => 'System configuration and customization',
'settings_tab_general' => 'General',
'settings_tab_defaults' => 'Voucher Defaults',
'settings_tab_cron' => 'Cron Sync',
'settings_tab_m365' => 'Microsoft 365',
'settings_tab_smtp' => 'SMTP',
'settings_tab_templates_email' => 'Templates',
'settings_tab_system' => 'System',
'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_favicon_hint' => 'Browser tab icon (.ico, .png, .svg)',
'settings_instr_header' => 'Instructions - Headline',
'settings_instr_text' => 'Instructions - Text',
'settings_public_access'=> 'Public Access',
'settings_default_expire' => 'Default Validity (Minutes)',
'settings_default_expire_hint'=> 'Default expiry time for new vouchers (480 = 8 hours)',
'settings_default_devices' => 'Default Device Count',
'settings_default_devices_hint'=> 'Pre-filled value in the voucher form',
'settings_max_devices' => 'Maximum Device Count',
'settings_max_devices_hint' => 'Maximum value a user can select',
'settings_pw_current' => 'Current Password',
'settings_pw_new' => 'New Password',
'settings_pw_confirm' => 'Confirm Password',
'settings_pw_changed' => 'Password changed successfully!',
'settings_pw_minlength' => 'At least 8 characters',
// Login / Auth
'login_title' => 'Sign In',
'login_subtitle' => 'Sign in to continue',
'login_email' => 'Email',
'login_password' => 'Password',
'login_btn' => 'Sign In',
'login_ms' => 'Sign in with Microsoft',
'login_local' => 'Sign in with username and password',
'login_back' => '← Back to Code Creation',
'login_forgot' => 'Forgot password?',
'login_error_empty' => 'Please enter email and password',
'login_error_rate' => 'Too many failed attempts. Please wait 10 minutes.',
'login_error_creds' => 'Invalid email or password',
// Password Reset
'reset_title' => 'Forgot Password',
'reset_subtitle' => 'Enter your email we will send you a reset link.',
'reset_email_label' => 'Email Address',
'reset_send_btn' => 'Send Reset Link',
'reset_success' => 'If an account with this email exists, you will receive an email shortly.',
'reset_back_login' => '← Back to Login',
'reset_new_pw' => 'Set New Password',
'reset_new_pw_label'=> 'New Password',
'reset_confirm_label'=> 'Confirm Password',
'reset_set_btn' => 'Set Password',
'reset_invalid' => 'Invalid or expired reset link.',
'reset_done' => 'Your password has been changed successfully.',
// Errors
'error_csrf' => 'Invalid security token',
'error_login_req' => 'You must be logged in',
'error_no_permission'=> 'No permission for this action',
'error_not_found' => 'Not found',
'error_name_req' => 'Please enter a name',
'error_site_req' => 'Please select a location',
'error_devices_range'=> 'Device count must be between 1 and {max}',
'error_email_invalid'=> 'Invalid email address',
'error_site_no_perm'=> 'No permission for this site',
'error_site_not_found'=> 'Site not found',
'error_voucher_invalid'=> 'UniFi did not return a valid voucher',
'error_connection' => 'Connection to UniFi Controller failed',
'error_fill_all' => 'Please fill in all required fields',
// Hello / User
'hello' => 'Hello, {name}',
'minutes_short' => 'min.',
'hours_short' => 'hrs.',
'never' => 'Never',
'unknown' => 'Unknown',
'or' => 'or',
];

267
login.php
View file

@ -1,45 +1,40 @@
<?php <?php
// Error Reporting (kann nach erfolgreicher Einrichtung entfernt werden)
error_reporting(E_ALL); error_reporting(E_ALL);
ini_set('display_errors', 1); ini_set('display_errors', 1);
// Absolute Pfade verwenden
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php'; require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php'; require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/I18n.php';
try { try {
$auth = new Auth(); $auth = new Auth();
if ($auth->isLoggedIn()) { header('Location: index.php'); exit; }
// Wenn bereits eingeloggt, weiterleiten
if ($auth->isLoggedIn()) {
header('Location: index.php');
exit;
}
} catch (Exception $e) { } catch (Exception $e) {
die('Fehler beim Initialisieren: ' . $e->getMessage()); die('Fehler beim Initialisieren: ' . $e->getMessage());
} }
I18n::init();
$error = ''; $error = '';
$success = ''; $success = '';
// Login-Verarbeitung
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try { try {
$email = trim($_POST['email'] ?? ''); $email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? ''; $password = $_POST['password'] ?? '';
if (empty($email) || empty($password)) { if (empty($email) || empty($password)) {
$error = 'Bitte E-Mail und Passwort eingeben'; $error = __('login_error_empty');
} else { } else {
$result = $auth->login($email, $password); $result = $auth->login($email, $password);
if ($result === true) { if ($result === true) {
header('Location: index.php'); header('Location: index.php');
exit; exit;
} elseif ($result === 'rate_limited') { } elseif ($result === 'rate_limited') {
$error = 'Zu viele Fehlversuche. Bitte warten Sie 10 Minuten.'; $error = __('login_error_rate');
} else { } else {
$error = 'Ungültige E-Mail oder Passwort'; $error = __('login_error_creds');
} }
} }
} catch (Exception $e) { } catch (Exception $e) {
@ -52,27 +47,20 @@ try {
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', ''); $logoUrl = $db->getSetting('logo_url', '');
// M365 aktiviert prüfen - ALLE drei Felder müssen ausgefüllt sein
$m365ClientId = $db->getSetting('m365_client_id', ''); $m365ClientId = $db->getSetting('m365_client_id', '');
$m365ClientSecret = $db->getSetting('m365_client_secret', ''); $m365ClientSecret = $db->getSetting('m365_client_secret', '');
$m365TenantId = $db->getSetting('m365_tenant_id', ''); $m365TenantId = $db->getSetting('m365_tenant_id', '');
$m365Enabled = !empty($m365ClientId) && !empty($m365ClientSecret) && !empty($m365TenantId);
$m365Enabled = !empty($m365ClientId) &&
!empty($m365ClientSecret) &&
!empty($m365TenantId);
$publicAccess = $db->getSetting('public_access', 0); $publicAccess = $db->getSetting('public_access', 0);
$smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1';
// M365 OAuth URL generieren falls aktiviert
$m365LoginUrl = ''; $m365LoginUrl = '';
if ($m365Enabled) { if ($m365Enabled) {
// Dynamische Redirect URI basierend auf aktuellem Pfad
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST']; $host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME']); $scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath; $scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php'; $redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
$params = [ $params = [
'client_id' => $m365ClientId, 'client_id' => $m365ClientId,
'response_type' => 'code', 'response_type' => 'code',
@ -81,13 +69,10 @@ try {
'scope' => 'openid profile email User.Read', 'scope' => 'openid profile email User.Read',
'state' => bin2hex(random_bytes(16)) 'state' => bin2hex(random_bytes(16))
]; ];
$_SESSION['m365_state'] = $params['state']; $_SESSION['m365_state'] = $params['state'];
$m365LoginUrl = "https://login.microsoftonline.com/$m365TenantId/oauth2/v2.0/authorize?" . http_build_query($params); $m365LoginUrl = "https://login.microsoftonline.com/$m365TenantId/oauth2/v2.0/authorize?" . http_build_query($params);
} }
// Prüfen ob alternative Login-Form (Benutzername/Passwort) angezeigt werden soll
$showLocalLogin = isset($_GET['local']) && $_GET['local'] === '1'; $showLocalLogin = isset($_GET['local']) && $_GET['local'] === '1';
} catch (Exception $e) { } catch (Exception $e) {
@ -95,174 +80,55 @@ try {
} }
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="<?= I18n::getLanguage() ?>">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - <?= htmlspecialchars($appTitle) ?></title> <title><?= __('login_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; .login-container { background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); max-width: 420px; width: 100%; padding: 50px 40px; text-align: center; }
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); .logo { max-width: 200px; height: auto; margin-bottom: 30px; }
min-height: 100vh; h1 { color: var(--text-primary); font-size: 28px; margin-bottom: 10px; }
display: flex; .subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 30px; }
align-items: center; .form-group { margin-bottom: 20px; text-align: left; }
justify-content: center; label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
padding: 20px; input[type="email"], input[type="password"] { width: 100%; padding: 14px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; background: var(--bg-input); color: var(--text-primary); transition: all 0.2s; }
} input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
.login-container { .btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 10px; }
background: white; .btn:hover { background: var(--accent-hover); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102,126,234,0.4); }
border-radius: 20px; .btn-microsoft { background: #2f2f2f; color: white; border: none; margin-top: 0; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 12px; padding: 16px 24px; border-radius: 10px; width: 100%; font-size: 15px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
box-shadow: 0 20px 60px rgba(0,0,0,0.3); .btn-microsoft:hover { background: #1a1a1a; transform: translateY(-2px); }
max-width: 420px; .btn-microsoft svg { width: 20px; height: 20px; }
width: 100%; .divider { margin: 22px 0; text-align: center; position: relative; }
padding: 50px 40px; .divider::before { content: ''; position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--border-color); }
text-align: center; .divider span { background: var(--bg-card); padding: 0 15px; color: var(--text-muted); font-size: 13px; position: relative; z-index: 1; }
} .alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; }
.logo { .alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
max-width: 200px; .alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
height: auto; .back-link { display: block; margin-top: 20px; color: var(--accent); text-decoration: none; font-size: 14px; }
margin-bottom: 30px; .back-link:hover { text-decoration: underline; }
} .local-login-link { display: block; margin-top: 20px; color: var(--text-muted); text-decoration: none; font-size: 13px; }
h1 { .local-login-link:hover { color: var(--accent); text-decoration: underline; }
color: #333; .forgot-link { display: block; margin-top: 12px; text-align: right; color: var(--text-muted); font-size: 13px; text-decoration: none; }
font-size: 28px; .forgot-link:hover { color: var(--accent); text-decoration: underline; }
margin-bottom: 10px; .header-tools { position: absolute; top: 20px; right: 20px; display: flex; gap: 8px; }
}
.subtitle {
color: #666;
font-size: 14px;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 14px;
}
input[type="email"],
input[type="password"] {
width: 100%;
padding: 14px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 15px;
transition: all 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn {
width: 100%;
padding: 14px;
background: #667eea;
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
margin-top: 10px;
}
.btn:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-microsoft {
background: #2f2f2f;
color: white;
border: none;
margin-top: 0;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px 24px;
}
.btn-microsoft:hover {
background: #1a1a1a;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
text-decoration: none;
}
.btn-microsoft svg {
width: 20px;
height: 20px;
}
.divider {
margin: 25px 0;
text-align: center;
position: relative;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #e0e0e0;
}
.divider span {
background: white;
padding: 0 15px;
color: #999;
font-size: 13px;
position: relative;
z-index: 1;
}
.alert {
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
}
.alert-error {
background: #fee;
border: 1px solid #fcc;
color: #c33;
}
.alert-success {
background: #efe;
border: 1px solid #cfc;
color: #3c3;
}
.back-link {
display: block;
margin-top: 20px;
color: #667eea;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
.local-login-link {
display: block;
margin-top: 25px;
color: #999;
text-decoration: none;
font-size: 13px;
text-align: center;
}
.local-login-link:hover {
color: #667eea;
text-decoration: underline;
}
</style> </style>
</head> </head>
<body> <body>
<div style="position:fixed;top:15px;right:20px;display:flex;gap:8px;z-index:10;">
<div class="lang-switcher">
<?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">🌙</button>
</div>
<div class="login-container"> <div class="login-container">
<?php if ($logoUrl): ?> <?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo"> <img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
@ -270,64 +136,61 @@ try {
<h1><?= htmlspecialchars($appTitle) ?></h1> <h1><?= htmlspecialchars($appTitle) ?></h1>
<?php endif; ?> <?php endif; ?>
<p class="subtitle">Melden Sie sich an, um fortzufahren</p> <p class="subtitle"><?= __('login_subtitle') ?></p>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div> <div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($success): ?> <?php if ($success): ?>
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div> <div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($m365Enabled && !$showLocalLogin): ?> <?php if ($m365Enabled && !$showLocalLogin): ?>
<!-- M365 Login als Hauptoption --> <a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn-microsoft">
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
<path fill="#f35325" d="M1 1h10v10H1z"/> <path fill="#f35325" d="M1 1h10v10H1z"/>
<path fill="#81bc06" d="M12 1h10v10H12z"/> <path fill="#81bc06" d="M12 1h10v10H12z"/>
<path fill="#05a6f0" d="M1 12h10v10H1z"/> <path fill="#05a6f0" d="M1 12h10v10H1z"/>
<path fill="#ffba08" d="M12 12h10v10H12z"/> <path fill="#ffba08" d="M12 12h10v10H12z"/>
</svg> </svg>
Mit Microsoft anmelden <?= __('login_ms') ?>
</a> </a>
<a href="?local=1" class="local-login-link"><?= __('login_local') ?></a>
<a href="?local=1" class="local-login-link">Mit Benutzername und Passwort anmelden</a>
<?php else: ?> <?php else: ?>
<!-- Lokales Login-Formular -->
<form method="post"> <form method="post">
<div class="form-group"> <div class="form-group">
<label for="email">E-Mail</label> <label for="email"><?= __('login_email') ?></label>
<input type="email" id="email" name="email" required autofocus> <input type="email" id="email" name="email" required autofocus>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Passwort</label> <label for="password"><?= __('login_password') ?></label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required>
</div> </div>
<?php if ($smtpEnabled): ?>
<button type="submit" class="btn">Anmelden</button> <a href="forgot_password.php" class="forgot-link"><?= __('login_forgot') ?></a>
<?php endif; ?>
<button type="submit" class="btn"><?= __('login_btn') ?></button>
</form> </form>
<?php if ($m365Enabled): ?> <?php if ($m365Enabled): ?>
<div class="divider"><span>oder</span></div> <div class="divider"><span><?= __('or') ?></span></div>
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft"> <a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn-microsoft">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
<path fill="#f35325" d="M1 1h10v10H1z"/> <path fill="#f35325" d="M1 1h10v10H1z"/>
<path fill="#81bc06" d="M12 1h10v10H12z"/> <path fill="#81bc06" d="M12 1h10v10H12z"/>
<path fill="#05a6f0" d="M1 12h10v10H1z"/> <path fill="#05a6f0" d="M1 12h10v10H1z"/>
<path fill="#ffba08" d="M12 12h10v10H12z"/> <path fill="#ffba08" d="M12 12h10v10H12z"/>
</svg> </svg>
Mit Microsoft anmelden <?= __('login_ms') ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
<?php if ($publicAccess): ?> <?php if ($publicAccess): ?>
<a href="index.php" class="back-link"> Zurück zur Code-Erstellung</a> <a href="index.php" class="back-link"><?= __('login_back') ?></a>
<?php endif; ?> <?php endif; ?>
</div> </div>
<script src="assets/global.js"></script>
</body> </body>
</html> </html>

151
reset_password.php Normal file
View file

@ -0,0 +1,151 @@
<?php
error_reporting(E_ALL);
ini_set('display_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();
if ($auth->isLoggedIn()) { header('Location: index.php'); exit; }
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$token = trim($_GET['token'] ?? '');
$error = '';
$success = '';
$valid = false;
$tokenRow = null;
if (empty($token)) {
$error = __('reset_invalid');
} else {
$tokenRow = $db->fetchOne(
"SELECT prt.*, u.email, u.name FROM password_reset_tokens prt
JOIN users u ON prt.user_id = u.id
WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > NOW()",
[$token]
);
if (!$tokenRow) {
$error = __('reset_invalid');
} else {
$valid = true;
}
}
if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
$newPw = $_POST['new_password'] ?? '';
$confirm= $_POST['confirm_password'] ?? '';
if (strlen($newPw) < 8) {
$error = __('settings_pw_minlength');
$valid = true; // keep form visible
} elseif ($newPw !== $confirm) {
$error = 'Passwörter stimmen nicht überein';
$valid = true;
} else {
$hash = password_hash($newPw, PASSWORD_DEFAULT);
$db->execute("UPDATE users SET password_hash = ? WHERE id = ?", [$hash, $tokenRow['user_id']]);
$db->execute("UPDATE password_reset_tokens SET used = 1 WHERE token = ?", [$token]);
$db->execute(
"INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address) VALUES (?, 'password_reset', 'user', ?, 'Passwort erfolgreich geändert', ?)",
[$tokenRow['user_id'], $tokenRow['user_id'], $_SERVER['REMOTE_ADDR'] ?? '']
);
$success = __('reset_done');
$valid = false;
}
}
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('reset_new_pw') ?> <?= htmlspecialchars($appTitle) ?></title>
<link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.box { background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); max-width: 420px; width: 100%; padding: 45px 40px; text-align: center; }
.logo { max-width: 180px; height: auto; margin-bottom: 25px; }
h1 { color: var(--text-primary); font-size: 24px; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 28px; }
.form-group { margin-bottom: 18px; text-align: left; }
label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
input[type="password"] { width: 100%; padding: 13px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; background: var(--bg-input); color: var(--text-primary); transition: border-color 0.2s; }
input:focus { outline: none; border-color: var(--accent); }
.btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 8px; }
.btn:hover { background: var(--accent-hover); transform: translateY(-2px); }
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: left; }
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.back-link { display: block; margin-top: 22px; color: var(--accent); text-decoration: none; font-size: 14px; }
.back-link:hover { text-decoration: underline; }
.pw-strength { height: 4px; border-radius: 2px; margin-top: 6px; transition: all 0.3s; background: var(--border-color); }
.pw-strength.weak { background: var(--danger); width: 30%; }
.pw-strength.medium { background: var(--warning); width: 65%; }
.pw-strength.strong { background: var(--success); width: 100%; }
</style>
</head>
<body>
<div class="box">
<?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<?php else: ?>
<div style="font-size:28px;font-weight:700;color:var(--text-primary);margin-bottom:10px;"><?= htmlspecialchars($appTitle) ?></div>
<?php endif; ?>
<h1><?= __('reset_new_pw') ?></h1>
<?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; ?>
<?php if ($valid): ?>
<p class="subtitle">Für <strong><?= htmlspecialchars($tokenRow['email']) ?></strong></p>
<form method="post" action="reset_password.php?token=<?= htmlspecialchars($token) ?>">
<div class="form-group">
<label><?= __('reset_new_pw_label') ?></label>
<input type="password" name="new_password" id="pw" required minlength="8" oninput="checkPw(this.value)">
<div class="pw-strength" id="pwBar"></div>
</div>
<div class="form-group">
<label><?= __('reset_confirm_label') ?></label>
<input type="password" name="confirm_password" required>
</div>
<button type="submit" class="btn"><?= __('reset_set_btn') ?></button>
</form>
<?php elseif ($success): ?>
<a href="login.php" class="btn" style="display:block;text-decoration:none;text-align:center;"><?= __('btn_login') ?></a>
<?php endif; ?>
<a href="login.php" class="back-link"><?= __('reset_back_login') ?></a>
</div>
<script>
function checkPw(val) {
const bar = document.getElementById('pwBar');
if (!bar) return;
if (val.length >= 12 && /[A-Z]/.test(val) && /[0-9]/.test(val)) {
bar.className = 'pw-strength strong';
} else if (val.length >= 8) {
bar.className = 'pw-strength medium';
} else if (val.length > 0) {
bar.className = 'pw-strength weak';
} else {
bar.className = 'pw-strength';
}
}
</script>
</body>
</html>