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

View file

@ -38,6 +38,7 @@ class Auth {
$this->clearLoginAttempts($ip, $email);
$this->setUserSession($user);
$this->updateLastLogin($user['id']);
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich');
return true;
}
@ -45,6 +46,17 @@ class Auth {
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) {
try {
$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'));
}
public function sendRaw($to, $subject, $plainBody) {
return $this->send($to, $subject, $plainBody, false);
}
public function send($to, $subject, $body, $isHtml = false) {
if (!$this->smtpEnabled || empty($this->smtpHost)) {
// 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>