From b72d88491aac7e94abc3c4baecc6f91e03bf366f Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Wed, 2 Sep 2026 00:01:12 +0200 Subject: [PATCH] feat: add user editing password reset and deletion --- app/Domain/User/UserAdminService.php | 1 + public/index.php | 47 ++++++++++++++++++++++++++-- tests/UserAdministrationTest.php | 23 ++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tests/UserAdministrationTest.php diff --git a/app/Domain/User/UserAdminService.php b/app/Domain/User/UserAdminService.php index ffe0215..4bb24c2 100644 --- a/app/Domain/User/UserAdminService.php +++ b/app/Domain/User/UserAdminService.php @@ -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.'; diff --git a/public/index.php b/public/index.php index a7581b5..be62430 100644 --- a/public/index.php +++ b/public/index.php @@ -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 '

Users

Create and review system accounts.

' . (isset($_GET['created']) ? '
User created successfully.
' : '') . ($userErrors ? '
' . e(implode(' ', $userErrors)) . '
' : '') . '
Use at least 12 characters with upper/lowercase, number and symbol.
'; - foreach ($users as $listedUser) echo ''; + if ($editUser) echo '

Edit user

Cancel
Leave blank to keep the current password.
'; + echo '

Users

Create and review system accounts.

' . (isset($_GET['created']) ? '
User created successfully.
' : '') . ($userErrors ? '
' . e(implode(' ', $userErrors)) . '
' : '') . '
Use at least 12 characters with upper/lowercase, number and symbol.
NameEmailRoleStatusLast login
' . e($listedUser['name']) . '' . e($listedUser['email']) . '' . e($listedUser['role_name']) . '' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '' . e((string)($listedUser['last_login_at'] ?? 'Never')) . '
'; + foreach ($users as $listedUser) echo ''; echo '
NameEmailRoleStatusLast loginActions
' . e($listedUser['name']) . '' . e($listedUser['email']) . '' . e($listedUser['role_name']) . '' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '' . e((string)($listedUser['last_login_at'] ?? 'Never')) . 'Edit ' . (($user['role_name'] === 'Administrator' && (int)$listedUser['id'] !== (int)$user['id'] && $listedUser['role_name'] !== 'Administrator') ? '
' : '') . '
'; render_footer(); exit; } diff --git a/tests/UserAdministrationTest.php b/tests/UserAdministrationTest.php new file mode 100644 index 0000000..c7fa59b --- /dev/null +++ b/tests/UserAdministrationTest.php @@ -0,0 +1,23 @@ + 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");