# CR-13 — User Management module

Requirements by the BA (Fable), 2026-09-03. Client instruction verbatim:
"make User Management accoring to my wireframe design, i want pixle perfect.
… Remove Username * from user management user can login using email and
password. Remove Permissions and Additional Settings section. Role we will
use from role management but for now create Super Admin Role with full
system access. Password and Confirm Password * required only when user add
in the system at edit user not required but if password enter in edit user
then confirm password required and match. Please don't create test cases
and policies."

Wireframes (design source of truth, vendored at `docs/wireframes-v12/`):
`user-management.html`, `add-user.html`, `edit-user.html`, `view-user.html`,
`index.html` (sidebar). Recipe: `docs/adding-a-module.md` where this spec is
silent. Exemplar: **Broker Management (CR-12)** — the closest sibling
(full-page CRUD, code column, status enum, DataTable, show page).

## §0 Client-mandated overrides — do NOT "fix" back

1. **NO new test files.** Pinned tests may be MINIMALLY updated
   (AppShellTest, RolePermissionSeedTest, TwinViewParityTest if it
   enumerates, DemoSeedTest if touched) — nothing else, and per CR-12's
   lesson each edit's comment must describe what the edit really does.
2. **NO policies.** Bare gates: `Gate::authorize('users.view')` etc.,
   FormRequest `authorize()`, `@can` in blades. The escalation guards of
   §4.6 are implemented as explicit checks in the FormRequests/controller,
   not as a policy class.
3. **Username REMOVED** (client): login is by email + password — which is
   already the app's ONLY login mechanism (`config/fortify.php:48`
   `'username' => 'email'`; no username column exists). Remove the
   wireframe's Username field from add (add-user.html:119-123), edit
   (edit-user.html:119-122) and the view page's Account Details row.
   Nothing else changes about auth.
4. **Permissions section REMOVED** (client): add-user.html:163-254 and
   edit-user.html:159-250 (title, info-box, 16 checkboxes) do not exist.
   Per-user direct permissions are NOT a feature; access = role.
5. **Additional Settings section REMOVED** (client): add-user.html:256-284,
   edit-user.html:252-280 (Max Login Attempts, Session Timeout, 2FA,
   Notification Preferences). With it go the view page's Two-Factor Auth
   row and every other trace of those four settings.
6. **Password rule** (client, verbatim semantics): on ADD, Password * and
   Confirm Password * are required. On EDIT they are present but optional —
   blank means "keep the current password"; a non-blank password requires a
   matching confirmation. Implemented with the app's ONE password policy
   (`Password::default()` from AppServiceProvider) + `confirmed`.
7. **Roles come from Role Management** (client): the Role select lists the
   REAL spatie roles (currently Super Admin / Admin / Sales / Operations),
   ordered by name — NOT the wireframe's eight fictional options — so
   future roles created by the Role Management CR appear automatically.
   Super Admin already exists with full system access (`Gate::before` in
   AppServiceProvider::defineSuperAdmin) — create nothing new for it, but
   §9 D3 records that this satisfies the client's "for now create Super
   Admin Role" line.

## §1 Context (verified in recon — trust these, they were read)

- `users` table is Laravel's stock five columns; NO username, phone,
  status, department, soft deletes, audit. `User` model: `#[Fillable]`
  PHP attribute style, `HasRoles`, `'password' => 'hashed'` cast, no
  HasAudit (the one domain model without it), no relations.
- Auth: Fortify with routes enumerated in `routes/auth.php`; features =
  resetPasswords only; registration/2FA/profile OFF (closed back office).
  Password policy: min 8, letters, mixedCase, numbers, uncompromised in
  prod (`AppServiceProvider::definePasswordPolicy`).
- `AdminUserSeeder` seeds ONE user (Super Admin role, name "Super Admin").
- 4 roles, 28 permissions; no users.*/roles.* permissions yet.
- Sidebar: User Management is a Super-Admin-only inert preview
  (`sidebar.blade.php:284-286`); Role Management stays preview after this
  CR. AppShellTest pins the 14-label order and asserts
  `not->toContain(__('nav.user_management'))` at ~:453 — that line and the
  CR-12 absent-labels dataset need the recipe-§10 ceremony.
