# Organization user management enhancement

Date: **2026-10-23**. A focused enhancement: authorized Organization Admins can now create new user accounts from their own Organization Users panel. Existing organization user and custom role management were verified and kept. There are no schema changes and no new packages. Nothing was committed, pushed or deployed.

## 1. What changed

- **Add user button:** Organization Users (`/organization/users`) shows **Add user** in the page header, but only to people with the new `organization.users.create_accounts` permission.
- **Add user form:** **Organization Users → Add user** (`/organization/users/create`) creates a new **Standard User** account and adds it to the organization being worked in, optionally with organization roles, all at once.
- **New permission:** `organization.users.create_accounts` ("Create user accounts", sensitive). Only the protected Organization Admin role receives it automatically.
- **Existing accounts:** an email already used anywhere on the platform is refused with a generic message. Existing accounts are never changed, enrolled or revealed; they are still added through Central enrollment.
- **New audit event:** `user.created_by_organization`, shown as "User account created by organization".
- **Rate limit:** account-creation attempts are limited to 20 per minute per user.
- **Shared role picker:** the role checkbox list from the Central Add/Edit user forms is now shared (`resources/views/partials/role-picker.blade.php`) and reused here. It is not a parallel component set.
- **Wording:** the info notes on Organization Users and on the employee **Login account** tab now explain how new and existing people are added.

The Organization Users list keeps its columns (Name, Email, Roles, Access, Actions) and all existing actions.

## 2. Organization Add User workflow

| Field | Behaviour |
|---|---|
| Full name, Email address | Required. The email is trimmed and lower-cased. |
| Password, Confirm password | Required; the existing strong-password policy (`Password::defaults()`). It is never sent back to the form after an error. |
| Status (under "Access to this organization") | **Active** (default) or **Suspended**. This is the **membership** status. The account itself is always created active, and only Central manages account status. |
| Organization roles | Optional checkboxes with descriptions. These are the active roles of the current organization whose permissions you hold. Choosing a high-privilege role shows the confirmation checkbox. |

**Not shown:** an organization selector, account type, global roles, user IDs, platform permissions and other organizations. Sending any of `organization_id`, `account_type`, `is_central_admin`, `user_id` or `global_roles` is refused.

**On success:**

1. One transaction runs under the organization row lock (`SELECT … FOR UPDATE`):
   - the Standard User account is created (`is_central_admin = false`, `is_active = true`);
   - the membership is added in the **verified current organization only**;
   - the roles are assigned.
2. The browser goes to the new member's page with a confirmation message.

**Not created:**
- **No employee record:** creating a login account and creating an employee record stay separate.
- **No self-service:** the account has no self-service access until it is explicitly linked to an employee record and holds the own-data permissions.

**All or nothing:** if anything fails, nothing is kept. That includes validation, authorization, a refused role, a missing confirmation, an existing email, or a concurrent duplicate.

## 3. Role assignment behaviour

Every rule is enforced on the server by `OrganizationMembershipService`, with the same rules as the existing member role form:

| Rule | Behaviour |
|---|---|
| Scope | Only active roles **of the current organization**. Global, inactive, other-organization and forged IDs fail validation ("Select active roles of this organization only."). |
| Permission to assign | Choosing any role also needs `organization.users.assign_roles`. With only `create_accounts`, accounts can be created without roles; roles are added later by someone allowed to. |
| No escalation | Each role's permissions must all be held by the actor in this organization. Other roles are not offered and are refused if forged. |
| High privilege | Organization Admin, or any role with a sensitive permission, needs the explicit confirmation. The confirmation list is a JSON array (the browser-QA fix). |
| Protected roles and last admin | Unchanged. The Organization Admin role stays locked. The last-effective-admin protection applies to created admins exactly as before; a test covers this. |
| Global roles | Never created or assigned here. |

**Employee role:** the organization's **Employee** role carries employee self-service permissions (`employee.*`). The Organization Admin role intentionally does not hold these. Under the existing no-escalation rule, Organization Admins therefore cannot grant the Employee role, either here or on the member page. This is unchanged pre-existing behaviour; see §11.

## 4. Duplicate-email security

**Check:** inside the transaction, the email is compared case-insensitively against all accounts. The database's unique index also catches a concurrent request between the check and the insert; that case is rolled back and given the same answer.

**For any address already in use, the result is always the same:**

