# Central user global roles on Add user and Edit user

Date: **2026-10-23**. A focused usability enhancement to Central → Users. Global roles can now be chosen when a user is created and changed on the Edit user screen, instead of only one at a time on the details page. There are no schema changes, no new routes and no new packages. Nothing was committed, pushed or deployed.

## 1. What changed for users

### Add user (`/central/users/create`)

- **Fields kept:** the existing fields are unchanged (full name, email, password, confirm password, account type, account status).
- **New section:** a **Global roles** section follows the account type.
  - **Central Administrator:** shows checkboxes for the active global roles the signed-in administrator is allowed to assign (for example Super Admin, Platform Administrator, Platform Read Only), each with its description. Choosing a role is optional. Without one, the account has no Central Admin access.
  - **Standard User:** hides the roles and shows "Standard users have no global roles. Organization roles are assigned when the user is added to an organization." Hidden roles are disabled, so a role ticked before switching to Standard User is never submitted.
- **Excluded roles:** organization roles and inactive roles are never offered.
- **Confirmation:** choosing a high-privilege role shows a confirmation checkbox, the same one used elsewhere.
- **No automatic roles:** no role is assigned automatically.

### Edit user (`/central/users/{user}/edit`)

- **Ticking roles:** for a Central Administrator, the new **Global roles** section lists the user's current roles (pre-ticked) and the active global roles the administrator may add. Unticked roles are removed when **Save changes** is pressed, together with the profile and status changes.
- **Inactive roles:** an inactive role the user already holds is shown with an **Inactive role** badge and a note. It stays assigned while ticked and is never removed silently. If it is unticked, it cannot be added back until the role is reactivated.
- **Locked roles:** a role the administrator cannot remove is shown ticked and locked, with the reason. This covers two cases: the role has permissions the administrator lacks, or it is the last active Super Admin.
- **Read-only view:** your own account, or administrators without role-assignment permission, see the roles read-only, as before. Standard users show the organization-roles explanation instead.
- **Account type:** still changed only from the user details page. The **Make Standard User** confirmation now names the roles that will be removed, for example "These global roles will be removed: Platform Administrator, Platform Read Only."

### Users list

The Users list is unchanged: Name, Email, Account type, Role, Status, Actions. The details-page role controls still work.

## 2. Safeguards

All checks are server-side; the screens only reflect them.

| Rule | Where it is enforced |
|---|---|
| Only active **global** roles can be chosen when creating; organization, inactive and forged IDs fail validation. | `StoreUserRequest` rules |
| Only **global** roles can be referenced when editing; inactive ones pass validation only so a held inactive role can be kept. | `UpdateUserRequest` rules |
| Standard users cannot receive global roles (create: validation error; edit: validation error; service: refusal). | `StoreUserRequest`, `UpdateUserRequest::after()`, `CentralUserService`, `RoleAssignmentService::assign()` |
| Creating a Central Administrator (and choosing roles) needs `central.users.assign_roles`. | `StoreUserRequest::authorize()` (existing `assignCentralAdmin` ability) |
| Changing roles on Edit needs `central.users.update` **and** `assignRoles` on that user (which also excludes yourself and higher-ranked users). | `UpdateUserRequest::authorize()`, `UserPolicy` |
| No self-escalation or self-removal; no role with permissions the actor lacks; inactive roles are never newly assigned. | `RoleAssignmentService::assign()` / `remove()` (unchanged), used for every change |
| High-privilege roles need explicit confirmation. Keeping a role already held needs none. | `RoleAssignmentService::assign()` |
| At least one active Super Admin always remains. | `RoleAssignmentService::remove()` and `ensureAnotherSuperAdminRemains()` |
| All or nothing: account creation and its roles, or an edit's profile, status and role changes, are saved together or not at all. | One `DB::transaction` in `CentralUserService::create()` / `update()`, with `RoleAssignmentService::syncGlobal()` nested inside |
| Concurrent role edits on one user are serialized. | `syncGlobal()` locks the user row |
| Organization memberships and organization roles are never touched. | `syncGlobal()` works only on global roles |
| Account type alone grants nothing; Central access still needs an active role with `central.dashboard.view`. | Existing `access-central` gate (unchanged) |
| Demotion to Standard User removes roles only after the explicit confirmation, which names them. Each removal is audited, and demoting the last Super Admin is blocked. | Existing `changeAccountType()` (unchanged); dialog text improved |

