- Kontrast: gedämpfter Text war mit 3,1:1 unter WCAG AA, jetzt 4,9:1 (hell) bzw. 6,4:1 (dunkel) - Sprungmarke „Zum Inhalt springen" in Admin-Shell und Voucher-Seite - aria-label für alle reinen Icon-Schaltflächen (Theme, Menü, Abmelden, Zeilenaktionen), aria-current auf dem aktiven Navigationspunkt, role="group" für den Sprachumschalter - 194 dekorative Icons mit aria-hidden versehen, damit Screenreader sie nicht vorlesen - Toast-Container als aria-live-Bereich ausgezeichnet - prefers-reduced-motion schaltet Animationen und Übergänge ab Tabellen (Benutzer, Vouchers, Profile, Audit-Log, Dashboard, API-Keys) werden unter 720 px zu Karten: die Spaltenüberschrift steht per data-label vor dem Wert, statt horizontal zu scrollen. Datums- und Zeitformat in der Voucher-Liste folgen jetzt der gewählten Sprache statt fest de-DE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
4.7 KiB
PHP
121 lines
4.7 KiB
PHP
<?php
|
||
error_reporting(E_ALL);
|
||
ini_set('display_errors', 0);
|
||
ini_set('log_errors', 1);
|
||
|
||
require_once __DIR__ . '/config.php';
|
||
require_once __DIR__ . '/includes/Database.php';
|
||
require_once __DIR__ . '/includes/Auth.php';
|
||
require_once __DIR__ . '/includes/Mailer.php';
|
||
require_once __DIR__ . '/includes/Ui.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'] ?? '');
|
||
|
||
// Einfacher Throttle: max. 3 Anfragen pro 15 Minuten je Session (gegen Spam)
|
||
$now = time();
|
||
$rl = array_values(array_filter($_SESSION['pwreset_times'] ?? [], fn($t) => ($now - $t) < 900));
|
||
if (count($rl) >= 3) {
|
||
$error = 'Zu viele Anfragen. Bitte warten Sie einige Minuten.';
|
||
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
$error = __('error_email_invalid');
|
||
} else {
|
||
$rl[] = $now;
|
||
$_SESSION['pwreset_times'] = $rl;
|
||
$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>
|
||
<?= Ui::head($db) ?>
|
||
</head>
|
||
<body class="app-body focus-page">
|
||
<div class="focus-card card">
|
||
<?php if ($logoUrl): ?>
|
||
<img src="<?= htmlspecialchars(Ui::mediaUrl($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 btn-primary btn-lg btn-block"><?= __('reset_send_btn') ?></button>
|
||
</form>
|
||
<?php endif; ?>
|
||
|
||
<div class="auth-links"><a href="login.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('reset_back_login') ?></a></div>
|
||
</div>
|
||
<script src="assets/global.js"></script>
|
||
</body>
|
||
</html>
|