Files
pguzman 9622f9aeca Consolidate schema, add CSRF protection, and add login rate-limiting
Six fixes from a codebase review:

- Consolidate the ad hoc install.php + migrate_v2..v6.php chain into one
  canonical schema.sql (structure reference) plus a simplified install.php
  that creates all tables and seeds site_settings, the superadmin account,
  and the standard prayer library. The six migrate_v*.php scripts are
  deleted — their cumulative effect is now fully captured in schema.sql.

- Delete the two root-level setup.php/novena_group.php files that existed
  only to redirect to their admin/ equivalents of the same name; confirmed
  unreferenced by any link or .htaccess rule.

- Decouple includes/build_slides.php from data/prayers.php's implicit
  `global $opening, $mysteries, ...` contract. data/prayers.php now
  explicitly returns its arrays; build_slides.php loads them through a
  small memoized get_prayer_data() and destructures them by key.

- Add CSRF protection (includes/csrf.php: csrf_token/csrf_field/csrf_verify)
  across every POST-handling endpoint — 10 form pages and 7 API endpoints —
  plus token wiring in the JS/inline scripts that call the FormData- and
  JSON-body API endpoints (builder.js, setup.js, and the inline scripts in
  admin/audio.php, admin/novena_group.php, and index.php).

- Stop round-tripping the SMTP password in plaintext through the settings
  form: the field now renders blank with a "currently set" hint, and a
  blank submission leaves the stored password unchanged instead of
  clearing it.

- Add login rate-limiting: users.failed_login_attempts / locked_until
  columns, is_locked_out()/record_login_failure()/record_login_success()
  helpers in includes/auth.php, and lockout handling in login.php (5
  failed attempts locks the account for 15 minutes). README documents the
  one manual ALTER TABLE needed to add these columns to an existing
  production database.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:34:10 -07:00

435 lines
22 KiB
PHP