- Header topbar computes initials from `$user->name`
  (`topbar.blade.php:42-55`); many screens render `->name` via relations
  (creator/assignedTo/completedBy/causer). **`name` must therefore stay
  populated and correct.**

## §2 Schema

### 2.1 Migration `add_user_management_columns_to_users_table`

- `code` string nullable + unique — `USR-NNN` (§4.1)
- `first_name` string nullable, `last_name` string nullable — the form
  splits the name; **`name` stays the canonical display column**,
  maintained by the service as `trim(first.' '.last)` on every write, so
  topbar initials, causer names and assigned-to selects keep working.
- `phone` string nullable
- `gender` string nullable (enum male/female/other)
- `date_of_joining` date nullable
- `department` string nullable (enum values §3.2) + index
- `status` string NOT NULL default `'active'` (enum active/inactive) + index
- `last_login_at` timestamp nullable
- `password_changed_at` timestamp nullable
- `softDeletes()`
- All nullable except status (existing rows must survive; the FORM
  enforces the wireframe's `*`s — CR-12 D10 pattern).

### 2.2 Data migration (backfill)

Existing rows (the seeded admin, any dev users): `code` per §4.1 in id
order; `first_name`/`last_name` from splitting `name` on the LAST space
(one word → first_name only); leave the rest NULL; status stays 'active'
via the default. Query builder only, empty `down()` documented.

### 2.3 Model changes

`User` gains: HasAudit (which brings SoftDeletes + LogsActivity — the
house rule the model was exempt from only because no CR touched it), and a
**`getActivitylogOptions()` override**: `logFillable()->logOnlyDirty()
->dontSubmitEmptyLogs()->logExcept(['password'])` — **the password hash
must NEVER enter the activity log**; `remember_token` is not fillable but
verify no path logs it. New fillable: first_name, last_name, phone,
gender, date_of_joining, department. **`code`, `status`, `name`,
`last_login_at`, `password_changed_at` are server-owned — NOT fillable,
written via forceFill by the service/listener.** Casts: gender +
department + status to their enums, dates to date/datetime. Keep the
`#[Fillable]` attribute style the model already uses. `initials()`
mirroring `Broker::initials()`. Relations optional (none required by the
screens beyond spatie's roles).

New enums: `UserStatus` (active/inactive; label() Active/Inactive;
badgeClass() badge-success/badge-secondary; options()), `Gender`
(male/female/other; labels Male/Female/Other), `UserDepartment`
(sales/finance/legal/operations/administration/it; labels the wireframe's
Sales/Finance/Legal/Operations/Administration/IT). No default match arms.

### 2.4 Trashed-user display reads

User now soft-deletes, so every RELATION whose job is displaying a
historical name must tolerate a trashed user: grep `belongsTo(User::class`
across app/Models and add `->withTrashed()` to the display relations
(Inquiry created_by/assigned_to, FollowUp assigned_to/completed_by,
Booking/CustomerDoc/BookingType/CustomerDocType/CancellationReason/Unit
creator/updater — whatever the grep actually finds). These are reads of
record history, not privilege: a deleted user's name on an old inquiry is
the CR-12 withTrashed decision applied to users. Assignment DROPDOWNS
(inquiry assigned-to, follow-up assign) are queries on `User::` and
exclude trashed automatically via the global scope — verify, don't change
their behavior otherwise.

## §3 Screens — pixel-perfect minus §0 removals

Sidebar: User Management goes LIVE — recipe §10 ceremony: out of
`$previewMenu` into `@can('users.view')`, same position (1st in
Administration), `fa-user-gear`, `$inUsers = request()->routeIs('users.*')`.
Administration heading gate gains a `can('users.view')` arm (flat).
**Role Management stays preview-only.**

### 3.1 List — `users.index` (user-management.html)

- h1 "User Management"; breadcrumb Home / User Management.
- Action bar EXACTLY as the wireframe: empty left div, right
  "Add New User" button (`fa-plus`, `@can('users.create')`). No h2/subtext
  on this page.
- Stat cards grid-4, real counts, NO trend lines (wireframe has none):
  1. Total Users (non-deleted) — blue `fa-users`
  2. Active — green `fa-user-check`
  3. Inactive — yellow `fa-user-clock`
  4. **Deleted** (trashed count) — red `fa-user-lock` slot. REPLACES the
     wireframe's "Locked" card: no lockout mechanism exists and the user
     chose two statuses (D1); trashed count is this module's real fourth
     number. Label "Deleted".
- Filter bar: search (placeholder "Search users by name, email...");
  Role select = "All Roles" + REAL role names (D3); Status select =
  All Status / Active / Inactive (no Locked); Department select =
  All Departments + the six enum labels.
- DataTable (yajra, server-side): 9 wireframe columns:
  User ID (code, fw-500) | Full Name (name, fw-500) | Email | Phone |
  Role (badge — §3.5) | Department | Last Login
  (`config('nrems.date_format')` + time, — if never) | Status
  (badge-success/-secondary with `.dot`) | Actions.
  Whitelist: PHYSICAL columns only (code, name, email, phone, department,
  status is DELIBERATELY OFF per CR-12's lesson, last_login_at ON —
  physical and orderable). Role is a spatie join — render via eager-loaded
  `roles`, not whitelisted, no orderColumn needed (wireframe implies no
  role sort). Default sort: code ASC (wireframe lists USR-001 first —
  note this is the OPPOSITE of brokers, and matches the wireframe page).
  Search covers name, email, phone, code. Role/status/department filters
  as extra ajax params via the CR-12 `stringParam()` shape (array-safe).
  Excludes trashed (default scope).
- Row actions: View (`fa-eye`, @can users.view), Edit (`fa-pen`, @can
  users.edit), **status toggle** (@can users.edit + guards §4.6):
  `fa-user-slash` (danger, title "Deactivate") on active rows /
  `fa-user-check` (success, title "Activate") on inactive rows — a POST
  form with `confirm()` in BOTH directions (D1: "Always asked
  confirmation"), **Delete** (`fa-trash`, @can users.delete + guards):
  DELETE form + confirm(). All confirm strings via `Js::from()`.

### 3.2 Add / Edit — twin full pages (add-user.html / edit-user.html)

One `.card`. Sections in order, labels VERBATIM with `*`s:

**Personal Information** (`fa-user`):
- Row A form-row-3: First Name * ("Enter first name"), Last Name *
  ("Enter last name"), Email * ("Enter email address").
- Row B form-row-3: Phone * ("+91 XXXXX XXXXX", tel), Gender (select:
  Select Gender/Male/Female/Other, no *), Date of Joining (date, no *).

**Account Details** (`fa-lock`):
- Row A form-row-3 — the Username slot is REMOVED (§0.3):
  - ADD: Password * (password, "Enter password"), Confirm Password *
    (password, "Confirm password"), + one empty `.form-group` spacer so
    the row keeps three tracks.
  - EDIT: Password (no *, placeholder — BA — "Leave blank to keep current
    password"), Confirm Password (no *, "Confirm password"), spacer.
    This REPLACES the wireframe's readonly Username + "Reset Password"
    button (D2: the client's edit-password rule makes the inline fields
    the mechanism; a separate reset flow would be a second writer).
- Row B form-row-2: Role * (select: "Select Role" + real roles, D3),
  Department * (select: "Select Department" + six options).

**Account Activity** (edit ONLY, read-only, `fa-clock-rotate-left`,
edit-user.html:282-310): detail-items with REAL values —
Account Created (created_at, date), Last Login (last_login_at,
date+time, — if never), Total Logins (§4.4), then second row
Last Password Change (password_changed_at, — if never) + two spacers.
The wireframe's "Failed Login Attempts" row is dropped (nothing tracks
it — D4).

Footer:
- ADD: Cancel (outline → index) + "Save User" (primary `fa-check`).
- EDIT: Cancel + **Deactivate User / Activate User** button (outline
  danger `fa-user-slash` when active — wireframe verbatim; outline
  success `fa-user-check` "Activate User" when inactive — D1 mirror) +
  "Update User" (primary `fa-check`). The status button is its own small
  POST form with confirm() (it cannot nest inside the edit form —
  hoist it the CR-10 way with the HTML5 `form` attribute, or place the
  status form adjacent and reference by `form=`; do NOT nest forms).
  Hidden per §4.6 guards (self / Super Admin target without rights).
- Add h1 "Add New User", breadcrumb "… / Add User". Edit h1
  "Edit User - {name}", breadcrumb "… / Edit User" (wireframe verbatim).

### 3.3 View — `users.show` (view-user.html, minus removals)

- Title/h1 "User Profile - {name}"; breadcrumb "… / View User".
- Profile header card: 64px initials circle (`initials()`), h3 name,
  badges row: status badge (dot), role badge (§3.5), muted code. Right:
  "Edit User" (primary, @can users.edit), **Deactivate / Activate**
  (outline, confirm(), guards) — the wireframe's "Reset Password" button
  is REMOVED (D2). Delete is NOT on this page (wireframe has none).
- grid-2: **Personal Information** card (fa-user): Full Name, Email,
  Phone, Gender, Date of Joining, Department. **Account Details** card
  (fa-lock): Role, Account Created, Last Login, Total Logins,
  Last Password Change — Username and Two-Factor rows removed, Failed
  Attempts removed (D4). Em-dash blanks.
- **Assigned Permissions** card (fa-shield-halved) — D5, the user's
  answer "Based on roles permission user can access system": grid-4 of
  ALL seeded permissions (RolePermissionSeeder::PERMISSIONS order), each
  with `fa-circle-check` (green) when the user CAN it or `fa-circle-xmark`
  (muted) when not — computed via `$user->can(...)` so a Super Admin shows
  all granted (Gate::before) and everyone else shows their role's real
  grants. Label each with a humanized permission name (lang keys).
- Bottom grid-2: **Recent Activity** (fa-clock-rotate-left): the user's
  REAL activity as CAUSER, latest 10, timeline markup (time = nrems
  date+time; event = description + subject code where available — render
  what the log actually holds, no fabrication). **Performance Summary**
  (fa-chart-line): 4 stat tiles with REAL numbers — Inquiries Handled
  (inquiries created_by OR assigned_to = user), Bookings Closed
  (confirmed bookings created_by = user), Conversion Rate (bookings ÷
  inquiries as %, — when no inquiries), and the wireframe's "Revenue
  Generated" tile REMOVED (financials excluded, standing client rule) —
  render THREE tiles in the grid-2 (fourth cell empty) rather than
  inventing a metric; NO trend lines (wireframe's "This month" arrows are
  fictional periods — drop them, values are all-time).

### 3.5 Role badge mapping (dynamic strings, not an enum)

`badge-purple` for "Super Admin", `badge-primary` for "Admin",
`badge-info` for anything else (a `match` on a string with a default arm
is correct here — roles are user-creatable data, not a closed enum; a
helper on the view side or a small support class, NOT on the spatie Role
model). No role badge shows for a user with no role (—).

## §4 Behavior

### 4.1 User code

`USR-NNN` — 3-digit global sequence from 001 (wireframe: USR-001…008; no
year part, unlike brokers). Generated the CR-12 way: `nextCode()` on the
model, `withTrashed()` max, inside the insert transaction with
lockForUpdate + `RetryOnUniqueCollision::once`. Grows naturally past 999
(USR-1000). Server-owned via forceFill.

### 4.2 Create / Update — `UserService`

- create: code + status active (default) + `name` from first/last +
  password (rely on the `hashed` cast OR explicit Hash::make — match the
  model's existing convention) + `password_changed_at = now()` +
  `email_verified_at = now()` (closed back office, admin-created accounts
  are verified by definition — the seeder's own precedent) +
  `syncRoles([$role])` in the same transaction.
- update: never touches code; rebuilds `name`; password only when the
  input is non-blank (then also `password_changed_at = now()`);
  `syncRoles` only when the role actually changed. Role change for the
  CURRENT authed user must not kill their session (spatie cache is
  per-request; just forget the permission cache — verify what spatie
  needs, likely nothing beyond team-less default).
- Status is NOT written by create/update (§4.5 owns it).
- HasAudit logs fillable changes; the role change gets ONE explicit
  `activity()` row with `->event('role_changed')` + old/new role names in
  properties (CR-9 lesson: spatie sync writes no user-visible trail).

### 4.3 Login restriction (D1 "restrict the login")

Two layers, both required:
1. **At login**: an inactive user's credentials are rejected exactly like
   wrong credentials (same message — do not leak that the account exists
   but is disabled). Implement via `Fortify::authenticateUsing()` in
   FortifyServiceProvider: find by email (default scope excludes
   trashed), check `Hash::check`, check `status === active`. Keep the
   existing rate limiter untouched.
2. **Mid-session**: `EnsureUserIsActive` middleware appended to the
   authenticated web group — an authenticated user whose status is no
   longer active is logged out (session invalidated, token regenerated)
   and redirected to login with a status message. A soft-DELETED user is
   already logged out by the provider (global scope makes retrieveById
   return null) — verify, don't duplicate.
   Password reset flow: an inactive user may complete a reset (harmless —
   login stays blocked); no change there.

### 4.4 Login tracking

`LogAuthenticationActivity` (existing listener) — extend it: on Login,
also `forceFill(['last_login_at' => now()])` + save WITHOUT an extra
activity row for the column write (wrap in disableLogging/enableLogging
if HasAudit would log it — it won't, the column is not fillable, but the
CR-9 lesson says verify) and without breaking whatever it already logs.
**Total Logins** = count of the user's existing login activity rows
(read what the listener actually writes — event/description — and count
THAT; if it logs nothing usable, count from `last_login_at` is
impossible, so then Total Logins renders the activity-log login count
from this CR forward). No new table.

### 4.5 Status toggle — `users.status` route

`PATCH users/{user}/status` (or POST — match how the app does state
actions; CR-9/CR-10 used POST forms), gated `users.edit` + §4.6 guards.
Service method `toggleStatus`/`setStatus`: locked re-read, flip
active↔inactive via forceFill, ONE explicit activity row
`->event('activated')`/`->event('deactivated')`. Deactivation does NOT
touch sessions directly — the middleware (§4.3.2) catches the user on
their next request. Confirmation is client-side confirm() in both
directions (D1) — strings like "Are you sure you want to deactivate
{name}?" / "…activate {name}?" via Js::from.

### 4.6 Guards (D6 — enforced server-side, mirrored in UI visibility)

Checked in the FormRequests / controller (no policies, §0.2):
1. **Self**: a user cannot deactivate, delete, or change the status of
   THEMSELVES (the edit form may still update their own profile fields;
   role/status of self are off-limits — hide the buttons, reject the
   POST with 403).
2. **Super Admin escalation**: only a Super Admin may (a) assign or
   remove the Super Admin role, (b) edit, deactivate, or delete a user
   who HAS the Super Admin role. A non-super-admin users.edit holder
   editing such a user: hide Edit/toggle/Delete affordances AND 403 the
   attempt. The Role select never offers "Super Admin" to a
   non-super-admin (and the POST rejects it).
3. **Last Super Admin**: refuse deactivating/deleting/demoting the LAST
   active, non-deleted Super Admin (friendly validation error) — the
   system must never lock itself out. Count under lock in the service.

### 4.7 Delete (D7)

Soft delete, confirm() DELETE form, `users.delete` permission + §4.6
guards. Trashed users: out of lists/dropdowns/login (global scope);
names survive on history via §2.4 withTrashed relations; activity causer
render must tolerate a trashed causer (verify the causer relation —
morphTo — returns the model regardless; if the default scope hides it,
render '—' rather than erroring). Email stays reserved by the DB unique
index (covers trashed rows) — acceptable; the validation message for a
reused email is the standard unique message. No restore UI this CR.

## §5 Validation — `app/Http/Requests/Users/` Store + Update

- first_name / last_name: required, string, max:255
- email: required, email, max:255, unique users ignoring self on update
  (DB index also covers trashed — see §4.7)
- phone: required + the shared phone rules
  (`App\Support\Validation\PhoneRules` from CR-12)
- gender: nullable, enum; date_of_joining: nullable, date
- department: required, enum
- role: required, string, exists in spatie roles table (guard-name web);
  PLUS §4.6.2 (Super Admin assignable only by a Super Admin) as an
  after() check
- password: ADD `['required', 'string', Password::default(), 'confirmed']`
  (the exact `PasswordValidationRules` shape); EDIT
  `['nullable', 'string', Password::default(), 'confirmed']` — blank =
  keep (D8; `confirmed` enforces "if entered, confirm must match"; verify
  a present-but-empty confirmation with a blank password does not trip)
- authorize(): users.create / users.edit + §4.6 target checks
- `clientRuleExclusions()` per the CR-12/CR-6 reasoning (uniques, the
  password rule object, role after-check)
- Lang: `lang/en/users.php` — fields/placeholders/sections/columns/
  status/confirm strings/validation, wireframe wording verbatim; plus
  humanized permission labels for §3.3's card (a `permissions.*` block).

## §6 Routes & permissions

- `routes/modules/users.php`: `Route::resource('users',
  UserController::class)` (all seven) + the status route, names
  `users.*`. NOTE the model is `App\Models\User` — route binding
  `{user}`; excluded trashed by default (correct — no screen shows a
  trashed user).
- Permissions `users.view/create/edit/delete` → 28 + 4 = **32**. Admin
  gets all via `Admin => PERMISSIONS`. Sales/Operations: none.
  RolePermissionSeedTest count updated minimally.
- DataTable ajax gate `ajax() && wantsJson()` (CR-7).

## §7 Pinned-test updates (minimal, honest comments)

- AppShellTest: the `not->toContain(__('nav.user_management'))` line in
  the shared tail moves to the CR-12 absent-labels dataset dimension —
  every existing row adds 'User Management' to its `$absent` array, and a
  new `'users' => ['users.view', 'User Management', 'users.index',
  ['Booking Types', 'Customer Doc Types', 'Broker Management']]` row
  proves the presence side. `role_management` absence stays in the tail
  (still preview-only). The 14-label order test is UNCHANGED (Super Admin
  still sees the same labels — verify).
- RolePermissionSeedTest: 28 → 32 + any pinned role-grant lists.
- Any test creating users via factory is unaffected (new columns
  nullable; factory may gain code/first/last defaults ONLY if a test
  breaks without them — prefer leaving the factory alone unless the
  suite forces it; if the factory must change, keep `name` coherent).
- DemoSeedTest / AdminUserSeeder: the seeded admin must end up with code
  + split name after `migrate:fresh --seed` (via the backfill migration
  order or seeder logic — the CR-12 deviation-1 lesson: check the
  fresh-install path explicitly).

## §8 Decisions

- **D1 (user)** Status = active/inactive via action button (list row +
  edit footer + view header), default active on create, confirm() BOTH
  directions, inactive cannot log in (login-time + mid-session). Locked
  dropped.
- **D2 (BA)** The wireframe's "Reset Password" buttons (edit + view) are
  replaced by the client-mandated optional password fields on edit — one
  mechanism, not two. The forgot-password flow remains for self-service.
- **D3 (user/client)** Role select = real spatie roles; Super Admin
  already provides "full system access" via Gate::before — nothing new
  seeded. Single role per user (`syncRoles([$role])`), matching the
  wireframe's single select.
- **D4 (user)** View/edit show real data only: Failed Login Attempts,
  Two-Factor Auth and Revenue Generated dropped; Total Logins from the
  activity log; Last Login/Last Password Change from new columns.
- **D5 (user)** Assigned Permissions card = the user's EFFECTIVE
  permissions via `$user->can()` over the seeded permission list.
- **D6 (user)** Escalation guard: Super Admin accounts/role only
  manageable by Super Admins; no self-deactivate/self-delete; last
  Super Admin protected.
- **D7 (user)** Soft delete with confirm; historical names via
  withTrashed display relations (§2.4).
- **D8 (client)** Password required on add; optional-but-confirmed on
  edit.
- **D9 (BA)** 4th stat card = Deleted (trashed count) replacing Locked;
  list default sort = code ASC (the wireframe's own order).
- **D10 (BA)** `name` stays canonical (server-maintained from
  first/last); first_name/last_name are the form's fields.
- **D11 (BA)** Activity-log hygiene: password NEVER logged
  (`logExcept`), role/status changes get explicit `->event()` rows.

## §9 Definition of Done

`C:\wamp64\bin\php\php8.3.28\php.exe` for everything (PATH php is
8.2.29); FOREGROUND runs:

1. `php artisan migrate:fresh --seed` green; RolePermissionSeeder
   idempotent (2nd run keeps 32); seeded admin has code + name intact and
   can log in.
2. Full `php artisan test` green — baseline 1101 tests / 4 pre-existing
   skips; only §7's pinned updates may move counts (state any move
   explicitly). NO new test files.
3. `composer analyse` (larastan level 6) = 0.
4. `vendor/bin/pint --test --dirty` clean (never touch
   `tests/Feature/Projects/ProjectCrudTest.php`).
5. Commit NOTHING; run no git write commands.
6. Report: files created/modified, every deviation flagged with
   reasoning, real gate output.

---

# CR-13.1 — User Management fix round (client instructions, 2026-09-07)

Client-mandated changes to the shipped CR-13 module. Same overrides as
CR-13 §0 (no new test files, no policies). Verbatim asks, restated:

- **B1 — Client-side validation for Email, Password, Confirm Password,
  Role.** ROOT CAUSE (verified): `StoreUserRequest::clientRuleExclusions()`
  = `['email','role','password']` — each excluded for a good reason
  (unique/exists render as forbidden REMOTE rules; the Password rule
  object cannot render) — but the exclusion drops whole FIELDS, so all
  three plus `password_confirmation` (whose `confirmed` rides on
  `password`) have ZERO client rules today. Fix = the CR-12.1 A3
  mechanism: re-arm the safe subset client-side.
  * **Promote the rules half of `nrems-broker-form.js` (armRules/
    armRulesOn/SAFE_RULES) into a shared `public/assets/js/
    nrems-client-rules.js`**, loaded by the broker twins (alongside the
    slimmed broker file) AND the user twins. Same properties: closed
    SAFE_RULES map (never `remote`), messages read off `data-validate-
    message-*` attributes Blade renders from the lang layer, validator-
    existence guarded, ES5, no-op without markers. Extend SAFE_RULES
    with **`equalTo`** whose target comes from a `data-validate-equal-to`
    attribute (a selector Blade builds from `form_field_id('password')` —
    our markup, not user input; still guard: use it only when the
    attribute is present and non-empty).
  * **Create twin** arms: email `required`+`email`; password `required`;
    password_confirmation `required`+`equalTo` (#password); role
    `required`.
  * **Edit twin** arms: email `required`+`email`; role `required`;
    password_confirmation `equalTo` only (password is optional on edit —
    an empty pair is equal and passes, a filled password with a blank or
    different confirm fails ON THE CONFIRM FIELD, mirroring B2).
  * The exclusions list itself DOES NOT SHRINK (taking any of the three
    off re-renders a remote rule or a degraded object rule;
    ProjectJsValidationTest asserts no `laravelValidationRemote`).
  * Implementer MAY instead let proengsoft render `password_confirmation`
    rules natively if that provably works without a remote rule — but the
    acceptance criteria below decide, and whatever ships must be verified
    by rendering both twins and asserting the armed rules exist and no
    remote rule does.
- **B2 — Mismatch error must appear under Confirm Password (server
  too).** Today `'confirmed'` sits ON `password`, so the server keys the
  mismatch error to the password field. Change the rule shape in
  StoreUserRequest::passwordRules(): drop `confirmed`; add
  `'password_confirmation' => ['required', 'same:password']` (create).
  UpdateUserRequest overrides to password `nullable` — its confirmation
  becomes `['required_with:password', 'same:password']` (edit; blank
  pair stays valid = "keep current password"). `same` keys the error to
  password_confirmation, which the form group under that input renders.
  VERIFY: `password_confirmation` still never reaches the model or the
  activity log (fillable filtering; UserService writes named fields) —
  state where the guarantee lives. Fortify's reset flow is untouched.
- **B3 — Listing: remove the User ID and Department columns.** In
  UsersDataTable: drop both from `getColumns()`, the whitelist, the
  projection (`users.code`, `users.department`) and their `editColumn`s.
  Seven columns remain: Full Name, Email, Phone, Role, Last Login,
  Status, Actions. Column-index prose in docblocks (e.g. the
  `columns[7]` status example) must be re-checked. `code`/`department`
  stay everywhere else (show page, form, service, seeder) — this is a
  LISTING change only.
- **B4 — Default order: the CR-12.1 A6 lesson applies verbatim.** The
  current `->orderBy(0,'asc')` sorted on `code` (oldest first, USR-001
  at top). Column 0 becomes Full Name. Ship the PROVEN pattern:
  `'order' => []` in parameters + a conditional
  `applyDefaultOrder()` tap ordering `users.id` ASC (oldest first —
  note the direction differs from brokers) that stands down when
  yajra's `Request::isOrderable()` is true. Do NOT apply an
  unconditional query order — yajra APPENDS the requested order after
  it and a unique-key order first kills every header sort. Verify
  header sorts still reorder rows and a hand-rolled draw naming a
  removed column answers 200.
- **B5 — Remove the Department filter.** Delete its `<select>` +
  visually-hidden label from users/index.blade.php, drop
  `users_filter_department` from the JS selects list, remove the
  `filter_department` arm from `applyFilters()` (and the now-unused
  UserDepartment import IF nothing else in the class needs it). Role
  and Status filters stay. Stat cards STAY (not in this CR's scope).
- **B6 — lang/en/users.php**: remove `columns.code`,
  `columns.department`, `filters.all_departments`. Every other
  `department` key stays (the form and show page still draw it).
- **B7 — Docblock hygiene** (the A8 rule): every claim these changes
  falsify — UsersDataTable's "Nine columns, exactly the wireframe's
  nine", the search-box coverage paragraph (code leaves it), the
  department-whitelist paragraph, index.blade's filter/docblock prose,
  StoreUserRequest's exclusions + password docblocks — must be
  rewritten to tell the truth. No comment may describe removed code.

Acceptance criteria (render-and-assert, throwaway verification only —
delete any scratch file and prove it gone): create twin shows live
client errors for empty email/password/confirm/role and for a
confirm/password mismatch (on the confirm field); edit twin for empty
email/role and a mismatch; no `laravelValidationRemote` on either twin;
server POST with mismatched pair returns the error keyed
`password_confirmation`; listing draws 7 columns oldest-first, header
sorts work both directions, department filter param is ignored.

Definition of Done: CR-13 §8 unchanged — full suite baseline **1102
tests / 4 pre-existing skips** (broker CR-12.1 is in the tree,
uncommitted; do not touch broker files except the twins' script include
+ comment if the shared-JS promotion requires it), phpstan level 6 = 0,
pint --dirty clean, run everything with
C:\wamp64\bin\php\php8.3.28\php.exe FOREGROUND, commit nothing. Pinned
tests: search tests/ for the removed lang keys/columns before assuming
none asserts them; any edit quoted in the report.