- **Message** (on the email field): "An account cannot be created with this email address here. If this person already has an HRMS account (for example in another organization), ask a Central Administrator to add them to this organization."
- **No new account and no changes:** no password, status, name or membership of the existing account is touched.
- **No enrollment:** the existing account is not added to this organization.
- **Nothing revealed:** not their name, other organizations, roles, employee profiles, Central Administrator status or account status.
- **Same answer for every case**, whether the address belongs to a Central Administrator, a member of another organization, or anyone else.

The only thing the form can reveal is that *some* account uses an address. The brief accepts this; the 20-per-minute rate limit makes probing slow. Central Admin enrollment of existing accounts is unchanged.

## 5. Permission architecture

| Permission | Purpose |
|---|---|
| `organization.users.create_accounts` (**new**, sensitive) | Create new Standard User accounts in the current organization |
| `organization.users.assign_roles` (existing) | Needed in addition, to choose roles while creating |
| `organization.users.view` / `deactivate` / `remove` (existing) | List, suspend or reactivate, and remove memberships (unchanged) |
| `organization.users.create` (existing, *Future module*) | Not used; it stays reserved for enrolling existing accounts |

**Registration and sync:**
- **Registry:** the permission lives in `config/rbac.php`.
- **Organization Admin:** holds every `organization.*` permission, so `hrms:sync-permissions` adds the new one to every Organization Admin role. That role stays locked.
- **Other roles:** no other default role template lists it, and existing custom roles are not changed.
- **Custom roles:** an Organization Admin may still choose to grant it to a custom role on the Roles page; this is covered by a test.
- **No global inheritance:** no `central.*` permission is involved, and global roles still grant no organization access.

**Checks (all server-side):**
- **Ability:** `OrganizationMembershipPolicy::createAccounts`, used by the request, the controller and the service.
- **Organization context:** the policy applies `PermissionResolver::hasOrganizationPermission` in the verified current organization. A suspended membership, an inactive organization or an inactive account means no permission.
- **Service guard:** an organization other than the verified context is refused.

## 6. Employee-account linking

**Unchanged rules.** A new account:

- **Linking:** it can be linked to an employee record only through the existing **Login account → Link account** action, and only while its membership is **active**. A suspended membership is refused.
- **No automatic link:** it is never linked by matching email or name. This was tested with an employee record whose email equals the new account's.
- **Self-service:** it gets **no** self-service from creation alone. The portal still requires an active account, organization and membership, an active linked employee, and the own-data permissions.

The employee page's "no linkable members" hint now points to Organization Users instead of the Central Admin.

## 7. Files changed

| File | Change |
|---|---|
| `config/rbac.php` | New `organization.users.create_accounts` permission |
| `app/Enums/AuditEvent.php` | New `UserCreatedByOrganization` event, label and tone |
| `app/Policies/OrganizationMembershipPolicy.php` | New `createAccounts` ability |
| `app/Services/Organization/OrganizationMembershipService.php` | New `createAccount()`, `isHighPrivilege()` and generic existing-account handling; escalation guard takes an organization ID |
| `app/Http/Requests/Organization/StoreOrganizationUserRequest.php` | **New**: validation, prohibited forged fields, current-organization role rule |
| `app/Http/Controllers/Organization/OrganizationUserController.php` | New `create`/`store` actions |
| `routes/organization.php` | `GET users/create`, `POST users` (rate-limited), before `users/{member}` |
| `resources/views/organization/users/create.blade.php` | **New** Add user form |
| `resources/views/organization/users/index.blade.php` | Add user header button; updated note |
| `resources/views/partials/role-picker.blade.php` | Moved from `central/users/partials/` and shared; optional legend, confirmation text and high-privilege list |
| `resources/views/central/users/create.blade.php`, `edit.blade.php` | Include path of the shared role picker |
| `resources/views/organization/employees/show.blade.php` | "No linkable members" hint wording |
| `tests/Feature/Enrollment/OrganizationAccountCreationTest.php` | **New**: 18 tests |
| `tests/Feature/Enrollment/OrganizationAccountThrottleTest.php` | **New**: rate limit |
| `tests/MySql/OrganizationAccountCreationMySqlTest.php` | **New**: 3 MySQL tests |
| `tests/Feature/Enrollment/OrganizationUserManagementTest.php` | One assertion updated. It previously required that `POST /organization/users` not exist; it now requires that enrolling an existing account by ID is refused and nothing is attached. |
| `README.md`, `CLAUDE.md` | Documented the new workflow and rules |

## 8. Tests and assertions

