Unifi-Voucher-Tool/reset_password.php
Friederich Loheide 6da46f040a Alle Frontend-Assets lokal ausliefern statt über CDNs
Inter, Font Awesome, Chart.js, qrcodejs und TinyMCE liegen jetzt unter
assets/vendor/ und werden vom eigenen Server ausgeliefert.

Warum:
- Datenschutz: bisher ging bei jedem Seitenaufruf die IP der Nutzer an
  Google Fonts, cdnjs, jsDelivr und Tiny Cloud
- Funktion: UniFi-Installationen stehen oft in abgeschotteten Netzen –
  dort fehlten bisher Schrift, Icons, Diagramme und Editor

Neu: includes/Ui.php
- Ui::head()/Ui::script() binden die Assets ein und hängen einen
  Versionsstempel an (?v=filemtime), damit Browser nach einem Update
  nicht das alte CSS aus dem Cache nehmen
- Ui::themeScript() setzt das Theme aus der gespeicherten Auswahl oder
  – wenn keine vorliegt – aus prefers-color-scheme; global.js folgt
  Systemwechseln live, solange nichts manuell gewählt wurde
- <meta name="color-scheme"> ergänzt, damit Formularelemente passen

Der TinyMCE-API-Key entfällt: der Editor läuft immer lokal (GPL-Variante),
inklusive deutscher Oberfläche, wenn die App auf Deutsch steht.

Neu: tools/demo/build.py – baut aus dem Projekt eine Demo-Instanz ohne
Datenbank (Stubs für Database/Auth, feste Beispieldaten). Damit lassen
sich Screenshots reproduzierbar erzeugen und alle Seiten einmal rendern
(Smoke-Test), ohne eine MySQL-Instanz aufzusetzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:23:44 +00:00

135 lines
4.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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/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', '');
$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>
<?= Ui::head($db) ?>
<style>
.pw-strength { height: 4px; border-radius: var(--r-pill); margin-top: 8px; background: var(--border-color); transition: width .3s, background-color .3s; }
.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 class="app-body focus-page">
<div class="focus-card card">
<?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<?php else: ?>
<div class="focus-icon" style="margin-bottom:14px;"><i class="fas fa-key"></i></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 btn-primary btn-lg btn-block"><?= __('reset_set_btn') ?></button>
</form>
<?php elseif ($success): ?>
<a href="login.php" class="btn btn-primary btn-lg btn-block"><?= __('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>