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:
1 parent
91ec95a4a9
commit
242c5165e1
21 files changed
+669
-77
No files matched your search
+92
-26
@@ -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">×</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" />
|
||||
|
||||
Reference in new issue
Block a user