| Command | Result |
|---|---|
| `php artisan test` | **574 passed, 3,303 assertions**, 0 failed, 0 skipped (baseline 552; 22 new). The MySQL suite ran on `hrms_db_testing`. |
| `vendor/bin/pint --test` | Passed |
| `npm run build` | Passed |
| `php artisan route:list` | **171 routes** (169 + `GET organization/users/create` + `POST organization/users`) |
| `php artisan migrate:status` (`hrms_db`) | **19 applied, 0 pending**. No migration was added. |
| `composer audit` | No security vulnerability advisories |

**New tests:**

| Area | Covered by |
|---|---|
| Permission registered, sensitive; only Organization Admin holds it by default | `test_permission_is_registered_…` |
| Button and form only with `create_accounts`; HR Manager gets 403 | `test_add_user_button_and_form_are_shown_only_with_create_accounts` |
| Standard User created and enrolled only in the current organization, even for an admin of two organizations; no global roles; no employee record | `test_organization_admin_creates_a_standard_user_…` |
| Multiple roles; suspended membership while the account stays active | `test_multiple_roles_with_confirmation_and_suspended_membership` |
| Audit: three events with actor, organization and timestamp; no password, hash or secret | `test_creation_is_audited_…` |
| High-privilege confirmation; full rollback; entries kept, password not | `test_high_privilege_role_needs_confirmation_…` |
| Escalation refused, and the role is not offered | `test_roles_beyond_the_actors_own_permissions_are_refused` |
| `create_accounts` without `assign_roles` | `test_create_accounts_without_assign_roles_…` |
| Global, inactive and other-organization roles rejected | `test_global_inactive_and_other_organization_roles_are_rejected` |
| Forged `organization_id`, `account_type`, `is_central_admin`, `user_id` and `global_roles` refused | `test_forged_organization_account_type_and_global_role_fields_are_refused` |
| Organization comes only from the verified context, with a service guard | `test_the_organization_is_always_the_verified_current_one` |
| Existing email (any casing, including a Central Administrator): generic message, account unchanged and not enrolled, nothing revealed | `test_existing_email_is_refused_generically_…` |
| Concurrent duplicate rolled back with the same message (mutation-checked: fails if the unique-index handling is removed) | `test_a_concurrent_duplicate_is_rolled_back_…` |
| Suspended administrator and inactive organization denied | `test_suspended_administrators_and_inactive_organizations_are_denied` |
| Role assign/remove, suspend/reactivate and remove still work; the account is never deactivated or deleted; no Central access | `test_membership_workflows_still_work_…` |
| Last-admin protection unchanged | `test_last_admin_protection_is_unchanged_for_created_admins` |
| Custom roles can carry the new permission; global permissions rejected | `test_custom_organization_roles_can_carry_the_new_permission_…` |
| No automatic link; linking needs an active membership; no self-service | `test_created_accounts_get_no_self_service_…` |
| Rate limit (429 after 20 attempts) | `OrganizationAccountThrottleTest` |
| MySQL: email uniqueness is case-insensitive; a mixed-case stored account is never attached or changed; membership and role constraints hold | `OrganizationAccountCreationMySqlTest` (ran on `hrms_db_testing`, not skipped) |

## 9. Real Chrome results

**Environment:**
- **Database:** the isolated `hrms_browser_qa`, rebuilt by the guarded `fresh_demo.sh`, which also registers the new permission there.
- **Server:** `http://127.0.0.1:8095`. Every page must carry the browser-qa header.
- **Browser:** Google Chrome 155.0.8059.85, headless, with playwright-core 1.64.0.

The script is `storage/app/browser-qa/runner/org_users.mjs`. Evidence is in `storage/app/browser-qa/evidence/org-users/` (local, ignored by git): `results.jsonl` and 17 screenshots. The script creates only new `@demo.example.test` accounts; `hrms_db` was never used.

