From 242c5165e188447848ca6c036c66703ba831a5d5 Mon Sep 17 00:00:00 2001 From: David Dyer Date: Sun, 25 Oct 2026 12:58:19 -0400 Subject: [PATCH] Refactor admin panel: enhance user management and security features - Added role-based access control for user management links in admin navigation. - Improved user account display with a dedicated account link. - Implemented form submission confirmation for delete actions in the admin interface. - Introduced a password visibility toggle on the login page. - Added login throttling to prevent brute-force attacks, recording failed attempts. - Created a CLI script for creating an admin user, replacing the previous hardcoded admin credentials. - Updated database schema to support login attempt tracking. - Enhanced user editing functionality, allowing role changes with restrictions. - Improved modal handling for user actions, ensuring better focus management. - Added styles for new account management page and improved modal transitions. --- admin/account.php | 182 ++++++++++++++++++++++++++ admin/hours.php | 4 +- admin/index.js | 10 ++ admin/index.php | 6 +- admin/login.js | 19 +++ admin/login.php | 52 ++++---- admin/menu.php | 4 +- admin/users.js | 90 ++++++++++++- admin/users.php | 118 +++++++++++++---- bin/.htaccess | 1 + bin/create-user.php | 53 ++++++++ inc/auth.php | 46 ++++++- inc/db.php | 23 ++-- inc/login-throttle.php | 82 ++++++++++++ inc/users.php | 21 +++ package.json | 2 +- scripts/build-dist.js | 2 +- src/scss/components/_admin-modal.scss | 9 +- src/scss/pages/_admin-account.scss | 7 + src/scss/styles.scss | 1 + styles.css | 14 +- 21 files changed, 669 insertions(+), 77 deletions(-) create mode 100644 admin/account.php create mode 100644 admin/login.js create mode 100644 bin/.htaccess create mode 100644 bin/create-user.php create mode 100644 inc/login-throttle.php create mode 100644 src/scss/pages/_admin-account.scss diff --git a/admin/account.php b/admin/account.php new file mode 100644 index 0000000..53419fb --- /dev/null +++ b/admin/account.php @@ -0,0 +1,182 @@ + + + + + + + + My Account | The Old Fitz Admin + + + + + + + + + + +
+
+
+

Account

+

My Account

