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.
This commit is contained in:
David Dyer committed 2026-10-25 12:58:19 -04:00
1 parent 91ec95a4a9
commit 242c5165e1
21 files changed
+669 -77

No files matched your search

+92 -26
View File
@@ -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();
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<span>Logged in as <strong><?= htmlspecialchars($user['username']) ?></strong></span>
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
<a href="logout.php">Sign Out</a>
</div>
</div>
@@ -252,6 +275,12 @@ $users = get_all_users();
<div data-label="Actions">
<div class="row-actions">
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-edit-user"
data-id="<?= (int) $row['id'] ?>"
data-username="<?= htmlspecialchars($row['username']) ?>"
data-role="<?= htmlspecialchars($row['role']) ?>">
Edit
</button>
<button type="button" class="btn btn-outline btn-sm btn-change-password"
data-id="<?= (int) $row['id'] ?>"
data-username="<?= htmlspecialchars($row['username']) ?>">
@@ -297,13 +326,13 @@ $users = get_all_users();
<option value="manager">Manager</option>
<option value="staff">Staff</option>
</select>
<p class="muted field-hint">For your own reference only — every account has the same access.</p>
<p class="muted field-hint">Only admins can manage users. Other permissions are still to be decided.</p>
</div>
<div class="form-group">
<label for="user-field-password">Password</label>
<div class="password-input-wrap">
<input type="password" id="user-field-password" name="password" class="form-control" autocomplete="new-password" minlength="8" required>
<input type="password" id="user-field-password" name="password" class="form-control" autocomplete="new-password" minlength="<?= MIN_PASSWORD_LENGTH ?>" required>
<button type="button" class="password-toggle-btn" aria-label="Show password" data-target="user-field-password">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
@@ -316,7 +345,7 @@ $users = get_all_users();
<div class="form-group">
<label for="user-field-confirm">Confirm Password</label>
<div class="password-input-wrap">
<input type="password" id="user-field-confirm" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required>
<input type="password" id="user-field-confirm" name="confirm_password" class="form-control" autocomplete="new-password" minlength="<?= MIN_PASSWORD_LENGTH ?>" required>
<button type="button" class="password-toggle-btn" aria-label="Show password" data-target="user-field-confirm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
@@ -335,6 +364,43 @@ $users = get_all_users();
</div>
</div>
<!-- Modal Backdrop: Edit User -->
<div class="admin-modal-backdrop" id="modal-edit-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-edit-title">
<div class="admin-modal">
<div class="modal-header">
<h2 id="modal-edit-title">✏️ Edit User</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-edit-modal">&times;</button>
</div>
<form id="form-edit">
<input type="hidden" name="action" value="edit_user">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="user_id" id="edit-field-user-id" value="0">
<div class="form-grid">
<div class="form-group full-width">
<label for="edit-field-username">Username</label>
<input type="text" id="edit-field-username" name="username" class="form-control" autocomplete="off" required>
</div>
<div class="form-group full-width">
<label for="edit-field-role">Role</label>
<select id="edit-field-role" name="role" class="form-control">
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="staff">Staff</option>
</select>
<p class="muted field-hint" id="edit-role-self-hint" hidden>You can't change your own role. Another admin has to do it.</p>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="btn-save-edit">Save Changes</button>
<button type="button" class="btn btn-outline" id="btn-cancel-edit">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Modal Backdrop: Change Password -->
<div class="admin-modal-backdrop" id="modal-password-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-password-title">
<div class="admin-modal">
@@ -364,7 +430,7 @@ $users = get_all_users();
<div class="form-group">
<label for="password-field-new">New Password</label>
<div class="password-input-wrap">
<input type="password" id="password-field-new" name="new_password" class="form-control" autocomplete="new-password" minlength="8" required>
<input type="password" id="password-field-new" name="new_password" class="form-control" autocomplete="new-password" minlength="<?= MIN_PASSWORD_LENGTH ?>" required>
<button type="button" class="password-toggle-btn" aria-label="Show password" data-target="password-field-new">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
@@ -377,7 +443,7 @@ $users = get_all_users();
<div class="form-group">
<label for="password-field-confirm">Confirm New Password</label>
<div class="password-input-wrap">
<input type="password" id="password-field-confirm" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required>
<input type="password" id="password-field-confirm" name="confirm_password" class="form-control" autocomplete="new-password" minlength="<?= MIN_PASSWORD_LENGTH ?>" required>
<button type="button" class="password-toggle-btn" aria-label="Show password" data-target="password-field-confirm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />