Safari caches static assets far more aggressively than Chrome/Firefox and largely ignores heuristic revalidation, so a new deploy could keep showing the old CSS/JS indefinitely even on a hard refresh — no Cache-Control headers were set anywhere to guide it otherwise. inc/assets.php adds asset_version($path), which appends a ?v=<mtime> query string derived from the file's last-modified time. Every page that links styles.css or main.js (index.php, menu.php, and all five admin pages) now uses it, so the URL changes whenever the file's contents change and browsers always fetch the new version. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
404 lines
17 KiB
PHP
404 lines
17 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../inc/auth.php';
|
|
require_once __DIR__ . '/../inc/assets.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_toggle_user_enabled(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.'];
|
|
}
|
|
if ($targetId === (int) $currentUser['id']) {
|
|
return ['success' => false, 'error' => "You can't disable your own account while logged in as it."];
|
|
}
|
|
|
|
$currentlyEnabled = (int) $target['is_enabled'] === 1;
|
|
if ($currentlyEnabled && count_enabled_users() <= 1) {
|
|
return ['success' => false, 'error' => 'At least one user account must remain enabled.'];
|
|
}
|
|
|
|
toggle_user_enabled($targetId);
|
|
$isEnabled = (int) get_user_by_id($targetId)['is_enabled'] === 1;
|
|
|
|
return [
|
|
'success' => true,
|
|
'is_enabled' => $isEnabled,
|
|
'status_badge' => $isEnabled ? '● Enabled' : '○ Disabled',
|
|
'status_class' => $isEnabled ? 'active' : 'inactive',
|
|
];
|
|
}
|
|
|
|
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),
|
|
'toggle_user_enabled' => handle_toggle_user_enabled($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<?= asset_version('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>
|
|
<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="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="panel-head">
|
|
<h2>
|
|
<span>👤 All Users (<?= count($users) ?>)</span>
|
|
</h2>
|
|
</div>
|
|
|
|
<div class="user-grid-wrap">
|
|
<div class="user-grid-head" aria-hidden="true">
|
|
<span class="col-status">Status</span>
|
|
<span class="col-username">Username</span>
|
|
<span class="col-role">Role</span>
|
|
<span class="col-created">Created</span>
|
|
<span class="col-actions">Actions</span>
|
|
</div>
|
|
<div class="user-rows" id="users-rows">
|
|
<?php foreach ($users as $row): ?>
|
|
<?php $isSelf = (int) $row['id'] === (int) $user['id']; ?>
|
|
<?php $isEnabled = (int) $row['is_enabled'] === 1; ?>
|
|
<div class="user-row" id="user-row-<?= (int) $row['id'] ?>" data-user-id="<?= (int) $row['id'] ?>">
|
|
<div data-label="Status">
|
|
<?php if ($isSelf): ?>
|
|
<span class="status-badge <?= $isEnabled ? 'active' : 'inactive' ?>" title="You can't disable your own account.">
|
|
<?= $isEnabled ? '● Enabled' : '○ Disabled' ?>
|
|
</span>
|
|
<?php else: ?>
|
|
<button type="button" class="status-badge btn-toggle-user <?= $isEnabled ? 'active' : 'inactive' ?>" data-id="<?= (int) $row['id'] ?>" title="Click to mark as <?= $isEnabled ? 'disabled' : 'enabled' ?>">
|
|
<?= $isEnabled ? '● Enabled' : '○ Disabled' ?>
|
|
</button>
|
|
<?php endif; ?>
|
|
</div>
|
|
<div data-label="Username">
|
|
<strong><?= htmlspecialchars($row['username']) ?></strong>
|
|
<?php if ($isSelf): ?>
|
|
<span class="muted"> (you)</span>
|
|
<?php endif; ?>
|
|
</div>
|
|
<div data-label="Role"><?= htmlspecialchars(ucfirst($row['role'])) ?></div>
|
|
<div class="font-mono timestamp-cell" data-label="Created"><?= htmlspecialchars($row['created_at']) ?></div>
|
|
<div data-label="Actions">
|
|
<div class="row-actions">
|
|
<div class="action-stack">
|
|
<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 (!$isSelf): ?>
|
|
<button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="<?= (int) $row['id'] ?>">
|
|
Delete
|
|
</button>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
</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>
|
|
<div class="password-input-wrap">
|
|
<input type="password" id="user-field-password" name="password" class="form-control" autocomplete="new-password" minlength="8" 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" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<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" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</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>
|
|
<div class="password-input-wrap">
|
|
<input type="password" id="password-field-current" name="current_password" class="form-control" autocomplete="current-password">
|
|
<button type="button" class="password-toggle-btn" aria-label="Show password" data-target="password-field-current">
|
|
<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" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<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" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<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" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</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>
|