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 = htmlspecialchars($user['username']) ?>
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Username |
+ Role |
+ Created |
+ Actions |
+
+
+
+
+
+ |
+ = htmlspecialchars($row['username']) ?>
+
+ (you)
+
+ |
+ = htmlspecialchars(ucfirst($row['role'])) ?> |
+ = htmlspecialchars($row['created_at']) ?> |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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;
}