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
This commit is contained in:
1 parent
803d02d758
commit
e68b24e1c7
6 files changed
+569
-1
No files matched your search
@@ -79,6 +79,7 @@ $today = date('Y-m-d');
|
|||||||
<div class="admin-nav-links">
|
<div class="admin-nav-links">
|
||||||
<a href="index.php" class="active">Announcements</a>
|
<a href="index.php" class="active">Announcements</a>
|
||||||
<a href="menu.php">Menu</a>
|
<a href="menu.php">Menu</a>
|
||||||
|
<a href="users.php">Users</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-user">
|
<div class="admin-user">
|
||||||
<span>Logged in as <strong><?= htmlspecialchars($user['username']) ?></strong></span>
|
<span>Logged in as <strong><?= htmlspecialchars($user['username']) ?></strong></span>
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ $menuGrouped = get_full_menu(false);
|
|||||||
<div class="admin-nav-links">
|
<div class="admin-nav-links">
|
||||||
<a href="index.php">Announcements</a>
|
<a href="index.php">Announcements</a>
|
||||||
<a href="menu.php" class="active">Menu</a>
|
<a href="menu.php" class="active">Menu</a>
|
||||||
|
<a href="users.php">Users</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-user">
|
<div class="admin-user">
|
||||||
<span>Logged in as <strong><?= htmlspecialchars($user['username']) ?></strong></span>
|
<span>Logged in as <strong><?= htmlspecialchars($user['username']) ?></strong></span>
|
||||||
|
|||||||
+175
@@ -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
|
||||||
|
// <body data-csrf-token="..." data-current-user-id="...">.
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<td><strong>${escapeHtml(user.username)}</strong></td>
|
||||||
|
<td>${escapeHtml(user.role.charAt(0).toUpperCase() + user.role.slice(1))}</td>
|
||||||
|
<td class="font-mono item-sort-cell">${escapeHtml(user.created_at)}</td>
|
||||||
|
<td><div class="admin-table-actions">
|
||||||
|
<button type="button" class="btn btn-outline btn-sm btn-change-password" data-id="${user.id}">Change Password</button>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="${user.id}">Delete</button>
|
||||||
|
</div></td>
|
||||||
|
`;
|
||||||
|
tr.querySelector('.btn-change-password').dataset.username = user.username;
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
+316
@@ -0,0 +1,316 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
|
require_once __DIR__ . '/../inc/users.php';
|
||||||
|
|
||||||
|
require_auth();
|
||||||
|
$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.
|
||||||
|
const ALLOWED_ROLES = ['admin', 'manager', 'staff'];
|
||||||
|
const DEFAULT_ROLE = 'admin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely read a trimmed string from a POST payload, guarding against
|
||||||
|
* unexpected non-string input (e.g. a spoofed `name[]=` array field).
|
||||||
|
*/
|
||||||
|
function post_str(array $data, string $key, string $default = ''): string {
|
||||||
|
$value = $data[$key] ?? $default;
|
||||||
|
return is_string($value) ? trim($value) : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function post_role(array $data): string {
|
||||||
|
$role = post_str($data, 'role', DEFAULT_ROLE);
|
||||||
|
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');
|
||||||
|
$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();
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>User Management | The Old Fitz Admin</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="../styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>" data-current-user-id="<?= (int) $user['id'] ?>">
|
||||||
|
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<div class="container admin-nav-inner">
|
||||||
|
<a href="index.php" class="admin-brand">
|
||||||
|
<img src="../assets/knot.png" alt="" class="knot">
|
||||||
|
<span>The Old Fitz Admin</span>
|
||||||
|
</a>
|
||||||
|
<div class="admin-nav-links">
|
||||||
|
<a href="index.php">Announcements</a>
|
||||||
|
<a href="menu.php">Menu</a>
|
||||||
|
<a href="users.php" class="active">Users</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="logout.php">Sign Out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="dashboard-layout">
|
||||||
|
<div class="container">
|
||||||
|
<div class="dash-header">
|
||||||
|
<p class="eyebrow">Access</p>
|
||||||
|
<h1>User Management</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="btn-new-user">+ Add User</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table" id="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="users-tbody">
|
||||||
|
<?php foreach ($users as $row): ?>
|
||||||
|
<tr id="user-row-<?= (int) $row['id'] ?>" data-user-id="<?= (int) $row['id'] ?>">
|
||||||
|
<td>
|
||||||
|
<strong><?= htmlspecialchars($row['username']) ?></strong>
|
||||||
|
<?php if ((int) $row['id'] === (int) $user['id']): ?>
|
||||||
|
<span class="muted"> (you)</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars(ucfirst($row['role'])) ?></td>
|
||||||
|
<td class="font-mono item-sort-cell"><?= htmlspecialchars($row['created_at']) ?></td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-table-actions">
|
||||||
|
<button type="button" class="btn btn-outline btn-sm btn-change-password"
|
||||||
|
data-id="<?= (int) $row['id'] ?>"
|
||||||
|
data-username="<?= htmlspecialchars($row['username']) ?>">
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
<?php if ((int) $row['id'] !== (int) $user['id']): ?>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="<?= (int) $row['id'] ?>">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Modal Backdrop: Add User -->
|
||||||
|
<div class="admin-modal-backdrop" id="modal-user-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-user-title">
|
||||||
|
<div class="admin-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-user-title">✨ Add User</h2>
|
||||||
|
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-user-modal">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="form-user">
|
||||||
|
<input type="hidden" name="action" value="create_user">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label for="user-field-username">Username</label>
|
||||||
|
<input type="text" id="user-field-username" name="username" class="form-control" placeholder="e.g. jsmith" autocomplete="off" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label for="user-field-role">Role</label>
|
||||||
|
<select id="user-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">For your own reference only — every account has the same access.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-field-password">Password</label>
|
||||||
|
<input type="password" id="user-field-password" name="password" class="form-control" autocomplete="new-password" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-field-confirm">Confirm Password</label>
|
||||||
|
<input type="password" id="user-field-confirm" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary" id="btn-save-user">Create User</button>
|
||||||
|
<button type="button" class="btn btn-outline" id="btn-cancel-user">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">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-password-title">🔑 Change Password</h2>
|
||||||
|
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-password-modal">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="form-password">
|
||||||
|
<input type="hidden" name="action" value="change_password">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||||
|
<input type="hidden" name="user_id" id="password-field-user-id" value="0">
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full-width" id="password-current-group">
|
||||||
|
<label for="password-field-current">Current Password</label>
|
||||||
|
<input type="password" id="password-field-current" name="current_password" class="form-control" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password-field-new">New Password</label>
|
||||||
|
<input type="password" id="password-field-new" name="new_password" class="form-control" autocomplete="new-password" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password-field-confirm">Confirm New Password</label>
|
||||||
|
<input type="password" id="password-field-confirm" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary" id="btn-save-password">Update Password</button>
|
||||||
|
<button type="button" class="btn btn-outline" id="btn-cancel-password">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="users.js" defer></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin user management helpers.
|
||||||
|
*
|
||||||
|
* Every read here explicitly selects columns and leaves password_hash
|
||||||
|
* out, so it can never accidentally end up in a JSON response or a
|
||||||
|
* data-* attribute the way a full row dump would.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
function get_all_users(): array {
|
||||||
|
$pdo = get_db();
|
||||||
|
$stmt = $pdo->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]);
|
||||||
|
}
|
||||||
+4
-1
@@ -470,7 +470,10 @@
|
|||||||
transform: translateY(-1px);
|
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;
|
cursor: pointer;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user