From e68b24e1c7f0a3a356ee1b6f56194f665aa4eb24 Mon Sep 17 00:00:00 2001 From: David Dyer Date: Sun, 30 Aug 2026 22:30:47 -0400 Subject: [PATCH] Add user management to admin dashboard and refine button specificity - Implement admin user management interface (, , ) with async CRUD and password updating - Add Users tab link to admin navigation across and - Use in to lower specificity for native button resets, allowing modifier classes like to take precedence --- admin/index.php | 1 + admin/menu.php | 1 + admin/users.js | 175 +++++++++++++++++++++++++++ admin/users.php | 316 ++++++++++++++++++++++++++++++++++++++++++++++++ inc/users.php | 72 +++++++++++ styles.css | 5 +- 6 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 admin/users.js create mode 100644 admin/users.php create mode 100644 inc/users.php diff --git a/admin/index.php b/admin/index.php index b936138..9c81d18 100644 --- a/admin/index.php +++ b/admin/index.php @@ -79,6 +79,7 @@ $today = date('Y-m-d');
Logged in as diff --git a/admin/menu.php b/admin/menu.php index 2893cb7..ec9ddbc 100644 --- a/admin/menu.php +++ b/admin/menu.php @@ -165,6 +165,7 @@ $menuGrouped = get_full_menu(false);
Logged in as diff --git a/admin/users.js b/admin/users.js new file mode 100644 index 0000000..5d731db --- /dev/null +++ b/admin/users.js @@ -0,0 +1,175 @@ +// User Management admin page: create users, change passwords, delete users. +// CSRF token and the current user's id are handed off from PHP via +// . + +const csrfToken = document.body.dataset.csrfToken; +const currentUserId = document.body.dataset.currentUserId; + +const userModal = document.getElementById('modal-user-backdrop'); +const passwordModal = document.getElementById('modal-password-backdrop'); + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text ?? ''; + return div.innerHTML; +} + +async function postForm(formData) { + const response = await fetch('users.php', { + method: 'POST', + body: formData, + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + return response.json(); +} + +// Add User modal +function openUserModal() { + document.getElementById('form-user').reset(); + userModal.classList.add('is-open'); + document.getElementById('user-field-username').focus(); +} + +function closeUserModal() { + userModal.classList.remove('is-open'); +} + +// Change Password modal +function openPasswordModal(userId, username) { + const isSelf = String(userId) === currentUserId; + + document.getElementById('form-password').reset(); + document.getElementById('password-field-user-id').value = userId; + document.getElementById('modal-password-title').textContent = isSelf + ? '🔑 Change Your Password' + : `🔑 Change Password for ${username}`; + + document.getElementById('password-current-group').style.display = isSelf ? '' : 'none'; + + passwordModal.classList.add('is-open'); + (isSelf ? document.getElementById('password-field-current') : document.getElementById('password-field-new')).focus(); +} + +function closePasswordModal() { + passwordModal.classList.remove('is-open'); +} + +document.getElementById('btn-new-user').addEventListener('click', () => openUserModal()); +document.getElementById('btn-close-user-modal').addEventListener('click', closeUserModal); +document.getElementById('btn-cancel-user').addEventListener('click', closeUserModal); +document.getElementById('btn-close-password-modal').addEventListener('click', closePasswordModal); +document.getElementById('btn-cancel-password').addEventListener('click', closePasswordModal); + +userModal.addEventListener('click', (e) => { if (e.target === userModal) closeUserModal(); }); +passwordModal.addEventListener('click', (e) => { if (e.target === passwordModal) closePasswordModal(); }); + +window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeUserModal(); + closePasswordModal(); + } +}); + +// Create user +document.getElementById('form-user').addEventListener('submit', async (e) => { + e.preventDefault(); + const saveBtn = document.getElementById('btn-save-user'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + const res = await postForm(new FormData(e.target)); + if (res.success) { + closeUserModal(); + insertUserRow(res.user); + } else { + alert(res.error || 'Failed to create user.'); + } + } catch { + alert('Network error. Please try again.'); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Create User'; + } +}); + +// Change password +document.getElementById('form-password').addEventListener('submit', async (e) => { + e.preventDefault(); + const saveBtn = document.getElementById('btn-save-password'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + const res = await postForm(new FormData(e.target)); + if (res.success) { + closePasswordModal(); + alert('Password updated.'); + } else { + alert(res.error || 'Failed to update password.'); + } + } catch { + alert('Network error. Please try again.'); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Update Password'; + } +}); + +// Delegate table actions: Change Password, Delete +document.getElementById('users-tbody').addEventListener('click', async (e) => { + const { target } = e; + + if (target.classList.contains('btn-change-password')) { + openPasswordModal(target.dataset.id, target.dataset.username); + return; + } + + if (target.classList.contains('btn-delete-user')) { + if (!confirm('Are you sure you want to delete this user? This cannot be undone.')) return; + const row = target.closest('tr'); + + const fd = new FormData(); + fd.append('action', 'delete_user'); + fd.append('user_id', target.dataset.id); + fd.append('csrf_token', csrfToken); + + try { + const data = await postForm(fd); + if (data.success && row) { + row.style.transition = 'opacity 0.2s ease, transform 0.2s ease'; + row.style.opacity = '0'; + row.style.transform = 'translateX(-10px)'; + setTimeout(() => row.remove(), 200); + } else if (!data.success) { + alert(data.error || 'Failed to delete user.'); + } + } catch { + alert('Network error. Please try again.'); + } + } +}); + +function insertUserRow(user) { + const tbody = document.getElementById('users-tbody'); + + const tr = document.createElement('tr'); + tr.id = `user-row-${user.id}`; + tr.dataset.userId = user.id; + + // data-username is set via dataset below, never interpolated into the + // HTML string — escapeHtml() only escapes for text-content positions, + // not attribute values, so it can't safely build an attribute string. + tr.innerHTML = ` + ${escapeHtml(user.username)} + ${escapeHtml(user.role.charAt(0).toUpperCase() + user.role.slice(1))} + ${escapeHtml(user.created_at)} +
+ + +
+ `; + tr.querySelector('.btn-change-password').dataset.username = user.username; + + tbody.appendChild(tr); +} diff --git a/admin/users.php b/admin/users.php new file mode 100644 index 0000000..020f56a --- /dev/null +++ b/admin/users.php @@ -0,0 +1,316 @@ + 32) { + return 'Username must be between 3 and 32 characters.'; + } + if (!preg_match('/^[a-zA-Z0-9_.-]+$/', $username)) { + return 'Username can only contain letters, numbers, and . _ -'; + } + return null; +} + +function handle_create_user(array $post, array $currentUser): array { + $username = post_str($post, 'username'); + $password = post_str($post, 'password'); + $confirm = post_str($post, 'confirm_password'); + + if ($error = validate_username($username)) { + return ['success' => false, 'error' => $error]; + } + if (username_exists($username)) { + return ['success' => false, 'error' => 'That username is already taken.']; + } + if (strlen($password) < MIN_PASSWORD_LENGTH) { + return ['success' => false, 'error' => 'Password must be at least ' . MIN_PASSWORD_LENGTH . ' characters.']; + } + if ($password !== $confirm) { + return ['success' => false, 'error' => 'Passwords do not match.']; + } + + $newId = create_user($username, $password, post_role($post)); + return ['success' => true, 'user' => get_user_by_id($newId)]; +} + +function handle_change_password(array $post, array $currentUser): array { + $targetId = (int) ($post['user_id'] ?? 0); + $target = $targetId > 0 ? get_user_by_id($targetId) : null; + if (!$target) { + return ['success' => false, 'error' => 'User not found.']; + } + + $newPassword = post_str($post, 'new_password'); + $confirm = post_str($post, 'confirm_password'); + $isSelf = $targetId === (int) $currentUser['id']; + + if ($isSelf) { + $currentPassword = post_str($post, 'current_password'); + if (!verify_user_password($targetId, $currentPassword)) { + return ['success' => false, 'error' => 'Your current password is incorrect.']; + } + } + + if (strlen($newPassword) < MIN_PASSWORD_LENGTH) { + return ['success' => false, 'error' => 'Password must be at least ' . MIN_PASSWORD_LENGTH . ' characters.']; + } + if ($newPassword !== $confirm) { + return ['success' => false, 'error' => 'Passwords do not match.']; + } + + set_user_password($targetId, $newPassword); + return ['success' => true]; +} + +function handle_delete_user(array $post, array $currentUser): array { + $targetId = (int) ($post['user_id'] ?? 0); + if ($targetId <= 0 || !get_user_by_id($targetId)) { + return ['success' => false, 'error' => 'User not found.']; + } + if ($targetId === (int) $currentUser['id']) { + return ['success' => false, 'error' => "You can't delete your own account while logged in as it."]; + } + if (count_users() <= 1) { + return ['success' => false, 'error' => 'At least one user account must remain.']; + } + + delete_user($targetId); + return ['success' => true]; +} + +function handle_users_action(string $action, array $post, array $currentUser): array { + return match ($action) { + 'create_user' => handle_create_user($post, $currentUser), + 'change_password' => handle_change_password($post, $currentUser), + 'delete_user' => handle_delete_user($post, $currentUser), + default => ['success' => false, 'error' => 'Unknown action'], + }; +} + +// Handle AJAX actions (JSON response) +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + header('Content-Type: application/json; charset=utf-8'); + + $csrfToken = $_POST['csrf_token'] ?? null; + if (!verify_csrf_token(is_string($csrfToken) ? $csrfToken : null)) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Session expired or invalid security token. Please refresh.']); + exit; + } + + $action = is_string($_POST['action'] ?? null) ? $_POST['action'] : ''; + echo json_encode(handle_users_action($action, $_POST, $user)); + exit; +} + +$users = get_all_users(); +?> + + + + + + + User Management | The Old Fitz Admin + + + + + + + + + + +
+
+
+

Access

+

User Management

+
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + +
UsernameRoleCreatedActions
+ + + (you) + + +
+ + + + +
+
+
+
+
+
+ + + + + + + + + + + + diff --git a/inc/users.php b/inc/users.php new file mode 100644 index 0000000..669a06d --- /dev/null +++ b/inc/users.php @@ -0,0 +1,72 @@ +query("SELECT id, username, role, created_at FROM users ORDER BY username ASC"); + return $stmt->fetchAll() ?: []; +} + +function get_user_by_id(int $id): ?array { + $pdo = get_db(); + $stmt = $pdo->prepare("SELECT id, username, role, created_at FROM users WHERE id = ?"); + $stmt->execute([$id]); + $res = $stmt->fetch(); + return $res ?: null; +} + +function count_users(): int { + $pdo = get_db(); + return (int) $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn(); +} + +function username_exists(string $username, ?int $excludeId = null): bool { + $pdo = get_db(); + if ($excludeId !== null) { + $stmt = $pdo->prepare("SELECT 1 FROM users WHERE username = ? AND id != ?"); + $stmt->execute([$username, $excludeId]); + } else { + $stmt = $pdo->prepare("SELECT 1 FROM users WHERE username = ?"); + $stmt->execute([$username]); + } + return (bool) $stmt->fetchColumn(); +} + +function create_user(string $username, string $password, string $role): int { + $pdo = get_db(); + $hash = password_hash($password, PASSWORD_DEFAULT); + $stmt = $pdo->prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)"); + $stmt->execute([$username, $hash, $role]); + return (int) $pdo->lastInsertId(); +} + +function set_user_password(int $id, string $newPassword): bool { + $pdo = get_db(); + $hash = password_hash($newPassword, PASSWORD_DEFAULT); + $stmt = $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?"); + return $stmt->execute([$hash, $id]); +} + +function verify_user_password(int $id, string $password): bool { + $pdo = get_db(); + $stmt = $pdo->prepare("SELECT password_hash FROM users WHERE id = ?"); + $stmt->execute([$id]); + $hash = $stmt->fetchColumn(); + return is_string($hash) && password_verify($password, $hash); +} + +function delete_user(int $id): bool { + $pdo = get_db(); + $stmt = $pdo->prepare("DELETE FROM users WHERE id = ?"); + return $stmt->execute([$id]); +} diff --git a/styles.css b/styles.css index 6c3ce82..86f27a2 100644 --- a/styles.css +++ b/styles.css @@ -470,7 +470,10 @@ transform: translateY(-1px); } - button.btn { + /* :where() keeps this native-button reset at zero specificity, so + modifier classes like .btn-primary's background can actually win + instead of losing to button.btn's higher (0,1,1) specificity */ + :where(button.btn) { cursor: pointer; background: transparent; }