<?php
/**
* admin/users.php — User management. Requires admin role.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/mailer.php';
require_once __DIR__ . '/../includes/csrf.php';
require_role('admin');
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$is_super = has_role('superadmin');
$site_name = get_setting('site_name', APP_NAME);
$messages = [];
$errors = [];
// ── Handle actions ───────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$action = $_POST['action'] ?? '';
// ── Create user ──────────────────────────────────────────────────────────
if ($action === 'create_user') {
$new_username = trim($_POST['new_username'] ?? '');
$new_display_name = trim($_POST['new_display_name'] ?? '');
$new_email = trim($_POST['new_email'] ?? '');
$new_password = $_POST['new_password'] ?? '';
$new_role = $_POST['new_role'] ?? 'user';
$new_limit = (int)($_POST['new_rosary_limit'] ?? 1);
// Validate role — admins can't set superadmin
$allowed_roles = $is_super ? ['user','superuser','admin','superadmin'] : ['user','superuser'];
if (!in_array($new_role, $allowed_roles, true)) $new_role = 'user';
if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $new_username)) {
$errors[] = 'Invalid username.';
} elseif (!filter_var($new_email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email.';
} elseif (strlen($new_password) < 8) {
$errors[] = 'Password must be at least 8 characters.';
} else {
$chk = $pdo->prepare('SELECT id FROM users WHERE username=? OR email=?');
$chk->execute([$new_username, $new_email]);
if ($chk->fetch()) {
$errors[] = 'Username or email already in use.';
} else {
$hash = password_hash($new_password, PASSWORD_BCRYPT);
$pdo->prepare("
INSERT INTO users (username,email,password_hash,display_name,role,rosary_limit,email_confirmed)
VALUES (?,?,?,?,?,?,1)
")->execute([$new_username, $new_email, $hash, $new_display_name ?: $new_username, $new_role, $new_limit]);
$messages[] = "User '{$new_username}' created.";
}
}
}
// ── Update user ──────────────────────────────────────────────────────────
if ($action === 'update_user') {
$target_id = (int)($_POST['target_id'] ?? 0);
$upd_display = trim($_POST['upd_display_name'] ?? '');
$upd_email = trim($_POST['upd_email'] ?? '');
$upd_role = $_POST['upd_role'] ?? '';
$upd_limit = (int)($_POST['upd_rosary_limit'] ?? 1);
// Fetch target to check if superadmin
$tgt = $pdo->prepare('SELECT * FROM users WHERE id=?');
$tgt->execute([$target_id]);
$tgt = $tgt->fetch();
if (!$tgt) {
$errors[] = 'User not found.';
} else {
// Role protection
$allowed_roles = $is_super ? ['user','superuser','admin','superadmin'] : ['user','superuser'];
if (!in_array($upd_role, $allowed_roles, true)) $upd_role = $tgt['role'];
// Non-superadmin cannot change superadmin's role
if (!$is_super && $tgt['role'] === 'superadmin') {
$errors[] = 'Cannot modify a superadmin account.';
} else {
if (!filter_var($upd_email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email.';
} else {
$chk = $pdo->prepare('SELECT id FROM users WHERE email=? AND id!=?');
$chk->execute([$upd_email, $target_id]);
if ($chk->fetch()) {
$errors[] = 'Email already in use by another account.';
} else {
$pdo->prepare('UPDATE users SET display_name=?,email=?,role=?,rosary_limit=? WHERE id=?')
->execute([$upd_display, $upd_email, $upd_role, $upd_limit, $target_id]);
$messages[] = "User '{$tgt['username']}' updated.";
}
}
}
}
}
// ── Reset password ───────────────────────────────────────────────────────
if ($action === 'reset_password') {
$target_id = (int)($_POST['target_id'] ?? 0);
$new_pass = $_POST['new_pass'] ?? '';
if (strlen($new_pass) < 8) {
$errors[] = 'New password must be at least 8 characters.';
} else {
$hash = password_hash($new_pass, PASSWORD_BCRYPT);
$pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([$hash, $target_id]);
$messages[] = 'Password reset successfully.';
}
}
// ── Resend confirmation email ─────────────────────────────────────────────
if ($action === 'resend_confirmation') {
$target_id = (int)($_POST['target_id'] ?? 0);
$tgt = $pdo->prepare('SELECT * FROM users WHERE id = ? AND email_confirmed = 0');
$tgt->execute([$target_id]);
$tgt = $tgt->fetch();
if (!$tgt) {
$errors[] = 'User not found or already confirmed.';
} else {
$smtp_host = get_setting('smtp_host');
if ($smtp_host === '') {
// No SMTP — confirm directly
$pdo->prepare('UPDATE users SET email_confirmed = 1, confirm_token = NULL WHERE id = ?')
->execute([$target_id]);
$messages[] = "No SMTP configured — {$tgt['username']} has been confirmed directly.";
} else {
$token = bin2hex(random_bytes(32));
$site_url = rtrim(get_setting('site_url'), '/');
$site_name = get_setting('site_name', APP_NAME);
$link = $site_url . '/confirm?token=' . urlencode($token);
$disp = $tgt['display_name'] ?: $tgt['username'];
$pdo->prepare('UPDATE users SET confirm_token = ? WHERE id = ?')
->execute([$token, $target_id]);
$body_html = "
<h2 style='margin-top:0;color:#1e3a5f'>Confirm your email</h2>
<p>Hello, <strong>" . htmlspecialchars($disp) . "</strong>!</p>
<p>An administrator has resent your confirmation email for {$site_name}. Click the button below to confirm your email address and activate your account:</p>
<p style='text-align:center;margin:28px 0'>
<a href='" . htmlspecialchars($link) . "' style='display:inline-block;background:#1e3a5f;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600'>Confirm Email</a>
</p>
<p style='color:#6b7280;font-size:13px'>Or copy this link: " . htmlspecialchars($link) . "</p>
<p style='color:#6b7280;font-size:13px'>If you did not register, ignore this email.</p>
";
$html = email_template('Confirm your email — ' . $site_name, $body_html);
$ok = send_email($tgt['email'], $disp, 'Confirm your email — ' . $site_name, $html);
if ($ok) {
$messages[] = "Confirmation email resent to {$tgt['email']}.";
} else {
$errors[] = 'Failed to send email. Check your SMTP settings.';
}
}
}
}
// ── Delete user ──────────────────────────────────────────────────────────
if ($action === 'delete_user') {
$target_id = (int)($_POST['target_id'] ?? 0);
if ($target_id === $uid) {
$errors[] = 'You cannot delete your own account.';
} else {
$tgt = $pdo->prepare('SELECT role,username FROM users WHERE id=?');
$tgt->execute([$target_id]);
$tgt = $tgt->fetch();
if ($tgt && $tgt['role'] === 'superadmin' && !$is_super) {
$errors[] = 'Cannot delete a superadmin account.';
} elseif ($tgt) {
$pdo->prepare('DELETE FROM users WHERE id=?')->execute([$target_id]);
$messages[] = "User '{$tgt['username']}' deleted.";
}
}
}
}
// ── Load all users with rosary counts ────────────────────────────────────────
$users = $pdo->query("
SELECT u.*,
(SELECT COUNT(*) FROM sessions WHERE user_id=u.id AND occasion != 'novena_deceased') +
(SELECT COUNT(*) FROM novena_groups WHERE user_id=u.id)
AS rosary_count
FROM users u
ORDER BY u.created_at DESC
")->fetchAll();
$role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superuser','user'=>'User'];
$role_colors = [
'superadmin' => '#dc2626',
'admin' => '#ea580c',
'superuser' => '#2563eb',
'user' => '#6b7280',
];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title>Users — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<style>
.role-badge{display:inline-block;padding:2px 10px;border-radius:99px;font-size:12px;font-weight:700;color:#fff}
.edit-panel{display:none;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:20px;margin-top:12px}
.edit-panel.open{display:block}
.user-row td{vertical-align:top;padding:12px 10px}
.mini-form{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end}
.mini-form .form-group{margin:0;min-width:160px}
.mini-form label{font-size:12px;font-weight:600;display:block;margin-bottom:4px;color:#374151}
.mini-form input,.mini-form select{font-size:14px;padding:6px 10px;border:1px solid #d1d5db;border-radius:6px;width:100%}
.create-panel{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:24px;margin-bottom:28px;display:none}
.create-panel.open{display:block}
</style>
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<h1>&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">&#x2190; View Site</a>
<a href="<?= BASE_URL ?>/admin/builder.php" class="btn btn-ghost">&#x2712; Builder</a>
<a href="<?= BASE_URL ?>/admin/prayers.php" class="btn btn-ghost">Prayers</a>
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</a>
<?php if ($is_super): ?>
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
</div>
</header>
<main>
<?php foreach ($messages as $m): ?>
<div class="alert alert-success">&#x2713; <?= htmlspecialchars($m) ?></div>
<?php endforeach; ?>
<?php foreach ($errors as $e): ?>
<div class="alert alert-error"><?= htmlspecialchars($e) ?></div>
<?php endforeach; ?>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<h2 style="margin:0">Users (<?= count($users) ?>)</h2>
<button class="btn btn-primary" onclick="toggleCreate()">+ Create User</button>
</div>
<!-- Create User Panel -->
<div class="create-panel" id="create-panel">
<h3 style="margin:0 0 16px">Create New User</h3>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="create_user">
<div class="mini-form">
<div class="form-group">
<label>Username *</label>
<input type="text" name="new_username" pattern="[a-zA-Z0-9_]{3,30}" required>
</div>
<div class="form-group">
<label>Display Name</label>
<input type="text" name="new_display_name" maxlength="100">
</div>
<div class="form-group">
<label>Email *</label>
<input type="email" name="new_email" required>
</div>
<div class="form-group">
<label>Password * (min 8)</label>
<input type="password" name="new_password" minlength="8" required>
</div>
<div class="form-group">
<label>Role</label>
<select name="new_role">
<option value="user">User</option>
<option value="superuser">Superuser</option>
<option value="admin">Admin</option>
<?php if ($is_super): ?>
<option value="superadmin">Superadmin</option>
<?php endif; ?>
</select>
</div>
<div class="form-group">
<label>Rosary Limit (-1 = unlimited)</label>
<input type="number" name="new_rosary_limit" value="1" min="-1">
</div>
<div>
<button type="submit" class="btn btn-primary">Create</button>
<button type="button" class="btn btn-ghost" onclick="toggleCreate()">Cancel</button>
</div>
</div>
</form>
</div>
<!-- Users Table -->
<div class="sessions-table-wrap">
<table class="sessions-table">
<thead>
<tr>
<th>Username</th>
<th>Display Name</th>
<th>Email</th>
<th>Role</th>
<th>Limit</th>
<th>Rosaries</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): ?>
<tr class="user-row">
<td>
<strong><?= htmlspecialchars($u['username']) ?></strong>
<?= !$u['email_confirmed'] ? '<br><span style="font-size:11px;color:#d97706">Unconfirmed</span>' : '' ?>
</td>
<td><?= htmlspecialchars($u['display_name'] ?? '') ?></td>
<td style="font-size:13px"><?= htmlspecialchars($u['email']) ?></td>
<td>
<span class="role-badge" style="background:<?= $role_colors[$u['role']] ?? '#6b7280' ?>">
<?= htmlspecialchars($role_labels[$u['role']] ?? $u['role']) ?>
</span>
</td>
<td><?= $u['rosary_limit'] < 0 ? '&#x221e;' : (int)$u['rosary_limit'] ?></td>
<td><?= (int)$u['rosary_count'] ?></td>
<td style="font-size:12px"><?= date('M j, Y', strtotime($u['created_at'])) ?></td>
<td class="actions">
<?php $can_edit = $is_super || ($u['role'] !== 'superadmin' && $u['role'] !== 'admin'); ?>
<?php if ($can_edit || (int)$u['id'] !== $uid): ?>
<button class="btn btn-sm btn-secondary"
onclick="toggleEdit(<?= $u['id'] ?>)">Edit</button>
<?php endif; ?>
<?php if (!$u['email_confirmed']): ?>
<form method="post" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="action" value="resend_confirmation">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<button type="submit" class="btn btn-sm"
style="background:#d97706;color:#fff;border:none"
title="Resend confirmation email to <?= htmlspecialchars($u['email']) ?>">
Resend Email
</button>
</form>
<?php endif; ?>
<?php if ((int)$u['id'] !== $uid && ($is_super || $u['role'] !== 'superadmin')): ?>
<form method="post" style="display:inline"
onsubmit="return confirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>? Their sessions will remain.')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<?php endif; ?>
<!-- Edit Panel -->
<div class="edit-panel" id="edit-<?= $u['id'] ?>">
<form method="post" style="margin-bottom:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_user">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<div class="mini-form">
<div class="form-group">
<label>Display Name</label>
<input type="text" name="upd_display_name" maxlength="100"
value="<?= htmlspecialchars($u['display_name'] ?? '') ?>">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="upd_email" required
value="<?= htmlspecialchars($u['email']) ?>">
</div>
<div class="form-group">
<label>Role</label>
<select name="upd_role">
<option value="user" <?= $u['role']==='user' ?'selected':'' ?>>User</option>
<option value="superuser" <?= $u['role']==='superuser' ?'selected':'' ?>>Superuser</option>
<option value="admin" <?= $u['role']==='admin' ?'selected':'' ?>>Admin</option>
<?php if ($is_super): ?>
<option value="superadmin" <?= $u['role']==='superadmin'?'selected':'' ?>>Superadmin</option>
<?php endif; ?>
</select>
</div>
<div class="form-group">
<label>Rosary Limit (-1=unlimited)</label>
<input type="number" name="upd_rosary_limit" min="-1"
value="<?= (int)$u['rosary_limit'] ?>">
</div>
<div>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div>
</div>
</form>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="reset_password">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<div class="mini-form">
<div class="form-group">
<label>New Password (min 8)</label>
<input type="password" name="new_pass" minlength="8" required>
</div>
<div>
<button type="submit" class="btn btn-secondary btn-sm"
onclick="return confirm('Reset this user\'s password?')">
Reset Password
</button>
</div>
</div>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</main>
</div>
<script>
function toggleCreate() {
var p = document.getElementById('create-panel');
p.classList.toggle('open');
}
function toggleEdit(id) {
var p = document.getElementById('edit-' + id);
p.classList.toggle('open');
}
</script>
</body>
</html>