Files
Rosary/admin/profile.php
T
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

264 lines
12 KiB
PHP

<?php
/**
* admin/profile.php — Current user's account settings.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
require_auth();
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$site_name = get_setting('site_name', APP_NAME);
$messages = [];
$errors = [];
// Superadmin can view/edit another user's profile via ?id=X
$target_id = $uid;
if (has_role('superadmin') && isset($_GET['id'])) {
$target_id = (int)$_GET['id'];
}
// Load target user
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$target_id]);
$profile = $stmt->fetch();
if (!$profile) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
// ── Handle form submissions ───────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$action = $_POST['action'] ?? '';
// ── Update profile ───────────────────────────────────────────────────────
if ($action === 'update_profile') {
$new_display = trim($_POST['display_name'] ?? '');
$pdo->prepare('UPDATE users SET display_name=? WHERE id=?')->execute([$new_display, $target_id]);
// Refresh session if editing own profile
if ($target_id === $uid) {
$_SESSION['display_name'] = $new_display;
}
$messages[] = 'Profile updated.';
$profile['display_name'] = $new_display;
}
// ── Update email ─────────────────────────────────────────────────────────
if ($action === 'update_email') {
$new_email = trim($_POST['new_email'] ?? '');
$cur_pass = $_POST['current_password'] ?? '';
if (!filter_var($new_email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email address.';
} elseif (!password_verify($cur_pass, $profile['password_hash'])) {
$errors[] = 'Current password is incorrect.';
} else {
$chk = $pdo->prepare('SELECT id FROM users WHERE email=? AND id!=?');
$chk->execute([$new_email, $target_id]);
if ($chk->fetch()) {
$errors[] = 'That email is already in use.';
} else {
$pdo->prepare('UPDATE users SET email=? WHERE id=?')->execute([$new_email, $target_id]);
if ($target_id === $uid) $_SESSION['email'] = $new_email;
$messages[] = 'Email updated.';
$profile['email'] = $new_email;
}
}
}
// ── Change password ───────────────────────────────────────────────────────
if ($action === 'change_password') {
$cur_pass = $_POST['current_password'] ?? '';
$new_pass = $_POST['new_password'] ?? '';
$conf_pass = $_POST['confirm_password'] ?? '';
if (!password_verify($cur_pass, $profile['password_hash'])) {
$errors[] = 'Current password is incorrect.';
} elseif (strlen($new_pass) < 8) {
$errors[] = 'New password must be at least 8 characters.';
} elseif ($new_pass !== $conf_pass) {
$errors[] = 'New passwords do not match.';
} else {
$hash = password_hash($new_pass, PASSWORD_BCRYPT);
$pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([$hash, $target_id]);
$messages[] = 'Password changed successfully.';
$profile['password_hash'] = $hash;
}
}
// ── Superadmin: change rosary limit ──────────────────────────────────────
if ($action === 'update_limit' && has_role('superadmin')) {
$new_limit = (int)($_POST['rosary_limit'] ?? 1);
$pdo->prepare('UPDATE users SET rosary_limit=? WHERE id=?')->execute([$new_limit, $target_id]);
if ($target_id === $uid) $_SESSION['rosary_limit'] = $new_limit;
$messages[] = 'Rosary limit updated.';
$profile['rosary_limit'] = $new_limit;
}
}
$is_own = ($target_id === $uid);
$is_super = has_role('superadmin');
$role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superuser','user'=>'User'];
?>
<!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><?= $is_own ? 'My Account' : 'Edit User' ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<style>
.profile-section{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:28px;margin-bottom:24px}
.profile-section h3{margin:0 0 20px;font-size:16px;font-weight:700;color:#1e3a5f;border-bottom:2px solid #e5e7eb;padding-bottom:10px}
.readonly-field{background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:8px 12px;font-size:14px;color:#374151}
</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>
<?php if (has_role('superuser')): ?>
<a href="<?= BASE_URL ?>/admin/builder.php" class="btn btn-ghost">&#x2712; Builder</a>
<?php endif; ?>
<?php if (has_role('admin')): ?>
<a href="<?= BASE_URL ?>/admin/prayers.php" class="btn btn-ghost">Prayers</a>
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
<?php endif; ?>
<?php if ($is_super): ?>
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</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; ?>
<h2 style="margin-bottom:24px"><?= $is_own ? 'My Account' : 'Edit: ' . htmlspecialchars($profile['username']) ?></h2>
<!-- Account Info -->
<div class="profile-section">
<h3>Account Info</h3>
<div class="form-group">
<label>Username</label>
<div class="readonly-field"><?= htmlspecialchars($profile['username']) ?></div>
</div>
<div class="form-group">
<label>Role</label>
<div class="readonly-field"><?= htmlspecialchars($role_labels[$profile['role']] ?? $profile['role']) ?></div>
</div>
<div class="form-group">
<label>Rosary Limit</label>
<div class="readonly-field">
<?= $profile['rosary_limit'] < 0 ? 'Unlimited' : (int)$profile['rosary_limit'] ?>
<?php if ($is_super && $target_id !== $uid): ?>
&nbsp;<a href="#limit-section" style="font-size:12px">(edit below)</a>
<?php endif; ?>
</div>
</div>
<div class="form-group">
<label>Member Since</label>
<div class="readonly-field"><?= date('F j, Y', strtotime($profile['created_at'])) ?></div>
</div>
</div>
<!-- Display Name -->
<div class="profile-section">
<h3>Display Name</h3>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_profile">
<div class="form-group">
<label for="display_name">Display Name</label>
<input type="text" id="display_name" name="display_name"
maxlength="100"
value="<?= htmlspecialchars($profile['display_name'] ?? '') ?>">
<p class="help-text">Shown publicly on your profile and rosary cards.</p>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
<!-- Email -->
<div class="profile-section">
<h3>Email Address</h3>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_email">
<div class="form-group">
<label>Current Email</label>
<div class="readonly-field"><?= htmlspecialchars($profile['email']) ?></div>
</div>
<div class="form-group">
<label for="new_email">New Email</label>
<input type="email" id="new_email" name="new_email" required>
</div>
<div class="form-group">
<label for="cur_pass_email">Current Password (to confirm)</label>
<input type="password" id="cur_pass_email" name="current_password" required>
</div>
<button type="submit" class="btn btn-primary">Update Email</button>
</form>
</div>
<!-- Change Password -->
<div class="profile-section">
<h3>Change Password</h3>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="change_password">
<div class="form-group">
<label for="cur_pass">Current Password</label>
<input type="password" id="cur_pass" name="current_password" required>
</div>
<div class="form-group">
<label for="new_pass">New Password</label>
<input type="password" id="new_pass" name="new_password" minlength="8" required>
<p class="help-text">At least 8 characters.</p>
</div>
<div class="form-group">
<label for="conf_pass">Confirm New Password</label>
<input type="password" id="conf_pass" name="confirm_password" minlength="8" required>
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
</form>
</div>
<!-- Rosary Limit (superadmin editing another user) -->
<?php if ($is_super && !$is_own): ?>
<div class="profile-section" id="limit-section">
<h3>Rosary Limit</h3>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_limit">
<div class="form-group">
<label for="rosary_limit">Limit (-1 = unlimited)</label>
<input type="number" id="rosary_limit" name="rosary_limit"
min="-1" value="<?= (int)$profile['rosary_limit'] ?>">
<p class="help-text">How many rosary sessions this user can create.</p>
</div>
<button type="submit" class="btn btn-primary">Save Limit</button>
</form>
</div>
<?php endif; ?>
</main>
</div>
</body>
</html>