| # | Role | Workflow | Result | Observed |
|---|---|---|---|---|
| G01 | Organization Admin A | Organization Users shows **Add user**; form opens | Pass | The button is in the header. The form has name, email, both passwords, status and this organization's roles (Attendance Manager, HR Manager, Organization Admin, Supervisor, View Only). There is no account type or organization field and no global roles. |
| G02 | Organization Admin A | Create a Standard User with Supervisor | Pass | Created; the member page shows Supervisor; listed as Active |
| G03 | Organization Admin A | High-privilege role (HR Manager) | Pass | Without confirmation it was refused, keeping the name, email and ticked role and clearing the password. With confirmation it was created with HR Manager. |
| G04 | Organization Admin A | Email of an Organization B employee | Pass | Generic message. No Organization B or person details shown. Not enrolled in A. |
| G05 | Organization B employee | Existing account unchanged | Pass | Signed in with the original password; their Organization B portal opened |
| G06 | Organization Admin A | Link the created account to employee DT-020 | Pass | Offered in **Member account** and linked by the explicit action |
| G07 | HR Manager A | No permission | Pass | No Add user button; `/organization/users/create` returned 403 |
| G08 | Organization Admin B | Cross-organization isolation | Pass | The created A account is not listed in B; its A member URL returned 404 |
| G09 | Organization Admin A | Layout at 360, 390, 768 and 1280 px | Pass ×4 | Users list and Add user form both had 0 px page overflow; the Add user button and the confirmation were fully on screen |

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

The first run had one failure, G01. The check looked up the "Full name" label by exact text, but the label includes the required marker ("Full name \*"). G02 filled the same field successfully. The check was changed to look for the form fields themselves, the QA database was rebuilt, and the whole script was rerun; the results above are from that run.

## 10. Database safety verification

- **`hrms_db`:**
  - no migration, reset, reseed or business-record change was made;
  - read-only fingerprints (row counts and checksums of all 30 tables) were taken around the one intentional change, `php artisan hrms:sync-permissions`;
  - that sync changed exactly `permissions` (+1, the new permission), `permission_role` (+1: the existing Organization Admin role gains it) and `audit_logs` (+1 sync entry);
  - from then until the end of this work, the only table that changed was `sessions`, from the developer's own use of the running app;
  - Apex Technologies, its users, memberships, roles, branches, departments, designations and employees were not touched.
- **`hrms_db_testing`:** used only by the guarded MySQL suite. The new MySQL test registers the permission inside its own rolled-back transaction.
- **`hrms_browser_qa`:** the only database written by the browser checks.
- **Migrations:** still **19**, none pending; no migration was added.
- **Servers and settings:** `.env` was not modified. The development servers on ports 8000 and 5173 kept running throughout. The QA server on port 8095 was stopped afterwards.

## 11. Remaining limitations

- **Organization Admins cannot grant the Employee role.** This is pre-existing behaviour under the no-escalation rule, because that role carries `employee.*` self-service permissions the Organization Admin role intentionally lacks. A created account that needs self-service currently gets the Employee role from a Central Administrator, or from a member who holds those permissions. Allowing it would require an explicit decision. One option is to let `organization.users.assign_roles` grant own-data self-service permissions, which give the grantee access only to their own linked records.
- **Existing accounts:** an organization cannot add a person who already has an account; a Central Administrator does that. No invitation or approval flow was added, as instructed.
- **Email probing:** the generic refusal still shows that some account uses a given address. The rate limit slows probing but cannot remove this.
- **Password delivery:** the new account's password must be shared with the person outside the system. No email notification is sent.
- **Browser coverage:** browser checks used Chrome only. Other browsers and assistive technology remain on the manual checklist.

## 12. Manual testing instructions

On `hrms_db`, the new permission is already synced. Sign out and in again if a page was open before the sync.

1. **Find the button:** sign in as an Organization Admin (for example of Apex Technologies) and open **Organization Users**. **Add user** is at the top right.
2. **Create a test account:** use a new email address, a strong password and the **Supervisor** role, then press **Create account**. You land on the new member's page with Supervisor shown.
3. **Try a high-privilege role:** tick **HR Manager** or **Organization Admin**. A yellow confirmation box appears. Submitting without ticking it shows "High-privilege organization roles need explicit confirmation." and keeps your entries except the password.
4. **Try an existing email:** use the email of any existing account, such as another organization's user. You get the generic message and nothing changes for that account.
5. **Check permissions:** sign in as a member without account-creation permission, such as an HR Manager. There is no **Add user** button, and opening `/organization/users/create` shows "Forbidden".
6. **Link to an employee (optional):** open an employee → **Login account** → choose the new account under **Member account** → **Link account**.
7. **Suspend:** on the new member's page, **Suspend** blocks access to the organization only. The account itself stays active, and its lifecycle remains with Central.

Test accounts created this way are real records in `hrms_db`. If a test account is not wanted:

1. Remove its membership from Organization Users (and unlink any employee record first).
2. A Central Administrator then deactivates or deletes the account. Central refuses to delete an account that still has memberships.
