Skip to content

feat(dot-roles): Tools tab in Angular, roles service consolidation, and edit-gate fixes - #37260

Merged
hmoreras merged 16 commits into
mainfrom
issue-36930-follow-up
Aug 31, 2026
Merged

feat(dot-roles): Tools tab in Angular, roles service consolidation, and edit-gate fixes#37260
hmoreras merged 16 commits into
mainfrom
issue-36930-follow-up

Conversation

@hmoreras

@hmoreras hmoreras commented Aug 28, 2026

Copy link
Copy Markdown
Member

Proposed Changes

role-portlet-progress.mp4

Follow-up work on the Roles (Beta) Angular portlet.

Consume the backends that landedGET /v1/roles/{roleId}/users (#37070) replaces the previous pair of member-loading calls. The old id-based /rolehierarchyanduserroles fallback returned Role objects rather than users, which is why the Users tab rendered an empty EMAIL column; that column now populates. childCount and userCount (#37071) drive leaf-vs-chevron detection on first paint — no more chevrons that expand into nothing — and unblock the per-row user-count badge the design called for.

Worth flagging: the code's comments predicted /roles/{id}/users would resolve inheritance server-side and collapse the whole flow into one call. It shipped as a direct grants only resource by design, so the ancestor-chain fan-out stays and is now documented as permanent rather than as a stopgap. For the same reason the members table keeps client-side paging — a server page of one ancestor is not a page of the merged union, so [lazy]="true" would be incorrect, not pending.

Migrate the Tools tab to Angular — replaces the Dojo iframe. Scope is deliberately narrower than the JSP it replaces: it lists tool groups and toggles which ones the role gets. Creating, editing and deleting tool groups is not here — those have no v1 endpoints (only RoleAjax DWR) and belong to a Tools portlet of their own. Inherited grants render checked but locked; echoing one back into the full-replace POST would silently promote it to a direct grant. The iframe component and its two JSPs are deleted rather than left behind.

Consolidate the roles surface into DotRolesService — three libs had grown their own implementation of the same /api/v1/roles/** surface, and three TypeScript models of the same backend RoleView. Reads, writes and models now live in one place; method names drop the redundant role (createRolecreate). get and search keep their legacy names — the workflow assign components inject this service and those paths are untouched. The full-hierarchy walk did not move into the service: it is one way of composing two endpoints for one UI, so it lives as a pure, HTTP-free function in dot-users, and it now prunes on childCount — most roles below the first level are leaves, which removes the large majority of the request burst the Roles tab produced on open.

Fix edit gates that were stricter than the backend — the Tools tab greyed itself out for CMS Administrator while the legacy portlet allows the edit. RoleHelper applies a different contract per operation: update/delete checks isSystem() || isLocked(), user grants check isEditUsers() alone, and saving layouts places no restriction on the target role at all.

Review fixes

A multi-agent review and hands-on QA surfaced defects that are fixed in this PR:

  • Two cross-role races. saveToolGroups and the user grant/remove flows captured the role id before their await and reconciled with it afterwards, so a role switch mid-flight repainted the new role's tab with the old role's data. For tool groups this was worse than cosmetic: the tab derives its next POST payload from toolGroups, so one role's grants could be written onto another. Guarded at both ends, with regression tests verified by neutering each guard and confirming they go red.
  • "Could not verify" was being rendered as "not granted". A per-ancestor catchError returned an empty set on failure, so a transient error showed an inherited tool group unchecked — and an admin trusting the grid would create a redundant direct grant. Failures now surface instead of being absorbed.
  • childCount regressions. Local tree mutations left it stale, so a parent that just gained its first child rendered as a leaf and the new role was unreachable. And POST /v1/roles answers with Role.toMap(), which carries no childCount, so a freshly created role drew as a folder.
  • The tree user-count badge never refreshed after a grant or revoke. It now syncs from the direct-grant count.
  • dot-users-list.store.spec.ts still mocked a method deleted in this PR and never mocked DotRolesService, so the roles column ran against the real root service and was effectively untested.

Add / Edit role dialogs

Required markers use the dotFieldRequired directive (which is what makes them red) instead of hand-written text, and dot-field-validation-message is wired in — there was no validation feedback at all. The dialog title moves from an h2 in the body into the dialog header.

Parent selection becomes a p-treeSelect: indentation alone gets ambiguous past two levels. It starts collapsed, hydrates a branch on expand (the backend sends two levels per request), and its filter runs the same deep search the roles tree uses, so a role in an unloaded branch is findable.

Two PrimeNG behaviours needed working around, both from one cause — the component keeps state inside the node objects it is given, and these options come from a computed that hands it new ones. Expansion is mutated onto node.expanded, so branches snapped shut the moment their children loaded. And Tree.getRootNode() returns its cached filteredNodes once the client filter has run, ignoring value, so server results never rendered. Both are handled explicitly, with the reasoning in the code — the second reaches one internal method, which is the most upgrade-fragile line in this PR.

Design review

Empty states now use the shared DotEmptyContainerComponent; the hand-rolled dashed card they replaced exists nowhere else in the product. shieldshield_person. The + on a tree row is primary with a pointer cursor. Untranslated portlet titles no longer print as raw i18n keys.

The user-count badge takes a real grey. text-color-secondary is a PrimeFlex-era class — PrimeFlex is not installed and tailwindcss-primeui does not provide it either, so it compiles to nothing and elements inherit the label's near-black. Verified against the built CSS rather than assumed. 104 more occurrences survive across 35 files and need their own pass.

Checklist

  • Tests
  • Translations
  • Security Implications Contemplated (add notes if applicable)

Additional Info

Verification — 36 projects linted, 35 tested, nx format:check clean, and the dotcms-ui production build green. The dot-roles suite is at 136 tests.

Security note. While tracing the tool-group endpoints, GET /api/v1/roles/layouts turned out to have no authentication gate — the only one of 16 endpoints in RoleResource without a WebResource.InitBuilder block. Confirmed at runtime: it returns 200 with the full tool-group catalog to an unauthenticated caller, while three sibling endpoints return 401 under identical conditions. It predates this work (introduced 2023-05-26, shipped since v23.06) and is filed separately as #37259. This PR does not change that behaviour, but it does put the endpoint on a more exercised path.

Standards note. ANGULAR_STANDARDS.md asks for Signal Forms on new forms, but the standard validation components are Reactive-Forms-bound — dotFieldRequired injects FormGroupDirective and dot-field-validation-message takes an AbstractControl. These dialogs stay on Reactive Forms; adopting Signal Forms would mean dropping both.

Follow-ups not included here

  • loadRootRoles(true)false is now possible thanks to childCount, and would lighten the initial payload considerably on installs with many roles. It changes lazy-load behaviour, so it deserves measuring on its own.
  • onNodeExpand marks a node fetched before the load resolves, so a failed lazy-load never retries.
  • Creating a role under a parent that is not currently loaded splices into a node that is not there — the role is created but does not appear until a reload.
  • The text-color-secondary sweep described above.

🤖 Generated with Claude Code

This PR fixes: #36930

This PR fixes: #36930

hmoreras and others added 6 commits August 27, 2026 13:35
Wires the Beta portlet to the two backend features that landed on main and
that the code was explicitly waiting on.

#37070 — GET /v1/roles/{roleId}/users

Replaces the pair of member-loading calls: /v1/users/filter?roleKey=X (fast
but unusable on roles created without a roleKey) and the id-based
/rolehierarchyanduserroles fallback (worked by id but returned Role objects,
so the Users tab rendered an empty EMAIL column). The new endpoint is keyed
on roleId AND returns the standard user serialization, so both problems go
away and every member row now carries an email.

The endpoint shipped as a direct-grants-only resource: it deliberately does
not resolve inheritance or denormalize granted-from metadata. The code's
comments predicted the opposite ("a single call replacing this whole flow"),
so they are corrected here. The ancestor-chain fan-out that composes
effective membership stays, and is now documented as permanent by design
rather than as a stopgap. For the same reason the members table keeps
client-side paging: a server page of one ancestor is not a page of the
merged union, so [lazy]="true" would be incorrect, not pending.

Removes the now-dead roleKey branching along the whole path, plus the
toRoleMemberResults adapter and the RoleHierarchyEntry model. None of these
were exported from the lib, so the change is contained to the portlet.

#37071 — childCount / userCount on RoleView

Leaf detection now reads childCount instead of inferring from an empty
roleChildren array, so the chevron is right on first paint at every depth —
no more chevrons that expand into nothing at level 2+. Legacy search nodes
(/api/role/loadbyname) do not carry the field and fall back to the previous
heuristic.

userCount unblocks the per-row user-count badge the design called for, which
had been left out because no endpoint exposed the number.

Tests: 11 suites / 111 passing. Prod build, lint and format:check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three libs had grown their own implementation of the same
`/api/v1/roles/**` surface, and three TypeScript models of the same
backend `RoleView`. The duplication was accidental — the same two pieces
of backend knowledge (two-level hydration, `_search` returning
`SmallRoleView` with no parent) had been independently rediscovered and
re-documented in two of them.

One model

`DotRole` in `@dotcms/dotcms-models` is now the single role shape. Only
`id` and `name` are guaranteed, because the surface is served by three
serializers that each omit part of the shape — documented on the type so
consumers stop assuming `roleKey` is an identifier or that an absent
`childCount` means zero.

`DotRoleView` is deleted from `dot-users.service.ts`; `DotRoleNode` and
`DotRoleDetail` become aliases, keeping the names that read well at the
tree's call sites. The lowercase `dbfqn` / `fqn` that `dot-users`
declared never matched the wire (Jackson serializes `getDBFQN()`
all-caps) and were never read; the unified model keeps the real spelling.

One read surface

`DotRolesService` gains `getRoots`, `getById` and `getForUser` alongside
the existing `get` / `search`. Names drop the redundant `role` — the
service already says it. `dot-users` and the `dot-roles` portlet both
repoint at it; `DotRolesPortletService` keeps CRUD, the legacy
`loadbyname` search and the write adapters, since promoting destructive
operations to workspace-public API would freeze the wire shape while
those endpoints are still landing.

`get` / `search` / `processRolesResponse` are deliberately untouched.
The workflow assign components inject this service, so leaving those
paths byte-for-byte identical keeps that blast radius at compile-time
only.

Strategy is not an endpoint

The full-hierarchy walk does NOT move into the shared service. It is one
way of composing two endpoints, chosen for one UI: the Roles tab renders
a shuttle and needs every role up front, while the roles portlet renders
a tree and composes the same endpoints lazily per expansion. Neither
belongs to the service. It now lives as a pure function in
`dot-users/utils/dot-roles-hierarchy.utils.ts`, taking a `fetchChildren`
callback so it is unit-testable without HTTP.

Two behaviors changed while it moved:

- It only descends into nodes reporting `childCount > 0` (#37071). Most
  roles below the first level are leaves, so this prunes the large
  majority of the request burst the Roles tab produced on open. Nodes
  with an absent `childCount` are still visited, so an older serializer
  degrades to the exhaustive walk rather than silently truncating.
- The per-node `catchError(() => of({children: []}))` is gone. A partial
  hierarchy rendered as if it were complete is worse than a visible
  failure; the error now reaches the tab, which already routes it
  through DotHttpErrorManagerService.

Verified: 35 affected projects tested, 36 linted, format:check and the
dotcms-ui production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the legacy Dojo iframe with a real Angular tab. The endpoints it
needs already exist, so nothing was blocking this.

Scope is deliberately narrower than the JSP it replaces: the tab lists the
tool groups and toggles which ones the role gets. Creating, editing and
deleting tool groups is not here — those have no v1 endpoints (only RoleAjax
DWR) and belong to a Tools portlet of their own.

Composition

Three endpoints, none of them new:

- GET /v1/roles/layouts — the system-wide catalog. Despite the path this is
  not a per-role read, and it is the only source of `portletTitles` (the
  localized portlet names resolved server-side), so it drives every row and
  the Included Tools column.
- GET /v1/roles/{roleId}/layouts — direct grants. The backend resolves this
  as `from LayoutsRoles where role_id = ?` with no hierarchy walk, so
  effective grants are composed client-side by walking the ancestor chain —
  the same shape the Users tab already uses, reusing collectAncestorChain.
  Each row is tagged with the closest ancestor that grants it, which is what
  the Granted From chip names.
- POST /v1/roles/layouts — a full replace, not an append: the backend diffs
  the payload against the role's current grants and drops the difference. So
  one toggle syncs the whole grid in a single call, and the payload always
  carries the complete direct-grant set. Inherited grants are excluded on
  purpose — echoing one back would silently promote it to a direct grant, so
  inherited rows render checked but locked, revocable only on the ancestor
  named in their chip.

All three live in the shared DotRolesService: they are reads and writes of
the roles domain, and splitting them by consumer count or by destructiveness
put one domain in two files behind a rule invisible to anyone reading either.
DotRoleToolGroup moves to @dotcms/dotcms-models alongside DotRole; the row
projection (granted + grantedFrom) stays in the portlet, since it is
presentation.

Header count

`toolGroupCount` counts effectively-granted groups and is loaded on role
selection rather than when the tab opens — the header shows it on every tab,
so deferring it would leave it reading 0 until the admin clicked Tools.

Removals

The tools iframe component and its two JSPs (view_role_tools_wrapper.jsp,
view_role_tools_inc.jsp) are deleted rather than left behind. The shared
view_role_iframe_stubs_inc.jsp stays — Permissions still wraps an iframe —
and its comments are updated so they stop pointing at deleted files.

Tests: 11 suites / 122 passing. 36 projects linted, 35 tested, format:check
and the dotcms-ui production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finishes what the earlier consolidation started. Splitting one domain across
two services by consumer count and by destructiveness put related endpoints
in different files behind a rule invisible to anyone reading either one. The
rule now is simply: roles endpoints live in the roles service.

Moved and renamed — the service already says "roles", so the methods stop
repeating it:

  searchRoles          -> searchTree   (search() is taken by _search)
  loadRoleMembers      -> getUsers
  createRole           -> create
  updateRole           -> update
  deleteRole           -> delete
  grantUserToRole      -> grantUser
  removeUsersFromRole  -> removeUsers
  reparentRole         -> reparent

The tool-group trio is normalized the same way now that every method is
role-scoped: getAllToolGroups() for the catalog, getToolGroups(roleId) and
saveToolGroups(roleId, ids) for the role's own.

loadRootRoles / loadRoleById were pure delegations to getRoots / getById and
are gone; the store calls the shared service directly. `get` and `search`
keep their legacy names — the workflow assign components inject this service
and those paths stay untouched.

DotRoleFormValue moves to @dotcms/dotcms-models (data-access cannot import
from a portlet) and the wire adapters move alongside the service, with their
spec, so the mapping logic keeps its coverage.

What stays behind

DotRolesPortletService is down to one method: searchUsers. /v1/users/filter
is a users endpoint, not a roles one, so it does not belong in DotRolesService
either. It stays until data-access has a shared users service to host it —
dot-users has one, but portlet-to-portlet imports are not allowed.

Tools tab: no flicker on toggle

Every checkbox click ran a reload that set status to LOADING, so the whole
table swapped for the skeleton. The toggle is now painted optimistically and
the post-save reconcile runs silently, leaving the table in its loaded state
throughout. A failed save rolls the patch back rather than leaving the grid
showing something the backend rejected.

Un-checking a group an ancestor also grants is the one case the optimistic
patch cannot resolve locally — a row only keeps its closest source — so the
silent reconcile restores the inherited chip a moment later.

Tree badge

Smaller person icon, larger count, and the secondary grey actually applies:
PrimeNG colors the node label with a more specific selector, so the badge was
inheriting its near-black.

Tests: 10 suites / 112 passing, including three that pin the no-flicker
behavior. 36 projects linted, 35 tested, format:check and the dotcms-ui
production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`text-xs` (12px) read too small next to the 14px count. `text-sm` puts the
icon back at the count's own size, and no scale step sits between them, so
the icon takes an explicit 13px — the same arbitrary-value-with-important
pattern other portlets already use for material symbols.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Tools tab greyed its checkboxes out and showed the cannot-edit notice for
CMS Administrator, while the legacy portlet lets you change that role's tool
groups and save. The Beta was stricter than both the backend and the screen it
replaces.

Cause: `canModifyRole` correctly gates on `system` / `locked`, and that shape
was copied to the per-domain gates. But `RoleHelper` applies a different
contract to each:

  update / delete role   isSystem() || isLocked()
  grant / remove users   !isEditUsers()                 — nothing else
  save layouts           no check on the target role at all

`POST /v1/roles/layouts` only requires the CALLER to hold the CMS Admin role,
which surfaces as a 403. CMS Administrator is a system role with editLayouts
true, so the legacy grid — which reads `currentRole.editLayouts` alone — enables
it and this tab did not.

canEditRoleLayouts now gates on `editLayouts` alone. That flag is kept even
though the backend ignores it, because it is the contract the legacy screen
honours and dropping it would be a behaviour change of its own.

canEditRoleUsers had the same defect and is fixed with it: user grants on CMS
Administrator were blocked for the same invented reason. Not reported yet, but
the same bug.

canEditRolePermissions is deleted rather than fixed — it had no consumers, the
Permissions tab is still an iframe.

Also, from design review on the tree:

- shield -> shield_person for leaf roles, in the tree and the detail header.
- The user-count badge takes `text-gray-400`. It had `text-color-secondary`,
  which is a PrimeFlex class — PrimeFlex is not installed, so it resolved to
  nothing and the badge inherited the node label's near-black. The dead class
  and a comment blaming PrimeNG specificity are gone with it. 104 more
  occurrences of that class survive across 35 files; they need their own pass.
- The badge icon returns to text-sm.

Tests: 116 passing, four of them pinning the corrected gates — including that a
system + locked role permits user and tool edits but still refuses update and
delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Aug 28, 2026
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @hmoreras's task in 3m 1s —— View job


Re-review — prior findings + fix commits

Checked the current head against the 9 prior findings and the three fix commits (33c0acac, 5570af29, 8281b3fa). Blocking findings 1–5 are resolved; 6–9 remain as non-blocking design/perf items (7 and 9 hold up as false positives on the current backend contract).

  • Read store + tree-utils + both dialogs on current HEAD
  • Recheck prior findings 1–9
  • Check the fix commits for newly introduced bugs
  • Post the review

Resolved

  • update with latest SVN #1 dot-roles.store.ts:879, 945createRole now captures selectionOnSubmit and updateRole gates selectedRole writes on stillSelected = selectedRoleId() === roleId. The cross-role guard is applied to both mutations.
  • Test Branch and Commit #2 dot-roles.tree-utils.ts:176-182collectAncestorChain returns [] (no fabricated stub) when the role isn't in the tree, and both loadMembers (:338) and loadToolGroups (:483) short-circuit to ERROR instead of firing a direct-grants-only fan-out.
  • Add .gitignore #3 dot-roles.store.ts:871-899 — verified against Role.toMap() (Role.java:213-228): the POST payload carries system, locked, editUsers, editPermissions, editLayouts, and parent. Seeding selectedRole from it (+ childCount: 0) leaves no edit-gate flag undefined, so the permissive ?? true fallbacks aren't hit. The concern is genuinely addressed, not just re-documented.
  • New Issue #4 dot-roles.store.ts:1104-1109 — the optimistic members prune now runs inside the selectedRoleId() === role.id guard, so a mid-flight role switch can't strip users from another role's slice.
  • wezell's issue #5 dot-roles-add.component.ts:183-231, dot-roles-edit.component.ts:223-267 — both #runSearch paths wrap the await in try/finally (token-guarded) and explicitly $searching.set(false) on the sub-3-char short-circuit. The stuck-spinner case is closed in both dialogs.

Existing (non-blocking)

  • 🟡 Medium: dot-roles.store.ts:408, 546 (test rwqrwqrwqr wq #6) — a single failed ancestor fetch still flips the whole slice to ERROR, hiding the ancestors that did resolve behind the generic error state. This is now a deliberate tradeoff (unverifiedERROR, documented at :383-405 / :537-542) chosen over rendering "could not verify" as "not granted". Defensible, but a PARTIAL state that renders the merged rows with an inline "some ancestors couldn't be verified" warning would be strictly better UX. Not a regression.
  • 🟡 Medium: dot-roles.store.ts:935-936 (testing assign #7 — author flagged false positive; concur) — nextParentId = updated.parent && updated.parent !== updated.id ? … : null only misfires if the PUT response omits parent. Role.toMap() (Role.java:224) and the RoleView serializer always emit it, and root is encoded as parent === id, so there's no current path that detaches-to-root by mistake. Purely a future-serializer hardening note; a parent == null → keep-in-place fail-closed would make the contract assumption explicit but isn't required.
  • 🟡 Medium: dot-roles-add.component.ts:199,222 + dot-roles-edit.component.ts:237,258 (this is added #8) — the treeViewChild._filter() private-API workaround plus the #expandedKeys/onNodeExpand/#runSearch/#toTreeNodes triad are ~90% duplicated across the two dialogs. The PR itself calls this the most upgrade-fragile line, and it exists twice — the next PrimeNG bump breaks both. Cleanup, not a bug. Extract shared picker →
  • 🟡 Medium: dot-roles.store.ts:704, 218 (this is added #9 — author flagged false positive; partly concur) — the toolGroupsSaved$ debounce coalesces only the toast; each checkbox click still fires a full-replace POST + catalog GET + N ancestor GETs. Acceptable for occasional toggles, but a burst on a role with several ancestors is a real request amplification, and out-of-order silent reconciles under jitter can restamp state from a stale union (only the last resolve is guarded, not the earlier stale ones). Worth a follow-up if the Tools tab sees heavy use; not a correctness blocker today.

Fix commits — no new issues

  • dot-roles-tree.component.ts:151-205 — the reveal effect keys on the ancestor path string (not the tree object), so a userCount patch on member-load doesn't re-open collapsed branches, and the #revealedPath guard makes it act only on an actual move. Reads correctly.
  • dot-roles-tree.component.ts:225-247 — the lostChildren = loadedChildren === 0 && (childCount ?? 0) > 0 gate correctly re-opens the lazy-load for a branch that lost its hydrated children on reparent. Sound.
  • dot-roles.tree-utils.ts:239-250 + dot-roles.store.ts:952-962dedupeRolesById is now applied once before either the same-parent (patchNodeInPlace) or reparent branch consumes roleChildren/childCount, so the multiplied-children response (PUT /v1/roles/{roleId} returns the role's children duplicated 4x, with childCount inflated to match #37303) no longer inflates the tree count even on a plain rename.

No new correctness or security issues found in this pass. Findings 1–5 (the pre-merge blockers) are all resolved.
· branch issue-36930-follow-up

Correctness

Two cross-role races, both reachable with ordinary navigation and normal
latency. `saveToolGroups` and `grantUserToRole`/`removeUsersFromRole` capture
the role id before their await and reconcile with it afterwards; a role switch
in between meant a late response repainted the new role's tab with the old
role's data. Worse for tool groups: the tab computes its next POST payload from
whatever is in `toolGroups`, so one role's grants could be written onto another.
Guarded at both ends — the reconcile only fires while the role is still
selected, and the rxMethod sinks refuse to write for a role that is no longer
selected (the caller-side guard also stops switchMap from cancelling the new
role's legitimate load). `selectRole` now clears `toolGroupsSaving` too, which
was locking every checkbox on the role being switched TO.

Per-ancestor `catchError` in `loadToolGroups` and `loadMembers` turned "could
not verify" into "not granted". A transient failure showed an inherited tool
group unchecked; an admin trusting the grid would grant it again, creating a
redundant direct grant off a masked network error. Failures are now tracked and
surface as an error state instead of a confident wrong answer.

Making `childCount` authoritative for leaf detection regressed local tree
mutations: `appendChildToParent` and `removeNodeFromTree` left the count stale,
so a parent that just gained its first child rendered as a childless leaf and
the new role was unreachable. Both keep it in sync now.

`POST /v1/roles` answers with `Role.toMap()`, not a `RoleView`, so it carries no
`childCount` — a freshly created role drew as a folder. It is normalised to 0,
which is true by construction. (`PUT` returns a RoleView and needed nothing.)

The tree's user-count badge never refreshed after a grant or revoke. It syncs
from the direct-grant count when members load — direct only, matching what the
backend puts in that field, and skipped when a check failed so a stale number is
not replaced by a wrong one.

`dot-users-list.store.spec.ts` still mocked `getUserRoles`, deleted from
`DotUsersService` in this PR, and never mocked `DotRolesService` — so the roles
column was exercised against the real root service and was effectively untested.

Add / Edit role dialogs

The required marker was hand-written text; it now uses the `dotFieldRequired`
directive, which is what makes it red. There was no validation message at all —
`dot-field-validation-message` is wired in. The dialog title moves from an h2 in
the body to the dialog header.

Parent is a `p-treeSelect` instead of a flat indented list: indentation alone
gets ambiguous past two levels. It starts collapsed, hydrates a branch on expand
(the backend sends two levels), and its filter runs the same deep search the
roles tree uses, so a role in an unloaded branch is findable.

Two PrimeNG behaviours had to be worked around, both from the same cause — the
component keeps state inside the node objects, and our options come from a
computed that hands it new ones. Expansion is mutated onto `node.expanded`, so
branches snapped shut the moment their children loaded; expanded keys are
tracked in a signal and re-applied. And `Tree.getRootNode()` returns its cached
`filteredNodes` once the client filter has run, ignoring `value`, so server
results never rendered until the filter is re-applied over the new options.

Design review

Empty states use the shared `DotEmptyContainerComponent` — the previous
hand-rolled dashed card exists nowhere else in the product. `shield` becomes
`shield_person`. The `+` on a tree row is primary with a pointer cursor. The
user-count badge takes a real grey: `text-color-secondary` is a PrimeFlex-era
class, PrimeFlex is not installed and tailwindcss-primeui does not provide it,
so it compiled to nothing and the badge inherited the label's near-black
(verified against the built CSS, not assumed). Granted From has a minimum width.
Untranslated portlet titles no longer print as raw i18n keys.

Note on standards: ANGULAR_STANDARDS asks for Signal Forms on new forms, but
`dotFieldRequired` injects `FormGroupDirective` and
`dot-field-validation-message` takes an `AbstractControl` — the standard
validation components are Reactive-Forms-bound. These dialogs stay Reactive.

Tests: 136 passing. The race and badge regressions were verified by neutering
each fix and confirming the new tests go red. 36 projects linted, 35 tested,
format:check and the dotcms-ui production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hmoreras
hmoreras marked this pull request as ready for review August 28, 2026 16:41
hmoreras and others added 2 commits August 28, 2026 10:42
The previous commit claimed to guard both ends of the cross-role races. It
only guarded the sink. The reconcile dispatches in saveToolGroups,
grantUserToRole and removeUsersFromRole still fired with the role id they
captured before their await, and that incomplete fix introduced a worse bug
than the one it closed: a stale dispatch enters the SHARED rxMethod, whose
switchMap cancels the current role's in-flight load, and the sink guard then
discards the stale result — so nothing ever settles that role's status and its
tab sits on the loading skeleton indefinitely. Guarded at the caller now, with
the sink guard kept as defence in depth.

Two more cross-role leaks from the same review:

- `saveToolGroups`'s rollback restored its `previous` snapshot without checking
  identity, so a failed save on role A could overwrite role B's grid — and the
  next save would persist A's rows as B's.
- `toolGroupsSaving` was cleared unconditionally on completion, so a finishing
  save could unlock a grid whose own save was still running.

Silent reparent to root

The Edit dialog resolved the role's current parent to a tree node and set the
picker only `if (node)`. A role reached through the page's search can have its
parent chain missing from the cached tree, so the picker stayed empty and Save
sent `parentRoleId: null` — moving the role to root on a dialog the admin only
opened to rename it. The Add dialog already had this fallback; Edit now does
too.

Cycle exclusion bypassable through search

The Edit picker built its exclusion set from the cached tree but rendered from
`searchResults ?? cached`. Search results come from a different endpoint and
carry ancestor paths the lazy tree never loaded, so a real descendant could
appear as a selectable parent — defeating the guard precisely on the path most
likely to surface deep roles. Exclusion is now collected from both datasets.

Badge sync missed the rendered tree

While a search is active the tree renders `searchResults`, not `roles`. The
user-count patch only touched `roles`, so the fix silently did nothing on the
common path of finding a role by typing. It patches both now.

createRole's fallback was a no-op

The "parent not in the loaded tree" branch fetched the parent and called
`patchNodeChildren`, which rewrites a node it must first find — in exactly the
case this branch exists for, it matched nothing. The role was created and
selected in the detail pane, so it read as success while never appearing in the
tree. It reloads the roots instead: heavier than a scoped patch, but coherent.

Parent picker search

Added a staleness token so a slower earlier query cannot overwrite a newer
one's results — the debounce gates when a search starts, not what order
responses arrive, and these are plain promises with no switchMap to cancel the
loser. Backspacing below the 3-character threshold now also resets PrimeNG's
inner filter; reverting the options alone left the previous search's rows on
screen, since `Tree.getRootNode()` keeps serving its cached `filteredNodes`.

Tests

A mock seeded with `mockResolvedValue` (not `Once`) leaked its implementation
into every later test in the suite — `mockClear` resets history, not behaviour.
And the "leaves the badge alone on failure" test captured an unset baseline, so
it only proved we do not write `undefined` on a first failed load rather than
the case it names: resetting an already-good count from a partial answer. Both
verified by an independent test reviewer, which also confirmed the race and
badge regression tests do fail without their guards.

Lib cleanup

`standalone: true` and `changeDetection: ChangeDetectionStrategy.OnPush` are
removed from all 9 components in the portlet — both are Angular 22 defaults and
ANGULAR_STANDARDS asks for neither to be set. No component used
`ChangeDetectionStrategy.Eager`, which the same standard says to leave alone.

Tests: 136 passing. Lint, format:check and the dotcms-ui production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hmoreras
hmoreras requested review from AP2300 and oidacra August 28, 2026 17:00
Four changes, all about the portlet feeling settled instead of twitchy.

Tool-group saves now raise the standard toast, debounced so ticking a
handful of boxes in a row yields one notification rather than a stack.

Creating a child under a collapsed parent reveals it: the tree expands
the whole ancestor chain (the parent may itself be collapsed) and fetches
the parent's children when they were never loaded — otherwise the branch
would hold only the new role and read as though its siblings had been
deleted. Selection was already handled by the store.

The checkbox blink after a tool-group save was PrimeNG's default
`rowTrackBy`, which is object identity. The reconcile rebuilds every row
object, so Angular tore down and re-created each <tr> and with it the
p-checkbox. Tracking by id lets it patch the rows in place.

The members table blinked for a different reason a level up: loadMembers
flipped to LOADING unconditionally, and the template renders the skeleton
on LOADING, so a post-grant refetch swapped the entire table out and back
— nothing rowTrackBy can reach, since it is a different @if branch. It
now takes the same `silent` flag loadToolGroups already had, used by both
the grant and the remove paths. Selecting a role stays non-silent: there
are no rows on screen yet, so the skeleton is right there.

Also gave the Granted From tag a fixed-height slot. It renders only on
granted rows, so without a reserved line box the row grew the instant a
checkbox was ticked.

Every fix has a regression test, each verified to go red when the fix is
reverted.

@AP2300 AP2300 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review — 9 findings, ranked most-severe first. Correctness bugs first, cleanup/altitude at the end. The recurring theme is that the cross-role-race guard the PR added for saveToolGroups isn't lifted to a shared helper and reapplied uniformly across mutations.

Findings referenced against the current head (d2e386b).


1. updateRole / createRole skip the cross-role guard the PR added for saves

dot-roles.store.ts:837-840, :881-891

Both write selectedRole / selectedRoleId unconditionally after the await. saveToolGroups and the user grant/remove flows added selectedRoleId() === roleId guards for exactly this reason.

Scenario: admin edits role A and saves, then clicks role B before the PUT resolves. selectRole clears state and loads B. PUT for A resolves after: selectedRole is overwritten with A while B is highlighted; the header, canModifyRole computeds, and the Users/Tools tabs all now describe A. A subsequent save on that panel PUTs against role A.

Consider extracting a withSelectedRoleScope((roleId) => …) helper (or the switchMap-keyed-by-roleId pattern in a shared feature slice) so every mutation acquires the guard by construction.


2. collectAncestorChain fabricates a parent-less stub → silent under-reporting of inherited grants

dot-roles.tree-utils.ts:163-179

When the picked role isn't in the merged tree, findRoleInTree(...) ?? { id, name: id } produces a stub with no parent; the loop exits after zero iterations and the fan-out shrinks to a single direct-grants request.

Scenario: a search-selected role whose branch hasn't been merged into roles / searchResults, or a race with a roles: [] reset (post-createRole root reload). loadMembers / loadToolGroups fire only GET /v1/roles/{targetId}/{users,tools}, silently omitting every ancestor's grants. Users tab renders an incomplete member roster and the Tools tab misses inherited grants — admin trusts the UI and re-creates a redundant direct grant. No error surfaced.

If the caller doesn't know the parent, this should fail loudly (or fall back to a getById) rather than short-circuit.


3. createRole seeds selectedRole with the raw POST payload — edit-gate flags are absent

dot-roles.store.ts:797-841

The comment on L799 says POST /v1/roles returns Role.toMap(), not a RoleView; the comment on L833 then claims the response is "already a hydrated RoleView" and seeds selectedRole directly. The type is asserted with a cast + childCount: 0 spread — TS doesn't catch it. system, locked, editUsers, editPermissions, editLayouts are absent, and downstream computeds fall back to permissive defaults (?? true) until the next page reload.

Scenario: admin creates a role whose contract would forbid user grants (editUsers === false per template). The just-created payload lacks the flag; the Users tab renders enabled; adds are either 403'd (bad UX) or, if the BE is lenient on fresh roles, silently promoted.

Two consistent options: (a) drop the "seed from POST" optimization and fire a follow-up GET; (b) have createRole echo a hydrated RoleView on the BE (worth a Java-side follow-up in either case, since PUT already does).


4. removeUsersFromRole runs the optimistic patchState before the cross-role guard

[dot-role-users-tab.component.ts grant/remove path → dot-roles.store.ts around L1006]

The guard only gates the follow-up loadMembers; the optimistic filter has already written into members, which by then may be a different role's slice.

Scenario: admin bulk-removes u1,u2 from role A, then clicks role B (inherits u1) before the DELETE resolves. selectRole flips the pane to B; the optimistic filter strips u1 from members — now B's slice — and the guarded reload skips because selectedRoleId !== A. B's member table wrongly shows u1 missing until the admin re-selects B.

Move the optimistic write behind the same guard, or key it against a captured roleId snapshot rather than the current members.


5. $searching never resets on stale-token drop or the <3-char short-circuit

dot-roles-add.component.ts:183-216, [dot-roles-edit.component.ts #runSearch]

$searching.set(true) is set right before await; on token !== #searchToken the function returns without $searching.set(false). Same shape in the edit dialog.

Scenario: admin types "abc" then a fourth char before the promise resolves; #searchToken bumps, old promise resolves into the guard-return branch, $searching stays true for the rest of the dialog session — the parent-picker looks permanently busy.

try/finally { $searching.set(false); } around the await would fix both.


6. Partial fan-out failure hides successful ancestor rows behind a generic ERROR

[dot-roles.store.ts around L397-L401 in loadMembers / loadToolGroups result mapping]

A single ancestor request failing flips membersStatus (and the tool-groups equivalent) to 'ERROR' even after the other ancestors' rows are already merged into state — the template hides the partial table behind a generic error banner while httpErrorManager fires a toast per failed request.

Scenario: one of five inherited role fetches returns a transient 5xx. Four ancestors' users are in members; template renders "Could not load" instead of them. Admin retries by re-selecting, fan-out repeats and can hit the same intermittent failure.

Introduce a 'PARTIAL' status (or a verified: boolean on the slice) so the table renders alongside an inline "some ancestors couldn't be verified" warning.


7. updateRole reparent branch mistakes a missing parent in the response for detach-to-root

dot-roles.store.ts:876-891

nextParentId = updated.parent && updated.parent !== updated.id ? updated.parent : null. If the PUT response ever omits parent (defensive undefined, partial view, future serializer refactor), previousParentId === nextParentId is false and the code splices the node out of its parent and appends it as a new root. childCount on the true parent isn't decremented, so the tree also shows the wrong count until reload.

Contract-wise the BE encodes root as parent === role.id; parent === undefined isn't in that contract. If it arrives, fail closed (keep the node where it was) rather than silently reparenting.


8. _filter private-API workaround + treeSelect state duplicated across Add and Edit

dot-roles-add.component.ts:194,215, [dot-roles-edit.component.ts equivalent]

The PR description calls out this line as "the most upgrade-fragile line in this PR" — and it exists twice. The surrounding #expandedKeys / onNodeExpand / onNodeCollapse / #searchToken / #runSearch / #toTreeNodes triad is ~90% duplicated between the two dialogs.

Cost: the next PrimeNG bump that renames _filter or restructures treeViewChild breaks both dialogs; fixes will drift over time as one gets updates the other doesn't.

Extract a shared dot-role-parent-picker (or a directive that owns the workaround) so the fragile access lives behind one seam. The two dialogs then differ only in their initial value, submit call, and the descendant-exclude filter.


9. Tool-group toggle fires a full reconcile per click — debounce coalesces only the toast

[dot-role-tools-tab.component.ts onToggledot-roles.store.ts:693 saveToolGroups], :218 toolGroupsSaved$.pipe(debounceTime(...))

The 1.5s toolGroupsSaved$ debounce coalesces only the toast; each save still triggers a full catalog GET + N ancestor tool-group GETs. A burst of 5 checkbox clicks on a role with 3 ancestors is 5 POSTs + 5 catalog GETs + 15 ancestor GETs.

Coalesce the reconcile into the same debounce (queue the id-set of dirty groups, single reconcile after the burst). Under network jitter, out-of-order reconciles can also restamp state with a stale union — the guarded write only gates the last one to resolve, not the earlier stale ones.


Out-of-scope for this PR but worth capturing: dot-users-list.store.ts bulk-delete path has no aggregate toast when every deletion fails (only per-request errors). Behavior pre-dates this PR; noting here so it isn't lost.

Nice work overall — the consolidation into DotRolesService, the Tools-tab Angular migration, and the tree-node child-count / reparent handling read cleanly. Findings 1–4 are the ones I'd want addressed before merge; the rest can be follow-ups.

hmoreras and others added 2 commits August 31, 2026 09:55
…chain under-report

Review feedback on #37260.

Lift the cross-role guard the PR added for `saveToolGroups` onto the
mutations that were still missing it:

- `updateRole` wrote `selectedRole` unconditionally after its await. The
  dialog is modal, but ESC / the X close it while the PUT is in flight,
  so a role switch afterwards left the header, `canModifyRole` and both
  tabs describing the role that was edited — and the next save on that
  pane would have targeted it. The tree patch stays unguarded: it is
  keyed by id.
- `createRole` seeded the selection the same way. It now only takes the
  selection if the pane has not moved on.
- `removeUsersFromRole` guarded its reload but not the optimistic prune,
  which reads `members` — by then possibly another role's slice. Both
  are behind the guard now.

Fix the ancestor chain, which drives the per-ancestor fan-out for both
effective membership and effective tool grants:

- `collectAncestorChain` climbed by `parent` id, but
  `unwrapLegacySearchNode` keeps id/name/locked and drops `parent`. Any
  role opened from the search results therefore produced a one-element
  chain and silently lost every inherited member and tool group. It now
  walks the tree structure, which both trees carry.
- A role missing from the tree got a fabricated parentless stub —
  indistinguishable from a root, so callers fanned out to direct grants
  only and rendered the result as complete. The chain is now empty and
  `loadMembers` / `loadToolGroups` surface it instead of fanning out.
- `mergeTreesPreferParent` deduped roots preferring the copy with
  `parent`; that tie-break is dead under a structural walk, and dropping
  a root's other copy loses the branch only that copy hydrated. Renamed
  to `mergeTreesForLookup` and no longer deduped.

Two more the review surfaced indirectly:

- `createRole` left the previous role's `members` and `toolGroups` in
  state. The Users tab reloads off a `selectedRole` effect; the Tools
  tab has none, so it kept painting the old role's grants — Granted From
  chips included — under the new role's name.
- `$searching` was set before the await and never cleared on the stale-
  token drop or the under-3-chars short-circuit, so the parent picker
  span forever once a query was superseded mid-request. Both dialogs.

Each guard was verified by neutering it and confirming the new tests go
red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One conflict, in `dot-users.service.ts`: this branch removed `getAllRoles`
and its `expandRoleTree` / `expandDescendants` helpers when the roles
surface was consolidated into `DotRolesService`, while main added the API
token methods to the same region of the file.

Resolved by keeping main's API token methods and dropping the roles walk.
Nothing calls `getAllRoles` any more — this branch moved its last consumer
onto the pure `dot-roles-hierarchy.utils.ts` helper — and the walk would no
longer compile regardless: the `switchMap` / `forkJoin` / `of` / `catchError`
imports and the `DotRoleView` / `DotRolesResponse` types it depends on were
removed with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hmoreras

Copy link
Copy Markdown
Member Author

@AP2300 the mention options was fixed; except 7 and 9 since are in some way false positive

AP2300
AP2300 previously approved these changes Aug 31, 2026
…y-load gate

Reparenting a role from the Edit dialog repainted its children four times
and hid its grandchildren.

Both come from the same place: the reparent path was the only one that
took the moved node's `roleChildren` and `childCount` from the PUT
response.

`PUT /v1/roles/{roleId}` hydrates two levels at most, so everything below
the moved role's direct children is absent from it — taking that copy
drops every lazy-loaded descendant out of the tree. `patchNodeInPlace`
already keeps the node's existing children on the same-parent path for
exactly this reason; the reparent path now does the same.

That response is also wrong today. Verified against a running instance
with a role that has ONE child:

    GET  before → childCount: 1, children: [Child]
    PUT  move   → childCount: 4, children: [Child, Child, Child, Child]
    GET  after  → childCount: 1, children: [Child]

The persisted rows are correct — which is why a reload cleared it — so
this is a response-body defect in `RoleFactoryImpl`, filed separately.
Preferring the counts we already hold makes the UI right either way.

The second half is ours regardless of that fix: `#fetchedRoleIds` is
add-only, so once a branch lost the children it had already fetched,
`onNodeExpand` returned early forever and the branch stayed expanded and
empty until a full page reload. It now re-fetches when `childCount` says
children exist and state holds none, which also closes the known
follow-up where a failed lazy-load never retried.

Each fix verified by neutering it and confirming the new tests go red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d role lands in

Two follow-ups from testing the previous commit against a live instance.

The duplicate children came back. Preferring the children we already hold
only helps when we hold some: a node whose branch was never expanded has
`roleChildren: []`, so the response's copy was all there was and it went
in untouched. That is the common case — `GET /v1/roles` hydrates two
levels, so anything from the third level down starts empty.

Neither `roleChildren` nor `childCount` is read raw from the response any
more; both are folded through `dedupeRolesById` once, before either branch
uses them. That also closes a case the first pass missed entirely: the
same-parent path runs `patchNodeInPlace`, which preserves the node's
existing children but takes every other field from the replacement, so the
inflated `childCount` reached the tree even on a plain rename.

Second, reparenting from the Edit dialog keeps the role selected but moves
it under a parent that is usually collapsed, so the role the admin was
just editing disappeared on save. The tree now opens the ancestor path of
the selected role when that path changes.

Keyed on the path rather than on the tree: `roles` is rewritten on every
member load to refresh the user-count badge, and reacting to that would
re-open branches the admin had just collapsed. Only ancestors are opened —
expanding the role itself would unfold a branch nobody asked for.

The backend defect behind the duplication is #37303. This makes
the UI correct either way.

Each fix verified by neutering it and confirming the new tests go red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hmoreras
hmoreras disabled auto-merge August 31, 2026 19:11
@hmoreras
hmoreras added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit cc1ff6f Aug 31, 2026
80 checks passed
@hmoreras
hmoreras deleted the issue-36930-follow-up branch August 31, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Task] Roles and Tools: Angular implementation + backend wiring

2 participants