**Audit:** these existing events are recorded inside the same transaction, so a refused save leaves no audit entry:

- `user.created`: a snapshot without the password;
- `user.role_assigned` and `user.role_removed`: one per role, with the role name and the acting administrator.

**Refused saves:** after a refusal, the form keeps the ticked roles, account type and other fields. Passwords and the confirmation checkbox are not kept and must be entered again.

## 3. Files changed

| File | Change |
|---|---|
| `app/Services/Rbac/RoleAssignmentService.php` | New `syncGlobal()`: set exact global roles via `remove()`/`assign()` in one transaction |
| `app/Services/Central/CentralUserService.php` | `create()`/`update()` accept optional `global_roles` and `confirm_high_privilege`; roles applied inside the existing transaction |
| `app/Http/Requests/Central/StoreUserRequest.php` | `roles[]`, `confirm_high_privilege`; standard-user prohibition; messages; role loading |
| `app/Http/Requests/Central/UpdateUserRequest.php` | `manage_roles`, `roles[]`, `confirm_high_privilege`; extra authorization; standard-user check |
| `app/Http/Controllers/Central/UserController.php` | Role data for the Add user form; locked-role reasons for Edit; shared "roles the actor may assign" query |
| `resources/views/central/users/partials/role-picker.blade.php` | **New** shared role checkbox list with descriptions, inactive badge, lock reasons and the confirmation checkbox |
| `resources/views/central/users/create.blade.php` | Global roles section that shows or hides with the account type |
| `resources/views/central/users/edit.blade.php` | Global roles section; read-only list kept for self or no permission |
| `resources/views/central/users/show.blade.php` | Make Standard User dialog names the roles that will be removed |
| `tests/Feature/Central/UserGlobalRoleFormTest.php` | **New**: 25 tests |

## 4. Automated tests

| Command | Result |
|---|---|
| `php artisan test` | **552 passed, 3,069 assertions**, 0 failed (baseline 527) |
| `vendor/bin/pint --test` | Passed |
| `npm run build` | Passed |
| `php artisan route:list` | **169 routes** (unchanged) |
| `php artisan migrate:status` (`hrms_db`) | **19 applied, 0 pending**. No migration was added. |

The 25 new tests in `UserGlobalRoleFormTest` cover the requested list:

- **Creation:**
  - a Central Administrator with one role, without a role (and no Central access), and with multiple roles;
  - a Standard User without roles, and refusal of roles for a Standard User.
- **Editing:**
  - assigned roles preselected (including inactive ones);
  - adding and removing roles, removing every role, and a save without the roles section leaving roles unchanged.
- **Protection:**
  - the last Super Admin is locked and refused, while removal is allowed when another Super Admin remains;
  - own roles are read-only and forged changes are refused;
  - lower administrators and roles with permissions the actor lacks are refused, on both Add and Edit.
- **High-privilege confirmation:** required on Add and Edit, and not required to keep a role already held.
- **Inactive roles:** kept when ticked, removed when unticked, never newly assigned, and never offered on Add user.
- **Cross-scope:** organization-role IDs and forged IDs are refused; organization roles stay unchanged.
- **Account type:**
  - demotion names and audits each removed role;
  - demoting the last Super Admin is blocked and keeps its roles.
- **Transaction rollback:**
  - a refused Add creates no account, assignment or audit entry;
  - a refused Edit saves neither the name, nor the removal, nor the new role.
- **Audit:** the event sequence and values are checked, with no password in any audit row.
- **Input kept:** ticked roles and account type survive validation errors and refusals, the password does not, and the high-privilege list renders as a JSON array.

All 527 existing tests still pass.

## 5. Browser verification (real Chrome)

**Environment:** the existing isolated QA environment:

- database `hrms_browser_qa`, rebuilt by the guarded `fresh_demo.sh`;
- server at `http://127.0.0.1:8095`, where every page must carry the `X-HRMS-Environment: browser-qa` header;
- **Google Chrome 155.0.8059.85**, headless, driven by playwright-core 1.64.0.

The script `storage/app/browser-qa/runner/user_roles.mjs` creates only new `@demo.example.test` accounts in the QA database. Evidence is in `storage/app/browser-qa/evidence/user-roles/` (local, ignored by git).

| # | Workflow | Result | Observed | Screenshot (`evidence/user-roles/screenshots/`) |
|---|---|---|---|---|
| U01 | Account type shows or hides Global Roles | Pass | Standard User: explanation, no checkboxes. Central Administrator: Platform Administrator, Platform Read Only and Super Admin offered; no organization roles. | `user-roles-create-standard.png`, `user-roles-create-central-admin.png` |
| U02 | Roles ticked, then type switched to Standard User | Pass | Created as Standard User with no roles and no error. | `user-roles-standard-created.png` |
| U03 | Create with two roles | Pass | The confirmation appeared only once Platform Administrator was ticked. The unconfirmed save was refused, keeping the type, both roles and the name, while the password was cleared. The confirmed save assigned both roles. | `user-roles-create-refused-kept.png`, `user-roles-created-with-roles.png` |
| U04 | Edit: preselected roles, remove one | Pass | Both roles pre-ticked and no confirmation shown. Unticking Platform Read Only removed it (audited). | `user-roles-edit-preselected.png` |
| U05 | Edit: add Super Admin with a name change | Pass | The unconfirmed save was refused with nothing saved; the entered name and ticks were kept. The confirmed save stored both. | `user-roles-edit-refused-kept.png` |
| U06 | Edit own account | Pass | No checkboxes; Super Admin listed read-only. | `user-roles-edit-self-readonly.png` |
| U07 | Phones at 360 and 390 px | Pass | Add user (Central Administrator with a role ticked) and Edit user: page overflow 0 px; the confirmation checkbox is fully on screen. | `user-roles-create-mobile-360.png`, `user-roles-edit-mobile-390.png` |

**Result: 8 of 8 passed, with no JavaScript errors.**

The first run reported U04 as failed. The screenshot and audit log showed that the role had in fact been removed. The script was reading the whole roles card, including the "Assign a global role" dropdown, which offers the removed role again. The check was narrowed to the assigned-roles table, the QA database was rebuilt and the whole script was rerun; the table above is from that run.

## 6. Database safety

- **`hrms_db`:**
  - no migration, reset, seeding or data change was made by this work;
  - read-only fingerprints were taken before and after the browser run, and in that window only `audit_logs`, `branches` and `sessions` changed;
  - the audit log shows those changes are a branch created and updated at 08:34–08:36 UTC through the development server, which is the user's manual testing (this work creates no branches);
  - these records were not touched.
- **Tests:** they ran only on SQLite in memory and the guarded `hrms_db_testing` suite.
- **Browser checks:** these used only `hrms_browser_qa` and new demo accounts. No live manual-test account was used.
- **Servers:** the development servers on ports 8000 and 5173 were not interrupted. The QA server on port 8095 was stopped afterwards.

## 7. Remaining limitations

- **Account type on Edit:** the account type is still changed only from the user details page, not on Edit user. This keeps the existing confirmation dialog for demotion.
- **Demotion behaviour:** demoting a Central Administrator to Standard User still removes their global roles, but only after a confirmation that now names them. Each removal is audited, and the last Super Admin cannot be demoted. Blocking demotion entirely while roles exist would change an existing, tested rule, so it was not done. It can be added if preferred.
- **Locked-role display:** the "last active Super Admin" lock on the Edit screen reflects the moment the page was loaded. The server re-checks under a row lock when saving.
- **Details page:** it keeps its one-at-a-time Assign role and Remove controls. Both paths use the same service rules.
- **Browser coverage:** browser verification used Chrome only. Other browsers and assistive technology remain on the manual checklist.
