Files
Critters/reset-password.php
T
pauljd c9d1a42751
pauljd/davidson-ci: Davidson CI / quality (push) Successful in 14s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 1m5s
Davidson CI testing & AI refinement
2026-09-11 09:35:31 +01:00

140 lines
6.9 KiB
PHP

<?php
require __DIR__ . '/session.php';
require __DIR__ . '/db.php';
require __DIR__ . '/lib/ticket.php';
start_app_session();
if (isset($_SESSION['username'])) {
header('Location: /');
exit;
}
$token = $_GET['token'] ?? ($_POST['token'] ?? '');
$tokenIsWellFormed = is_string($token) && $token !== '' && isPasswordResetTicketWellFormed($token);
$errors = [];
$success = false;
if ($tokenIsWellFormed && $_SERVER['REQUEST_METHOD'] === 'POST') {
$password = $_POST['password'] ?? '';
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
} elseif ($password === '') {
$errors['password'] = 'Password is required.';
} elseif (strlen($password) < 8) {
$errors['password'] = 'Password must be at least 8 characters long.';
} elseif (strlen($password) > 72) {
$errors['password'] = 'Password cannot exceed 72 characters.';
}
if (empty($errors)) {
$pdo = getDbConnection();
// Consuming the ticket and updating the password happen in one transaction so a
// failure between the two can't strand the user: either both take effect, or the
// ticket stays unconsumed and the (still valid) reset link can be used again.
$pdo->beginTransaction();
try {
$userId = consumePasswordResetTicket($pdo, $token);
if ($userId === null) {
$pdo->rollBack();
$errors['password'] = 'This reset link has already been used or has expired. Please request a new one.';
} else {
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
// Bumping session_version invalidates every existing session for this user
// (including a stolen one) the next time enforce_session_version() runs.
$stmt = $pdo->prepare('UPDATE users SET password = :password, session_version = session_version + 1 WHERE id = :id');
$stmt->execute(['password' => $hashedPassword, 'id' => $userId]);
$pdo->commit();
header('Location: login.php?reset=success');
exit;
}
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
}
$pageTitle = 'Reset Password';
?>
<!DOCTYPE html>
<html lang="en">
<?php require __DIR__ . '/lib/head.php'; ?>
<body class="bg-amber-50 min-h-screen flex flex-col">
<?php require __DIR__ . '/lib/header.php'; ?>
<div class="flex-1 flex items-center justify-center bg-gradient-to-b from-amber-50 via-pink-50 to-white p-4 py-12">
<div class="w-full max-w-md bg-white rounded-3xl shadow-xl ring-1 ring-gray-100 p-8 sm:p-10">
<?php if (!$tokenIsWellFormed): ?>
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-red-50 text-red-500 mx-auto mb-4">
<i data-lucide="shield-alert" class="h-7 w-7"></i>
</div>
<h1 class="text-2xl font-extrabold text-gray-900 text-center">Link expired</h1>
<p class="mt-2 text-gray-500 text-sm text-center">
This password reset link is invalid or has expired. Request a new one below.
</p>
<a href="forgot-password.php" class="mt-6 w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gray-900 text-white font-semibold py-3 hover:bg-gray-800">
Request New Link <i data-lucide="arrow-right" class="h-4 w-4"></i>
</a>
<?php else: ?>
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-purple-50 text-purple-500 mx-auto mb-4">
<i data-lucide="key-round" class="h-7 w-7"></i>
</div>
<h1 class="text-2xl font-extrabold text-gray-900 text-center">Choose a new password</h1>
<p class="mt-2 text-gray-500 text-sm text-center">Make it something you'll remember.</p>
<?php if (isset($errors['csrf'])): ?>
<div class="mt-4 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
<?php echo htmlspecialchars($errors['csrf'], ENT_QUOTES, 'UTF-8'); ?>
</div>
<?php endif; ?>
<form action="reset-password.php" method="post" novalidate class="mt-6 space-y-4">
<?php echo csrf_field(); ?>
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
<div class="relative">
<i data-lucide="lock" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
<input
type="password" id="password" name="password"
placeholder="At least 8 characters"
class="w-full rounded-xl border border-gray-300 pl-10 pr-10 py-2.5 focus:outline-none focus:ring-2 focus:ring-purple-400"
>
<button
type="button"
onclick="togglePasswordVisibility('password', this)"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Show password"
>
<i data-lucide="eye" class="h-4 w-4"></i>
<i data-lucide="eye-off" class="h-4 w-4 hidden"></i>
</button>
</div>
<?php if (isset($errors['password'])): ?>
<p class="mt-1 text-sm text-red-600"><?php echo htmlspecialchars($errors['password'], ENT_QUOTES, 'UTF-8'); ?></p>
<?php endif; ?>
</div>
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-pink-500 text-white font-semibold py-3 shadow-sm hover:from-purple-700 hover:to-pink-600">
Reset Password <i data-lucide="arrow-right" class="h-4 w-4"></i>
</button>
</form>
<?php endif; ?>
</div>
</div>
<script>
function togglePasswordVisibility(inputId, button) {
const input = document.getElementById(inputId);
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
button.querySelectorAll('[data-lucide]').forEach((icon) => icon.classList.toggle('hidden'));
}
</script>
<?php require __DIR__ . '/lib/footer.php'; ?>
</body>
</html>