+
+ + + + +
Account updated.
+ + + +
+
+ + + + diff --git a/admin/hours.php b/admin/hours.php index 2ba64e7..37258bd 100644 --- a/admin/hours.php +++ b/admin/hours.php @@ -87,12 +87,14 @@ $justSaved = !$_POST && isset($_GET['saved']);
- Logged in as View Public Site β†— + Sign Out
diff --git a/admin/index.js b/admin/index.js index 62b5ee7..983c799 100644 --- a/admin/index.js +++ b/admin/index.js @@ -32,3 +32,13 @@ document.addEventListener('submit', async (e) => { btn.disabled = false; } }); + +// Asks before submitting any form marked .js-confirm-submit (e.g. delete), +// using the message in its data-confirm attribute. +document.addEventListener('submit', (e) => { + const form = e.target; + if (!form.classList.contains('js-confirm-submit')) return; + if (!confirm(form.dataset.confirm || 'Are you sure?')) { + e.preventDefault(); + } +}); diff --git a/admin/index.php b/admin/index.php index 5cd1495..12459c4 100644 --- a/admin/index.php +++ b/admin/index.php @@ -122,12 +122,14 @@ $announcements = get_all_announcements();
- Logged in as View Public Site β†— + Sign Out
@@ -219,7 +221,7 @@ $announcements = get_all_announcements();
Edit -
+ diff --git a/admin/login.js b/admin/login.js new file mode 100644 index 0000000..8b60793 --- /dev/null +++ b/admin/login.js @@ -0,0 +1,19 @@ +// Login page: show/hide password toggle. Both icons are in the markup; +// this just flips which one is hidden, so no HTML is built in JS. + +const btn = document.getElementById('btn-toggle-login-password'); +const input = document.getElementById('password'); + +if (btn && input) { + const eyeOpen = btn.querySelector('.js-eye-open'); + const eyeOff = btn.querySelector('.js-eye-off'); + + btn.addEventListener('click', () => { + const show = input.type === 'password'; + input.type = show ? 'text' : 'password'; + btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password'); + eyeOpen.hidden = show; + eyeOff.hidden = !show; + input.focus(); + }); +} diff --git a/admin/login.php b/admin/login.php index 78b6636..1438c89 100644 --- a/admin/login.php +++ b/admin/login.php @@ -1,17 +1,26 @@ 0) { + http_response_code(429); + $error = 'Too many failed attempts. Try again in ' . (int) ceil($lockSeconds / 60) . ' minute(s).'; + } elseif (authenticate_user($username, $password)) { + clear_login_failures($ip, $throttleKey); header('Location: index.php'); exit; } else { + record_login_failure($ip, $throttleKey); $error = 'Invalid username or password.'; } } @@ -49,10 +58,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -62,23 +81,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ← Return to Pub Website - + diff --git a/admin/menu.php b/admin/menu.php index 3f1a555..c676209 100644 --- a/admin/menu.php +++ b/admin/menu.php @@ -201,12 +201,14 @@ $menuEnabled = is_menu_enabled();
- Logged in as View Public Site β†— + Sign Out
diff --git a/admin/users.js b/admin/users.js index 46f1969..2514689 100644 --- a/admin/users.js +++ b/admin/users.js @@ -7,6 +7,7 @@ const currentUserId = document.body.dataset.currentUserId; const userModal = document.getElementById('modal-user-backdrop'); const passwordModal = document.getElementById('modal-password-backdrop'); +const editModal = document.getElementById('modal-edit-backdrop'); function escapeHtml(text) { const div = document.createElement('div'); @@ -54,11 +55,19 @@ function openUserModal() { form.reset(); resetPasswordVisibility(form); userModal.classList.add('is-open'); - document.getElementById('user-field-username').focus(); +} + +// Safari ignores autocomplete="off" on fields labeled "Username" and offers +// saved logins whenever one has focus. The modals used to auto-focus that +// field, and a closed modal left it focused, so the prompt kept popping up. +// Now nothing is auto-focused, and closing a modal drops focus. +function closeModal(modal) { + modal.classList.remove('is-open'); + if (modal.contains(document.activeElement)) document.activeElement.blur(); } function closeUserModal() { - userModal.classList.remove('is-open'); + closeModal(userModal); } // Change Password modal @@ -80,9 +89,76 @@ function openPasswordModal(userId, username) { } function closePasswordModal() { - passwordModal.classList.remove('is-open'); + closeModal(passwordModal); } +// Edit User modal +function openEditModal(userId, username, role) { + const isSelf = String(userId) === currentUserId; + const form = document.getElementById('form-edit'); + const roleSelect = document.getElementById('edit-field-role'); + + form.reset(); + document.getElementById('edit-field-user-id').value = userId; + document.getElementById('edit-field-username').value = username; + roleSelect.value = role; + roleSelect.disabled = isSelf; + document.getElementById('edit-role-self-hint').hidden = !isSelf; + + editModal.classList.add('is-open'); +} + +function closeEditModal() { + closeModal(editModal); +} + +function capitalize(text) { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +// Updates an existing row's text and data attributes in place after an edit. +function updateUserRow(user) { + const row = document.getElementById(`user-row-${user.id}`); + if (!row) return; + + row.querySelector('[data-label="Username"] strong').textContent = user.username; + row.querySelector('[data-label="Role"]').textContent = capitalize(user.role); + row.querySelectorAll('.btn-edit-user, .btn-change-password').forEach((btn) => { + btn.dataset.username = user.username; + }); + row.querySelector('.btn-edit-user').dataset.role = user.role; + + if (String(user.id) === currentUserId) { + const navName = document.querySelector('.admin-account-link'); + if (navName) navName.textContent = user.username; + } +} + +document.getElementById('btn-close-edit-modal').addEventListener('click', closeEditModal); +document.getElementById('btn-cancel-edit').addEventListener('click', closeEditModal); + +document.getElementById('form-edit').addEventListener('submit', async (e) => { + e.preventDefault(); + const saveBtn = document.getElementById('btn-save-edit'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + const res = await postForm(new FormData(e.target)); + if (res.success) { + closeEditModal(); + updateUserRow(res.user); + } else { + alert(res.error || 'Failed to update user.'); + } + } catch { + alert('Network error. Please try again.'); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save Changes'; + } +}); + 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); @@ -184,6 +260,11 @@ document.getElementById('users-rows').addEventListener('click', async (e) => { return; } + if (target.classList.contains('btn-edit-user')) { + openEditModal(target.dataset.id, target.dataset.username, target.dataset.role); + return; + } + if (target.classList.contains('btn-change-password')) { openPasswordModal(target.dataset.id, target.dataset.username); return; @@ -236,11 +317,14 @@ function insertUserRow(user) {
${escapeHtml(user.role.charAt(0).toUpperCase() + user.role.slice(1))}
${escapeHtml(user.created_at)}
+
`; row.querySelector('.btn-change-password').dataset.username = user.username; + row.querySelector('.btn-edit-user').dataset.username = user.username; + row.querySelector('.btn-edit-user').dataset.role = user.role; rowsContainer.appendChild(row); } diff --git a/admin/users.php b/admin/users.php index 377f0af..f9b0239 100644 --- a/admin/users.php +++ b/admin/users.php @@ -5,16 +5,15 @@ require_once __DIR__ . '/../inc/auth.php'; require_once __DIR__ . '/../inc/assets.php'; require_once __DIR__ . '/../inc/users.php'; -require_auth(); +// User management is admin-only. Other pages don't check roles yet β€” +// what manager and staff can do is still to be decided with the owner. +require_role('admin'); $user = get_logged_in_user(); -const MIN_PASSWORD_LENGTH = 8; - -// Role is a label only right now β€” every account has identical access -// regardless of what's stored here. This just keeps the value to a known, -// displayable set instead of trusting arbitrary POST input. +// Keeps the stored role to a known set instead of trusting arbitrary POST +// input. Anything unrecognized falls back to the least-privileged role. const ALLOWED_ROLES = ['admin', 'manager', 'staff']; -const DEFAULT_ROLE = 'admin'; +const DEFAULT_ROLE = 'staff'; /** * Safely read a trimmed string from a POST payload, guarding against @@ -30,19 +29,6 @@ function post_role(array $data): string { return in_array($role, ALLOWED_ROLES, true) ? $role : DEFAULT_ROLE; } -function validate_username(string $username): ?string { - if ($username === '') { - return 'Username is required.'; - } - if (strlen($username) < 3 || strlen($username) > 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'); @@ -65,6 +51,42 @@ function handle_create_user(array $post, array $currentUser): array { return ['success' => true, 'user' => get_user_by_id($newId)]; } +function handle_edit_user(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.']; + } + + $username = post_str($post, 'username'); + $isSelf = $targetId === (int) $currentUser['id']; + + if ($error = validate_username($username)) { + return ['success' => false, 'error' => $error]; + } + if (username_exists($username, $targetId)) { + return ['success' => false, 'error' => 'That username is already taken.']; + } + + // You can't change your own role: an admin demoting themselves could + // leave nobody able to manage users. Another admin has to do it. + if ($isSelf) { + $role = $target['role']; + } else { + $role = post_str($post, 'role'); + if (!in_array($role, ALLOWED_ROLES, true)) { + return ['success' => false, 'error' => 'Invalid role.']; + } + } + + update_user($targetId, $username, $role); + if ($isSelf) { + $_SESSION['admin_username'] = $username; + } + + return ['success' => true, 'user' => get_user_by_id($targetId)]; +} + function handle_change_password(array $post, array $currentUser): array { $targetId = (int) ($post['user_id'] ?? 0); $target = $targetId > 0 ? get_user_by_id($targetId) : null; @@ -139,6 +161,7 @@ function handle_delete_user(array $post, array $currentUser): array { function handle_users_action(string $action, array $post, array $currentUser): array { return match ($action) { 'create_user' => handle_create_user($post, $currentUser), + 'edit_user' => handle_edit_user($post, $currentUser), 'change_password' => handle_change_password($post, $currentUser), 'toggle_user_enabled' => handle_toggle_user_enabled($post, $currentUser), 'delete_user' => handle_delete_user($post, $currentUser), @@ -192,8 +215,8 @@ $users = get_all_users(); Hours
- Logged in as View Public Site β†— + Sign Out
@@ -252,6 +275,12 @@ $users = get_all_users();
+
- +
+ + +