feat: add user editing password reset and deletion

This commit is contained in:
Marco0300
2026-09-02 00:01:12 +02:00
parent 185741b7a8
commit b72d88491a
3 changed files with 68 additions and 3 deletions
+1
View File
@@ -53,6 +53,7 @@ final class UserAdminService
$result = ($this->users ?? new UserRecord())->validate($input);
$existing = $existingUsers[array_search($id, array_map(static fn($row) => is_array($row) ? (int)($row['id'] ?? 0) : 0, $existingUsers), true)] ?? [];
if ($this->isProtectedAdministrator(['id' => $id, ...$existing]) && array_key_exists('role_id', $input) && (int)$input['role_id'] !== 1) $result['errors']['role_id'] = 'The protected Administrator account cannot be reassigned.';
if ($this->isProtectedAdministrator(['id' => $id, ...$existing]) && array_key_exists('is_active', $input) && !$this->asBool($input['is_active'])) $result['errors']['is_active'] = 'The protected Administrator account cannot be deactivated.';
$email = $result['email'] ?? '';
if (is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, $id)) {
$result['errors']['email'] = 'Email address is already in use.';
+44 -3
View File
@@ -17,6 +17,7 @@ require_once __DIR__ . '/../app/Domain/SLA/SlaAgreement.php';
require_once __DIR__ . '/../app/Domain/SLA/SlaThresholdClassifier.php';
require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php';
require_once __DIR__ . '/../app/Domain/User/UserRecord.php';
require_once __DIR__ . '/../app/Domain/User/UserAdminService.php';
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php';
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php';
require_once __DIR__ . '/../app/Domain/User/RolePermissionService.php';
@@ -658,6 +659,41 @@ if ($route === 'clients') {
exit;
}
if ($route === 'user_edit') {
require_permission('users.manage');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); exit('Edit requires POST'); }
verify_csrf();
$targetId = filter_var(scalar_input($_POST['user_id'] ?? null), FILTER_VALIDATE_INT);
$existingUsers = db()->query('SELECT id, name, email, role_id, is_active, role_name FROM users u JOIN roles r ON r.id = u.role_id')->fetchAll();
$target = null; foreach ($existingUsers as $candidate) if ((int)$candidate['id'] === (int)$targetId) { $target = $candidate; break; }
if (!$target) { http_response_code(404); exit('User not found'); }
$payload = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => isset($_POST['is_active']) ? '1' : '0'];
$service = new \App\Domain\User\UserAdminService();
$result = $service->validateForEdit((int)$targetId, $payload, $existingUsers);
if (trim(scalar_input($_POST['password'] ?? null)) !== '') { $passwordCheck = $service->validatePasswordReset(['id' => $targetId], $_POST['password']); if (!$passwordCheck['valid']) $result['errors'] = [...$result['errors'], ...$passwordCheck['errors']]; }
$roleCheck = db()->prepare('SELECT id FROM roles WHERE id = :id'); $roleCheck->execute(['id' => $result['role_id'] ?? 0]); if (!$roleCheck->fetchColumn()) $result['errors']['role_id'] = 'Selected role does not exist.';
if (!$result['errors']) {
$pdo = db(); $pdo->beginTransaction();
$pdo->prepare('UPDATE users SET name = :name, email = :email, role_id = :role, is_active = :active WHERE id = :id')->execute(['name' => $result['name'], 'email' => $result['email'], 'role' => $result['role_id'], 'active' => $result['is_active'] ? 1 : 0, 'id' => $targetId]);
if (trim(scalar_input($_POST['password'] ?? null)) !== '') $pdo->prepare('UPDATE users SET password_hash = :hash WHERE id = :id')->execute(['hash' => password_hash(scalar_input($_POST['password']), PASSWORD_DEFAULT), 'id' => $targetId]);
audit('user_updated', 'user', (int)$targetId, ['password_changed' => trim(scalar_input($_POST['password'] ?? null)) !== '']); $pdo->commit(); header('Location: /?route=users&updated=1'); exit;
}
$_SESSION['user_edit_errors'] = $result['errors']; header('Location: /?route=users&edit_user=' . (int)$targetId); exit;
}
if ($route === 'user_delete') {
if (($user['role_name'] ?? '') !== 'Administrator') { http_response_code(403); exit('Forbidden'); }
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); exit('Delete requires POST'); }
verify_csrf();
$targetId = filter_var(scalar_input($_POST['user_id'] ?? null), FILTER_VALIDATE_INT);
if (!$targetId || (int)$targetId === (int)$user['id']) { http_response_code(400); exit('User cannot be deleted.'); }
$targetStmt = db()->prepare('SELECT u.id, u.name, u.role_id, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = :id'); $targetStmt->execute(['id' => $targetId]); $target = $targetStmt->fetch();
$service = new \App\Domain\User\UserAdminService();
if (!$target || $service->isProtectedAdministrator($target)) { http_response_code(403); exit('Protected Administrator cannot be deleted.'); }
$linked = db()->prepare('SELECT (SELECT COUNT(*) FROM jobcard_assignments WHERE user_id = :id_a) + (SELECT COUNT(*) FROM time_entries WHERE technician_id = :id_b)'); $linked->execute(['id_a' => $targetId, 'id_b' => $targetId]);
if ((int)$linked->fetchColumn() > 0) { http_response_code(409); exit('User has assigned jobcards or time entries; deactivate the user instead.'); }
audit('user_deleted', 'user', (int)$targetId, ['name' => $target['name']]); db()->prepare('DELETE FROM users WHERE id = :id')->execute(['id' => $targetId]); header('Location: /?route=users&deleted=1'); exit;
}
if ($route === 'users') {
require_permission('users.manage');
$userErrors = [];
@@ -682,10 +718,15 @@ if ($route === 'users') {
}
}
$roles = db()->query('SELECT id, name FROM roles ORDER BY name')->fetchAll();
$users = db()->query('SELECT u.id, u.name, u.email, u.is_active, u.last_login_at, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id ORDER BY u.name')->fetchAll();
$users = db()->query('SELECT u.id, u.name, u.email, u.role_id, u.is_active, u.last_login_at, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id ORDER BY u.name')->fetchAll();
$editUser = null;
$editUserId = filter_var(scalar_input($_GET['edit_user'] ?? null), FILTER_VALIDATE_INT);
foreach ($users as $listed) if ($editUserId && (int)$listed['id'] === (int)$editUserId) { $editUser = $listed; break; }
if (isset($_SESSION['user_edit_errors'])) { $userErrors = (array)$_SESSION['user_edit_errors']; unset($_SESSION['user_edit_errors']); }
render_header('Users');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Users</h1><p class="text-muted mb-0">Create and review system accounts.</p></div><button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-user">New user</button></div>' . (isset($_GET['created']) ? '<div class="alert alert-success">User created successfully.</div>' : '') . ($userErrors ? '<div class="alert alert-danger">' . e(implode(' ', $userErrors)) . '</div>' : '') . '<div class="collapse mb-4" id="new-user"><div class="card"><div class="card-body"><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-4"><label class="form-label">Name</label><input class="form-control" name="name" required></div><div class="col-md-4"><label class="form-label">Email</label><input class="form-control" type="email" name="email" required></div><div class="col-md-4"><label class="form-label">Role</label><select class="form-select" name="role_id" required><option value="">Choose role</option>'; foreach ($roles as $role) echo '<option value="' . (int)$role['id'] . '">' . e($role['name']) . '</option>'; echo '</select></div><div class="col-md-6"><label class="form-label">Initial password</label><input class="form-control" type="password" name="password" minlength="12" required><div class="form-text">Use at least 12 characters with upper/lowercase, number and symbol.</div></div><div class="col-12"><button class="btn btn-primary">Create user</button></div></form></div></div></div><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th></tr></thead><tbody>';
foreach ($users as $listedUser) echo '<tr><td>' . e($listedUser['name']) . '</td><td>' . e($listedUser['email']) . '</td><td>' . e($listedUser['role_name']) . '</td><td>' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '</td><td>' . e((string)($listedUser['last_login_at'] ?? 'Never')) . '</td></tr>';
if ($editUser) echo '<div class="card mb-4"><div class="card-body"><div class="d-flex justify-content-between"><h2 class="h5">Edit user</h2><a href="/?route=users">Cancel</a></div><form method="post" action="/?route=user_edit" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="user_id" value="' . (int)$editUser['id'] . '"><div class="col-md-4"><label class="form-label">Name</label><input class="form-control" name="name" value="' . e($editUser['name']) . '" required></div><div class="col-md-4"><label class="form-label">Email</label><input class="form-control" type="email" name="email" value="' . e($editUser['email']) . '" required></div><div class="col-md-4"><label class="form-label">Role</label><select class="form-select" name="role_id" required>'; foreach ($roles as $role) echo '<option value="' . (int)$role['id'] . '"' . ((int)$role['id'] === (int)$editUser['role_id'] ? ' selected' : '') . '>' . e($role['name']) . '</option>'; echo '</select></div><div class="col-md-4"><label class="form-label">New password <span class="text-muted">(optional)</span></label><input class="form-control" type="password" name="password" minlength="12"><div class="form-text">Leave blank to keep the current password.</div></div><div class="col-md-4 form-check pt-4"><input class="form-check-input" type="checkbox" name="is_active" value="1" id="edit-active"' . ($editUser['is_active'] ? ' checked' : '') . '><label class="form-check-label" for="edit-active">Active user</label></div><div class="col-12"><button class="btn btn-primary">Save user changes</button></div></form></div></div>';
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Users</h1><p class="text-muted mb-0">Create and review system accounts.</p></div><button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-user">New user</button></div>' . (isset($_GET['created']) ? '<div class="alert alert-success">User created successfully.</div>' : '') . ($userErrors ? '<div class="alert alert-danger">' . e(implode(' ', $userErrors)) . '</div>' : '') . '<div class="collapse mb-4" id="new-user"><div class="card"><div class="card-body"><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-4"><label class="form-label">Name</label><input class="form-control" name="name" required></div><div class="col-md-4"><label class="form-label">Email</label><input class="form-control" type="email" name="email" required></div><div class="col-md-4"><label class="form-label">Role</label><select class="form-select" name="role_id" required><option value="">Choose role</option>'; foreach ($roles as $role) echo '<option value="' . (int)$role['id'] . '">' . e($role['name']) . '</option>'; echo '</select></div><div class="col-md-6"><label class="form-label">Initial password</label><input class="form-control" type="password" name="password" minlength="12" required><div class="form-text">Use at least 12 characters with upper/lowercase, number and symbol.</div></div><div class="col-12"><button class="btn btn-primary">Create user</button></div></form></div></div></div><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th></tr></thead><tbody>';
foreach ($users as $listedUser) echo '<tr><td>' . e($listedUser['name']) . '</td><td>' . e($listedUser['email']) . '</td><td>' . e($listedUser['role_name']) . '</td><td>' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '</td><td>' . e((string)($listedUser['last_login_at'] ?? 'Never')) . '</td><td><a class="btn btn-sm btn-outline-primary" href="/?route=users&edit_user=' . (int)$listedUser['id'] . '">Edit</a> ' . (($user['role_name'] === 'Administrator' && (int)$listedUser['id'] !== (int)$user['id'] && $listedUser['role_name'] !== 'Administrator') ? '<form method="post" action="/?route=user_delete" class="d-inline" onsubmit="return confirm(\'Delete this user permanently?\');"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="user_id" value="' . (int)$listedUser['id'] . '"><button class="btn btn-sm btn-outline-danger">Delete</button></form>' : '') . '</td></tr>';
echo '</tbody></table></div></div>'; render_footer(); exit;
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php';
require_once __DIR__ . '/../app/Domain/User/UserRecord.php';
require_once __DIR__ . '/../app/Domain/User/UserAdminService.php';
function user_admin_assert(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); }
$service = new App\Domain\User\UserAdminService();
$existing = [
['id' => 1, 'name' => 'System Administrator', 'email' => 'admin@example.com', 'role_id' => 1, 'role_name' => 'Administrator', 'is_active' => 1],
['id' => 2, 'name' => 'Technician', 'email' => 'tech@example.com', 'role_id' => 3, 'role_name' => 'Technician', 'is_active' => 1],
];
$edit = $service->validateForEdit(2, ['name' => 'Updated Tech', 'email' => 'new-tech@example.com', 'role_id' => 3, 'is_active' => '1'], $existing);
user_admin_assert($edit['valid'] === true, 'A valid user edit should pass.');
$adminDeactivate = $service->validateForEdit(1, ['name' => 'System Administrator', 'email' => 'admin@example.com', 'role_id' => 1, 'is_active' => '0'], $existing);
user_admin_assert($adminDeactivate['valid'] === false && isset($adminDeactivate['errors']['is_active']), 'Administrator deactivation must be rejected.');
$password = $service->validatePasswordReset(['id' => 2], 'Strong-Password-42');
user_admin_assert($password['valid'] === true, 'A strong password reset should pass.');
$front = file_get_contents(dirname(__DIR__) . '/public/index.php');
user_admin_assert($front !== false && str_contains($front, "if (\$route === 'user_edit')") && str_contains($front, "if (\$route === 'user_delete')"), 'User edit and delete routes must exist.');
user_admin_assert(str_contains($front, "role_name'] ?? '') !== 'Administrator'"), 'User deletion must be Administrator-only.');
printf("User administration tests: 5 passed\n");