Skip to content

Issue 36930 fe signed - #37228

Closed
hmoreras wants to merge 25 commits into
mainfrom
issue-36930-fe-signed
Closed

Issue 36930 fe signed#37228
hmoreras wants to merge 25 commits into
mainfrom
issue-36930-fe-signed

Conversation

@hmoreras

@hmoreras hmoreras commented Aug 25, 2026

Copy link
Copy Markdown
Member

Proposed Changes

  • change 1
  • change 2

Checklist

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

Additional Info

** any additional useful context or info **

Screenshots

Original Updated
** original screenshot ** ** updated screenshot **

Closed unmerged — superseded by #37234.

This PR fixes: #36930

hmoreras and others added 25 commits August 13, 2026 09:13
…36930)

Ships the initial FE scaffold for the Dojo → Angular Roles and Tools
migration as an opt-in Beta portlet.

## Wiring

- `portlet.xml`: new `roles-beta` entry (`PortletController`,
  `<portlet-url>/roles</portlet-url>`, display `Roles and Tools (Beta)`).
  Existing Dojo `roles` entry stays untouched — rollback is removing the
  Beta from a layout.
- `app.routes.ts`: `roles` path lazy-loads
  `@dotcms/portlets/dot-roles/portlet`.
- `Language.properties`: `roles-beta` title + 40+ i18n keys for the portlet UI.
- `tsconfig.base.json`: alias `@dotcms/portlets/dot-roles/portlet`.

## Angular structure

`libs/portlets/dot-roles/`:

- Shell + page (two-column layout)
- Roles panel: `p-tree` with folder/shield icons, debounced filter,
  `New` button, inline `+` add-child action
- Role detail: header (name + counts + Edit Role button) + `p-tabView`
  with three tabs
- Users tab (Angular): `p-table` with NAME / EMAIL / GRANTED FROM chip
  (direct vs. inherited), `Grant to User` popover with `p-autoComplete`
  hitting `/v1/users/filter`
- Permissions tab: iframe pointing at
  `view_role_permissions_wrapper.jsp` (BE wrapper to be added separately)
- Tools tab: iframe pointing at `view_role_tools_wrapper.jsp` (BE
  wrapper to be added separately)
- Add Role dialog: reactive form, POST `/v1/roles` (fully functional)
- Edit Role dialog: read-only placeholder with a blocked-notice banner
- `DotRolesStore` SignalStore with tree, selection, members, tab, filter
- `DotRolesPortletService` covers the endpoints that exist today and
  throws instructive errors from the stubs blocked on the backend tasks

## Blocked write flows (placeholders with links to the backend tasks)

- Edit Role save + drag-to-reparent (#36936 — PUT /v1/roles/{roleId})
- Grant to User (#36937 — POST /v1/roles/{roleId}/users/{userId})
- Bulk / per-row Remove (#36938 — DELETE /v1/roles/{roleId}/users)
- Delete Role (#36939 — DELETE /v1/roles/{roleId})

`p-tree` drag-and-drop is registered but disabled
(`draggableNodes: false`, `droppableNodes: false`) until #36936 ships.

## Styling + tests

- Tailwind utility classes only (per `docs/frontend/STYLING_STANDARDS.md`);
  zero component-scoped SCSS files.
- PrimeNG-only UI components (`p-tree`, `p-tabs`, `p-table`, `p-dialog`,
  `p-select`, `p-checkbox`, `p-textarea`, `p-autoComplete`, `p-popover`).
- Jest + Spectator smoke coverage — 9 suites, 45 tests: store (17 tests)
  + each component (~2-4 tests each).
- Prod build (`nx build dotcms-ui --configuration=production`) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Polish pass after the initial scaffold, driven by hands-on feedback
against the demo instance. Groups five distinct fixes.

## Tree hierarchy — consume nested `roleChildren` instead of rebuilding

The `/v1/roles?loadChildrenRoles=true` response is nested (`RoleView` has
`roleChildren: List<RoleView>` even though the openapi.json misses the
field). Rebuilding the tree client-side from `parent` was flattening
grandchildren away, so Publisher / Legal → Reviewer → Contributor never
appeared. The store now consumes the nested response verbatim and
lazy-loads deeper levels on `onNodeExpand` via
`/v1/roles/{roleId}?loadChildrenRoles=true`. `#expandedRoles` tracks
which nodes have been fetched so confirmed-leaf chevrons hide correctly.

## Users tab — swap broken endpoint, handle parents + editUsers=false

The tab was calling `/v1/roles/{roleId}/rolehierarchyanduserroles` and
expecting `firstName/lastName/emailAddress` on the entries — but that
endpoint returns `Role[]`, not `User[]`, so the table stayed empty.

Two-path replacement:
- `loadRoleMembersByKey(roleKey)` → `GET /v1/users/filter?roleKey=X` —
  same fast path dot-users uses, returns users with email.
- `loadRoleMembersById(roleId)` → falls back to
  `/rolehierarchyanduserroles` and parses the `user === true` entries.
  No email today (the endpoint returns Role objects); table shows `—`
  in that column until an id-based user endpoint is added upstream.

The store picks the path based on whether the selected role has a
`roleKey`. Also drops the previous `!editUsers` short-circuit — the flag
gates GRANT/REMOVE actions, not visibility. Parent roles (Publisher /
Legal, System, etc.) now show their members; the "Users cannot be
assigned" banner appears above the table when `editUsers === false` and
the Grant / Remove buttons are disabled instead of the whole tab.

## Tree row height — match Content Drive by dropping `pButton`

The inline `+` add-child action was a `<button pButton size="small" text>`
whose ~40px min-height forced the p-tree-node-content to ~50px. Replaced
by a plain `<button>` constrained to `size-5` with `text-base leading-none`
material symbols. Row height now settles at ~34px, matching the
Content Drive p-tree the design references.

The whole tree also switched from raw `p-tree` to the shared
`DotFolderTreeComponent` (`@dotcms/ui`) so we get its compact chrome,
custom toggler icons, and PassThrough plumbing for free.

## Tab layout — table full-width, banner/actions inset

`p-tabpanels` and `p-tabpanel` ship non-zero padding by default; a
Tailwind `p-0` couldn't beat the `.p-tabpanels` selector specificity.
Switched to `p-0!` (Tailwind 4's important suffix) on the PassThrough
so the panel wrapper stops squeezing the members table off the divider.
Users tab host lost `px-6` too; banner, actions row, and empty state
inset themselves individually so only the `p-table` reaches the edges.

## Store efficiency — local splice on create instead of full reload

`createRole` no longer calls `loadRootRoles()` after a successful POST.
The response already carries the fully-hydrated new role (id, parent,
DBFQN, FQN…), so the store splices it into the parent's `roleChildren`
in place. Branches other than the parent share references, so re-render
churn stays minimal and any lazy-loaded subtrees below level 1 keep
their state (previously they were wiped by the reload).

Three paths:
- `parentRoleId === null` → append to `roles`.
- Parent is in state → splice via `appendChildToParent`.
- Parent isn't loaded → targeted `loadRoleById(parentId)` refreshes only
  that subtree via `patchNodeChildren`, still avoiding a root-level
  reload.

## Tests

Store spec covers all three createRole paths + `loadRoleChildren`
lazy-load + roleKey/id fallback in `loadMembers`. Component specs
updated to the new store surface (`roleTree`, `canGrantUsers`,
`selectedRoleIsParent`, `loadRoleChildren`, dual `loadRoleMembers*`).
54 tests, all green. Lint / format:check / prod build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a parent role is selected, the Users tab now lists members granted
directly to that role and to any of its ancestors, matching the Dojo
portlet behavior (dotCMS role grants flow downward — a user granted
Publisher / Legal is also a member of Reviewer, Contributor, and every
descendant). Each row is tagged with the closest ancestor where the user
was directly granted; that tag drives the new "Granted From" column and
disables the remove checkbox on inherited rows (removal has to happen
at the ancestor).

## Store

`loadMembers` now walks the ancestor chain in `state.roles` via the
`parent` id (up to a 20-deep guard against pathological data), fires one
`/v1/users/filter?roleKey=X` per role in `forkJoin`, and merges results
into a `Map<userId, DotRoleMember>` where first-in wins. Because the
chain is ordered `[selected, parent, ..., root]`, direct grants override
inherited entries when a user is granted at more than one level. Roles
without a `roleKey` fall back to the id-based path documented in the
service — the fallback returns no email today; a follow-up backend
ticket (#37070) will add a dedicated `GET /v1/roles/{roleId}/users`
returning `List<User>` so we can retire the workaround.

Per-request `catchError` recovers to an empty batch instead of aborting
the whole load, so one failing ancestor call no longer nukes the tab.

## Users tab

`DotRoleMember` regains `grantedFromRoleId` / `grantedFromRoleName`. The
table gains a "Granted From" column rendered as a `p-tag` (`info`
severity for direct rows, `secondary` for inherited). `p-tableCheckbox`
is disabled on inherited rows and `onSelectionChange` filters those out
defensively so the header checkbox can't select rows the admin can't
remove from here.

## Tests

Store spec covers: ancestor walk + tagging, dedup with closest-ancestor
priority when a user is granted at multiple levels, graceful partial
failure on ancestor call error. Users tab spec updated for the enriched
`DotRoleMember` shape and the new `selectedRoleId` mock dependency.
55 tests green, lint / format:check / prod build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Interim per epic #36909 — ship the Roles Beta Permissions tab as an
`<iframe>` embedding the existing Dojo permissions widget while the
Angular re-implementation is scoped. The wrapper is intentionally thin
so the eventual swap-to-Angular is a drop-in.

## JSP wrapper — `view_role_permissions_wrapper.jsp`

New file at `dotCMS/src/main/webapp/html/portlet/ext/roleadmin/`. Mirrors
the pattern used by `permissions.jsp` in other portlets
(folders / categories):

- `init.jsp` + `top_inc.jsp` + `messages_inc.jsp` for the standard head /
  Dojo / portlet setup.
- Reuses `view_roles_js_inc.jsp` (Dojo widgets + DWR RoleAjax bootstrap)
  and `view_role_permissions_js_inc.jsp` (permission matrix logic)
  verbatim — no forked JS.
- Reuses the `rolePermissionsWrapper` div markup from
  `view_role_permissions_inc.jsp` — the same shell the Dojo portlet has
  always rendered.
- Reuses `view_role_permissions.css` + `view_roles.css` for the accordion
  chrome.
- Body-level tweaks (`margin: 0; padding: 8px`) so the iframe doesn't
  double-scroll or leave dead space inside the Angular tab.
- Reads `roleId` from the query string and calls
  `loadPermissionsForRole(roleId)` inside `dojo.addOnLoad`. Missing
  `roleId` leaves the widget idle instead of erroring, so the JSP is
  safe to hit standalone for debugging.

## Angular wrapper — `dot-role-permissions-iframe.component.ts`

Small polish on the existing placeholder:

- Extracted the JSP path into a `PERMISSIONS_WRAPPER_JSP` constant so
  the source of truth for the URL prefix is a single line.
- `encodeURIComponent(roleId)` on the query param.
- Class comment now documents the alignment with
  `DotPermissionsIframeDialogComponent` in `@dotcms/ui` (same
  `bypassSecurityTrustResourceUrl`-after-validation pattern the shared
  dialog uses for its permissions URLs).

## No other functional changes

- Tests still: 55 passing, lint clean, format clean.
- Prod build (`nx build dotcms-ui --configuration=production`) succeeds.
- Follow-up epic #36909 tracks the Angular replacement of this tab; the
  swap replaces this component with a native accordion consuming
  `/v1/permissions/*` — the surrounding tab shell + iframe URL contract
  disappear cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… Tools tab (#36930)

The permissions wrapper was throwing NPE at request time because it
included `roleadmin/init.jsp`, which wraps `<portlet:defineObjects />`.
That JSP tag requires a portlet container context — absent when the JSP
is hit as a plain HTTP request from an Angular iframe. The Tools tab
was 404'ing because its wrapper had never been created.

Both are addressed by matching the pattern that
`html/portlet/ext/{folders,categories}/permissions.jsp` already
established for the same problem — those files ship the "load a portlet
JSP from an iframe" flow in production and skip the portlet-only init.

## Permissions wrapper — fix the init include

Replace the portlet-scoped `roleadmin/init.jsp` include with
`/html/common/init.jsp`. Same session / user setup, no
`<portlet:defineObjects />`. Comment above the include documents why so
this doesn't regress.

Also drop the explicit `</body></html>` at the tail: neither
`folders/permissions.jsp` nor `categories/permissions.jsp` closes them
(`top_inc.jsp` opens them and the framework handles the tail).

## Tools tab — new wrapper following the same pattern

- `view_role_tools_inc.jsp` — the Dojo markup for the tools tab
  (`newLayouDialog`, `customPortletDialog`, `roleToolsWrapper` with the
  layouts grid and Save button). Extracted verbatim from `view_roles.jsp`
  so the shared JS in `view_roles_js_inc.jsp` (`loadRoleLayouts`,
  `createNewLayout`, `saveLayout`, `showCustomContentPortletDia`, ...)
  keeps working unchanged — those functions look up ids defined here by
  name. Mirrors the existing `view_role_permissions_inc.jsp` shape.

- `view_role_tools_wrapper.jsp` — same skeleton as the permissions
  wrapper: common init, top / messages includes, reuse of
  `view_roles_js_inc.jsp` (Dojo widget bootstrap + DWR RoleAjax),
  reuse of `view_roles.css`, body tweaks so the iframe presents
  flush inside the Angular tab. Reads `roleId` from the query string
  and calls `loadRoleLayouts(roleId)` inside `dojo.addOnLoad`. Missing
  `roleId` leaves the widget idle — safe to hit standalone for
  debugging JSP issues independent of the Angular shell.

No Angular changes; both `dot-role-permissions-iframe` and
`dot-role-tools-iframe` already point at these URLs (`?roleId=...`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…36930)

- iframes now fill the tabpanel; drop `min-h-[500px]` and force the
  vertical flex chain via component SCSS. Tailwind `!` suffix classes
  through the PrimeNG `pt` API were unreliable — JIT dropped the
  newly-added utilities on a running dev server, so the tabpanel
  collapsed to `auto` and the iframe fell back to the min-height
- mount the permissions / tools iframes only when their tab is active
  (`@if store.activeTab() === ...`). PrimeNG hides inactive tabpanels
  with `display:none` and the Dojo widgets inside snapshot their
  container width once at boot via `dojo.contentBox`; loading while
  hidden gave 0-width grid columns forever
- suppress the `view_roles_js_inc.jsp` portlet-scoped `dojo.addOnLoad`
  initializers (`buildRolesTree`, `initializePortletInfoList`, users-grid
  init) via a new `view_role_iframe_stubs_inc.jsp` — hidden DOM stubs
  for the anonymous callbacks plus no-op overrides for the named ones.
  The include only lives inside the two iframe wrappers, so the
  canonical `view_roles.jsp` portlet is not affected
- Tools wrapper seeds `currentRole` via `/api/role/loadbyid` before
  calling `loadRoleLayouts` — mirrors how `roleClicked` populates it in
  the full portlet, avoiding `Cannot read properties of undefined
  (reading 'editLayouts')`
- drop the `view_role_permissions.css` include from the permissions
  wrapper. Its legacy `.dotcms .dijitAccordionTitle{height:23px}` rule
  clipped the accordion title and hid the custom template wrapper;
  `view_roles.jsp` does not load that file either

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…36930)

- roles tree now renders fully collapsed on mount and remembers UI
  open state for the session. Two signals split the concerns:
  `#fetchedRoleIds` is add-only and drives lazy-load dedup + confirmed-
  leaf detection; `#openNodeIds` reflects live expand/collapse events
  so a re-open of a previously-loaded branch does NOT hit the backend
  again. Empty on first load → every node starts collapsed
- POST /v1/roles now strips empty-string optional fields to `undefined`
  before hitting the wire. Postgres `cms_role.role_key` has a UNIQUE
  constraint and legacy roles ship with `role_key = ''`, so posting an
  empty string reliably triggered `duplicate key value violates unique
  constraint "cms_role_name_role_key"`. Sanitizing on the way out lets
  the backend persist NULL and satisfy the uniqueness contract

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	core-web/.gitignore
#	core-web/package.json
#	core-web/pnpm-lock.yaml
#	core-web/tsconfig.base.json
…xt menu (#36930)

Wires the four v1 REST endpoints that just landed on main so the Angular
Beta portlet can mutate roles + memberships end-to-end. Also collapses the
tree's Edit / Delete actions into a right-click context menu.

- PUT /v1/roles/{roleId} (#36936) → `updateRole` in the service + store;
  Edit Role dialog rewritten from a read-only placeholder to a real form
  (parent picker filters descendants + self, system/locked disable the
  form with a notice). Store patches `selectedRole` and splices the tree
  in-place when parent didn't change or removes+appends on reparent
- DELETE /v1/roles/{roleId} (#36939) → `deleteRole`; Edit dialog Delete
  button + a tree context-menu Delete both route through the same
  `<p-confirmDialog>` copy. Store prunes the node and clears the
  selection when the deleted role was the active one
- POST /v1/roles/{roleId}/users/{userId} (#36937) → `grantUserToRole`;
  Users tab Grant popover replaced with a `<p-listbox>` that shows the
  full list on open, filters by name/email, and shows a `<p-avatar>`
  with initials (endpoint doesn't return photo URLs today). Idempotent
  BE means re-grants are safe
- DELETE /v1/roles/{roleId}/users (#36938) → `removeUsersFromRole`;
  bulk-remove button wired through a confirm. Partial-success response
  (`removedUserIds` + `skipped`) is handled by optimistically pruning
  removed rows then re-fetching members so inherited/direct labels
  reconcile
- roles tree gains a right-click context menu (Edit / separator /
  Delete, no icons per design). `DotFolderTreeComponent` accepts an
  optional `[contextMenu]` input + emits `onNodeContextMenuSelect`;
  existing consumers are unaffected

Refactors + polish:
- extract `refreshMembersFor` as a local helper in the store so
  `loadMembers`, `grantUserToRole`, and `removeUsersFromRole` share the
  ancestor-walk + fan-out
- `#sanitizeRoleForm` also feeds `updateRole` (empty `roleKey` becomes
  NULL — the `cms_role.role_key` UNIQUE constraint still bites on empty
  strings)
- strip PrimeNG's `1rem` `.p-tree` padding via component SCSS so
  selection/hover span the full row
- move `<p-popover>` / `<p-confirmDialog>` out of the users-tab flex
  column — they are empty flex children and each was adding a `gap-4`
  slot, spacing the actions row ~48px too far from the table
- confirm dialogs are non-draggable and use the default (primary) button
  style per UX guidance; accept label is a plain "Delete"
- add missing i18n keys (`roles.confirm.delete.*`, `roles.edit.readonly`,
  etc.) and remove the stale "waiting on issue #NNN" placeholder copy
- stub `window.matchMedia` in the portlet's test-setup — PrimeNG
  ConfirmDialog / ContextMenu read it for breakpoints and jsdom does not
  ship an implementation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…frame wrapper (#36930)

The dojox.grid.DataGrid inside `view_role_tools_wrapper.jsp` painted the
column header BEHIND the first data row (users saw rows starting where
the header should be, with column titles nowhere visible). Two stacked
positioning bugs, both a consequence of the wrapper's lack of a
`dijit.layout.TabContainer` — which the canonical `view_roles.jsp`
portlet uses to give the grid a properly-sized containing block and to
fire `resize()` when the tab becomes active.

Bug 1 — `.dojoxGridHeader` (`position: absolute` + `overflow: hidden`
per `dojox/grid/resources/Grid.css:49-53`) was measured with
`headerNode.offsetHeight` inside `_ViewManager.js#measureHeader()` at a
moment when the container width wasn't fully realized. `_Grid.js:697`
then wrote inline `masterHeader.style.height = "0px"`, which combined
with the `overflow: hidden` clipped the four `<th>` cells to invisible.

Bug 2 — dojox drops an unclassified `<div role="presentation"
style="position: absolute; top: 0; left: 0">` inside `.dojoxGridContent`
as the row canvas. With the default `position: static` on
`.dojoxGridContent` / `.dojoxGridScrollbox` / `.dojoxGridView`, that
absolute canvas escaped the natural chain all the way up to the nearest
positioned ancestor (`#roleLayoutsGridWrapper`, which we made
`position: relative` to trap dojox absolutes) and painted rows at Y=0 of
the wrapper — right on top of the header. And because absolute children
don't contribute to their parent's height, `.dojoxGridContent`
collapsed to 0, letting the trailing `.buttonRow` (Save) paint on top
of the rows once we DID contain them.

Fix — neutralize dojox's absolute layout for this iframe only:

- `.dojoxGrid` / `#roleLayoutsGrid` / `.dojoxGridMasterHeader` /
  `.dojoxGridMasterView` → `position: static`, `height: auto`,
  `overflow: visible` so the outer scaffolding uses normal document
  flow (header first, then rows, then save)
- `.dojoxGridView` / `.dojoxGridScrollbox` / `.dojoxGridContent` →
  `position: relative` so they establish a containing block for the
  absolute row canvas AT their natural document position
- `.dojoxGridContent > div` (the row canvas) → `position: static` so
  its intrinsic height bubbles up through the ancestor chain and the
  Save row lands below the rows instead of on top of them
- `.dojoxGridHeader` → `position: relative`, `overflow: visible`, full
  width so the four `<th>` cells actually paint
- `.dojoxGridHiddenFocus` → `display: none`. dojox drops two
  keyboard-focus helper `<input>`s in every `.dojoxGridView`;
  normally they are clipped by `overflow: hidden`, but our
  `overflow: visible` uncloaks them into two floating checkboxes
  between the header and the first row. They are already
  `aria-hidden` so visual hiding is safe

Row virtualization is lost as a side effect — acceptable because
tool-group grids ship with ~10-20 rows in practice.

All rules are scoped under `#roleLayoutsGridWrapper` and live inline in
the wrapper JSP, so the canonical Dojo portlet (`view_roles.jsp`)
inherits none of this behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking Create Custom Tool Group / Create Custom Content Tool from the
Tools tab threw `RangeError: Maximum call stack size exceeded`, and once
the dialog was force-opened its Tools dropdown was empty. Also its form
rows collapsed with no cell spacing. Three chained iframe-only bugs:

1. Infinite recursion — `createNewLayout` (`view_roles_js_inc.jsp:1268`)
   defers to `initializePortletInfoList(createNewLayout)` when the
   portlet list isn't cached yet, expecting an async DWR fetch. The
   stubs file overrode `initializePortletInfoList` with a synchronous
   no-op that just fired the callback, turning that guard into
   unbounded self-recursion. The override was there to keep the shared
   file's boot-time `dojo.addOnLoad(initializePortletInfoList)` from
   failing on a missing `<select id="portletList">` — but the stubs
   file already injected one, so the real function could run. Drop the
   override.

2. Empty Tools dropdown — the shared stubs file also injected a hidden
   `<select id="portletList">` unconditionally. In the Tools wrapper
   the same id also exists inside the New Layout dialog markup, and
   dojox's `initializePortletInfoListCallback` upgrades the FIRST
   `#portletList` it finds via `dijit.form.FilteringSelect`. The stub
   comes before the real select in DOM order, so it "won" the widget
   binding and the real dropdown in the dialog stayed a plain empty
   `<select>`. Move the stub out of the shared file: only the
   permissions wrapper (which does not ship a `#portletList`) injects
   it, and it now lives at the end of that wrapper so it can never
   sit before a real one.

3. Form cell spacing — `.toolTable td { padding: 10px 8px; vertical-
   align: middle; position: relative }` lives INLINE in
   `view_roles.jsp` (lines 60-70), not in `view_roles.css`. This
   wrapper doesn't include `view_roles.jsp`, so the New Layout dialog
   rows collapsed with no breathing room. Mirror the rule in the
   wrapper's inline `<style>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gSelect in permissions iframe (#36930)

Two small UX fixes for the Angular Beta portlet:

- Users tab: the Remove button was always visible but disabled until
  the admin selected direct-grant rows. Wrap it in `@if
  ($canBulkRemove())` so it only renders when the store's selection
  is non-empty — no crowded toolbar for the common case, and the
  destructive action stops advertising itself when it can't fire.

- Permissions iframe: a stray 241px combobox was rendering under the
  tabs (`#widget_portletList`). The permissions wrapper stubs a
  `<select id="portletList">` so the shared JS's boot-time
  `initializePortletInfoList` can upgrade it to a
  `dijit.form.FilteringSelect` without erroring. The select itself
  had `display:none`, but dijit creates a NEW widget DOM element
  (`#widget_portletList`) as a sibling of the original — it takes its
  dimensions from its own CSS, so `display:none` on the source select
  didn't hide the visible widget. Wrap the stub in a
  `<div style="display:none">` container so the whole subtree
  (including the widget dijit inserts) inherits the hidden state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#36930)

Server-side role tree search (was client-side over the loaded cache
only, so a match nested under an unexpanded node never surfaced) plus
loading skeletons on role selection and Tailwind-only refactors.

- Roles tree filter now hits the deprecated-but-functional
  `GET /api/role/loadbyname/name/{q}/` (only REST surface today doing a
  deep-tree search — the modern `/v1/roles` endpoint has no filter
  query yet). Response shape is Dojo's `ItemFileReadStore` legacy
  format with dashes underscored in ids as a DnD artifact; we adapt
  it to `DotRoleNode[]`. Search is gated at 3+ chars matching the
  canonical Dojo portlet, and the tree auto-expands every branch when
  results are showing so leaf matches are visible without clicking
  through ancestors
- Grant to User popover: replaced the client-side `p-listbox`
  filter-only flow with a debounced pipe that hits
  `/api/v1/users/filter?query=X` per keystroke (300ms debounce +
  `switchMap`) — client-side filter would miss users past the first
  page for large tenants
- Empty state for the roles tree: centered stack with an icon +
  hint text on top padding, matching the pattern used in dot-tags
- Skeleton loading: `dot-roles-detail-header` now shows a circle +
  two-line + button placeholder while `selectedRoleStatus` is
  'loading', and `dot-role-users-tab` shows a five-row table
  skeleton while either the members list or the role detail is
  loading. Layouts mirror the loaded state to avoid content-jump
- Tree padding: dropped the `<style>` file and set
  `[--p-tree-padding:0]` on the PT root class instead — PrimeNG's
  `.p-tree` reads that CSS variable, so Tailwind can control it
  without `!important` or a component stylesheet
- Filter icon alignment: `text-base! leading-none!` beats the Google
  Fonts stylesheet order that keeps `material-symbols-outlined` at
  24px inside the small p-inputicon

Follow-up in flight: swap the deprecated `/api/role/loadbyname` to a
v1 search endpoint once BE files one, and swap the
`rolehierarchyanduserroles` fallback to #37070's
`GET /v1/roles/{roleId}/users` (already open) for real user email data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename the Beta portlet + Angular route to `/roles-beta` so it stops
  colliding with the legacy Dojo `roles` portlet in the menu highlight
  and the URL bar.
- Users tab: client-side paginated members table (matches
  publishing-queue-beta), per-row hover-only remove button, "just
  granted" row highlight (3s ease-in-out), filtered Grant popover that
  hides users already assigned directly or by inheritance, skeleton +
  friendly empty state on the picker.
- Replace `dot-roles-page.component.scss` with PT + Tailwind. The
  `flex: 1 1 0` (basis 0) on `p-tabpanels` is load-bearing: with the
  default `1 1 auto`, tabpanels' flex-basis is measured from the
  users-tab's natural content, which for roles with many members
  proportionally shrinks the tablist and clips the active-tab
  underline.
- Drop the removed `selectedMembers` bulk-remove state from the store
  (the design overhaul replaced the bulk button with per-row remove).
- i18n polish: clarify the "cannot grant" copy, split the search empty
  state into title + copy, add the remove tooltip string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace the fixed `<aside>` + `<section>` split with `p-splitter`
  (22/78 initial, `minSizes` to keep either side usable) so admins can
  resize the tree column and the detail pane.
- Roles tree: PrimeNG's `pt.nodeLabel` only reaches the top-level
  `<p-tree>` instance, so nested `<p-treenode>` labels fell back to
  defaults (no ellipsis, `+` next to the text). Apply the label styles
  as a descendant selector on the `root` slot so the rule cascades to
  every label regardless of depth.
- Drop the unused `roles.users.search.empty.copy` string and the `<p>`
  that rendered it in the Grant popover empty state.
- Move each PT config next to a short comment on its role and reorder
  so `splitterPt` reads before the `tabs*Pt` trio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	dotCMS/src/main/webapp/WEB-INF/portlet.xml
Review pass — visible bugs the QA session surfaced:

- **Silent 40-cap on members + Grant popover**: `UserResource.filter`
  defaults `per_page=40`, so roles with >40 members and Grant-popover
  candidate lists past 40 were silently truncated. Pass an explicit
  `per_page=USER_FILTER_PAGE_SIZE` (500) as a bridge until #37070
  ships the paged endpoint.
- **Edit-from-search wipes fields**: the tree passed the search-result
  node to the Edit dialog, but search results only carry
  `{id, name, locked}`. PUT is a full replace, so `parent / roleKey /
  description / editUsers / editPermissions / editLayouts` got wiped
  on save. Load the full detail via a new `store.fetchRoleDetail` on
  demand before opening.
- **Delete-role copy**: message claimed we'd cascade-delete child
  roles; BE actually rejects with 409 if children exist. Cascade
  covers users / permissions / tool groups only.
- **Locked-role Edit consistency**: header Edit button only checked
  `isSystemRole`; tree + dialog checked `system || locked`. New
  `canModifyRole` computed, consumed by the header.
- **Grant popover errors were silent**: a 500 rendered as "No users
  found." Route through `DotHttpErrorManagerService.handle(error)`.
- **Ancestor chain missed search-only branches**: `refreshMembersFor`
  walked `store.roles()` only, so a role reached via search dropped
  its inherited "Granted From" rows. Concat `searchResults` under
  active search.
- **Tools iframe**: missing `encodeURIComponent(roleId)` (permissions
  iframe already had it); the doc lied about `postMessage`.

409 handling in the shared error manager (`libs/data-access`):

- 409 was not registered in `errorHandlers`, so the manager silently
  swallowed conflict responses. Add `CONFLICT = 409` to `HttpCode`
  and a `handleConflictError` mirroring the 400 handler — extracts
  `error.message` from the BE body so the user sees, e.g., "Role has
  2 child role(s) and cannot be deleted" instead of nothing.
- All portlets that already route errors through the shared manager
  now surface 409 messages automatically.

Refactors + coverage:

- Move `HttpClient` out of the users tab: new
  `DotRolesPortletService.searchUsers(query)`.
- Extract pure adapters (`unwrapLegacySearchNode`,
  `toRoleMemberResults`, `sanitizeRoleForm`) and their wire types
  into `services/dot-roles.adapters.ts` — service becomes HTTP-only.
- Extract tree helpers to `store/dot-roles.tree-utils.ts` — store
  file shrinks 722 → 587 lines.
- Delete `_unused()` placeholder + its rxjs imports from
  `dot-roles-add.component.ts`.
- 20 unit tests for the adapters (100% funcs / 100% lines /
  ~89% branches), 27 for the tree utils (all passing).
- Trim stale issue refs from closed tickets (#36936-9); keep the
  one still open (#37070).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Permissions + Tools wrapper JSPs echoed the `roleId` request param
  into an inline `<script>` string via `UtilMethods.escapeSingleQuotes`.
  That helper is misleadingly named — it UNESCAPES `\'` to `'`
  (`RegEX.replaceAll(fixme, "\\\\'", "'")`), so a crafted URL like
  `…?roleId='-alert(document.cookie)-'` would execute arbitrary JS in
  an authenticated admin session. In the normal Angular flow the value
  is a real UUID, but the JSPs are directly reachable so the reflection
  is exploitable.
- Add a `Pattern ROLE_ID_UUID` guard in each wrapper: the raw param
  only reaches the inline script when it strictly matches the UUID
  shape; otherwise `roleId = null` and the script block is skipped
  (the widget renders its idle state, same as when the param is
  omitted).
- Tools wrapper additionally: `nameEl.innerHTML = role.name` →
  `nameEl.textContent = role.name` so a role name that happens to
  carry markup can't inject into the page.
- Keep `DotRolesPortletService.reparentRole()` with an explicit
  placeholder comment — it's staged for the tree drag-and-drop
  ticket; the wiring lands with that feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Flip `$suggestionsLoading` synchronously in `onGrantPanelShow` before
  pushing to the debounced pipeline. The `tap` that sets loading only
  fires after the 300ms debounce, so without the synchronous seed the
  popover flashed the empty state for ~300ms on first open.
- Clear `$userSuggestions` on show so leftover rows from a previous
  open don't render underneath the skeleton.
- Drop `distinctUntilChanged` from the search pipeline: reopening the
  popover pushes `''` again on purpose, and blocking the duplicate
  emission stalled the pipeline so loading stayed true forever on the
  second open. `debounceTime(300)` alone still coalesces keystrokes;
  the only side effect is that backspacing to the same text refetches,
  which is negligible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ible cleanups

Critical (subscription safety + races):

- **loadMembers race + leak**: `refreshMembersFor` was a bare
  `forkJoin(...).subscribe(...)` — rapid role-switching could resolve
  an earlier chain after a later one and overwrite `members` with
  stale data, plus leaked subscriptions on destroy. Converted to
  `rxMethod<{id, roleKey: string|null}>` with `switchMap` so a newer
  invocation cancels prior in-flight requests. Grant / remove now
  route through the same rxMethod instead of re-calling the closure.
- **CRUD Promise-boilerplate leak**: `fetchRoleDetail`, `createRole`,
  `updateRole`, `deleteRole`, `grantUserToRole`, `removeUsersFromRole`
  all used `new Promise((res, rej) => svc.subscribe({next, error}))`
  which leaks the subscription and has weird multi-emission
  semantics. Swapped all six to
  `await firstValueFrom(svc.pipe(take(1)))` — cancels correctly,
  propagates errors properly, one-liner.
- **loadRoleChildren leak**: bare `subscribe` on lazy-load. Now
  `async` + `firstValueFrom(...pipe(take(1)))` so spam-expanding
  tree nodes no longer leaks.
- **createRole parent-refresh race**: when the target parent wasn't
  in the loaded tree the code fired a bare inner
  `service.loadRoleById(parentId).subscribe(...)` and returned
  `created` *before* the parent subtree was patched. Now `await`s
  the parent refresh so callers see a coherent tree.

Real bugs:

- **deleteRole `deleted:false` desync**: the tree removed the node
  regardless of `result.deleted`. Server-side rejection (`deleted:false`
  in a 200 response — hierarchy / workflow constraint) now keeps the
  node in the tree, matching reality.
- **`dot-roles-edit` crash on misconfigured open**: `$parentOptions`
  touched `this.role.id` without a null guard. Bail on `!this.role`.
- **Double error toast on delete failure**: the dialog set an inline
  banner AND the shared toast fired. Distinguish `result === null`
  (HTTP error, toast already surfaced) from `result.deleted === false`
  (server rejection, inline banner is the only feedback).
- **Grant `.then` writes after destroy**: `onGrantUser` untracked;
  navigating away between click and response wrote to signals on a
  torn-down component. Guard with a `#destroyed` flag flipped by
  `#destroyRef.onDestroy`.
- **`unwrapLegacySearchNode` corrupted role keys with underscores**:
  the `_→-` replace was global — role keys like
  `DOTCMS_BACK_END_USER` (if they landed here) would get mangled.
  Restricted the substitution to ids that match the
  UUID-with-underscores shape.

Defensible:

- **OnPush** on every new component (9 files).
- **`DotRolesStatus` uses the shared `ComponentStatus`** union
  (per CLAUDE.md). All `'init' | 'loading' | 'loaded' | 'error'`
  literals swapped to the upper-case shared shape.
- **`MAX_ROLE_DEPTH = 20`** extracted (was a magic literal in
  `collectAncestorChain`).
- **Client-side UUID guard on iframe URLs** — defense-in-depth
  mirroring the JSP wrappers' server-side check.
- **`DotRolesPortletService` is `providedIn: 'root'`** — it's
  stateless, no need for component scoping.
- **Explicit `window.buildRolesTree` / `window.currentRole*`** in
  the JSPs so the shared-global contract with `view_roles_js_inc.jsp`
  survives any future strict-mode / ES-module refactor.
- **Tools JSP fallback UI on role-load failure** — a blank iframe
  used to leave admins guessing; now shows "Failed to load role —
  refresh to retry." in the same slot the happy path fills.
- **`mergeTreesPreferParent`** deduplicates the cache-vs-search
  input to the ancestor walk, preferring the copy that carries
  `parent` so a search-only role still climbs correctly.
- **Per-domain edit computeds** (`canEditRoleUsers`,
  `canEditRolePermissions`, `canEditRoleLayouts`) — the BE gates
  each independently; `canModifyRole` alone was too coarse.
- **`Delete` / `None (top level)` i18n**: swapped hardcoded English
  and literal-key lookups (`messageService.get('Delete')`,
  `ROOT_PARENT`) to real dotted keys (`roles.action.delete`,
  `roles.form.parent.root`).
- **Indentation via ` `** in add / edit parent pickers so the
  p-select renderer doesn't collapse leading whitespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename `loading` → `roles.status.loading` and `error.load-failed`
  → `roles.error.load-failed` so the two new keys don't pollute the
  global i18n namespace. Call sites in both templates + specs
  updated to match.
- Drop `roles.edit.blocked` — the "editing requires #36936 to ship"
  path was removed when the endpoint landed and the key is
  unreferenced in the diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- **createRole header flicker (#11 in review)**: the POST response is
  a hydrated `DotRoleDetail`, so seed `selectedRole` directly and
  skip the follow-up `loadRoleDetail(created.id)` — the header no
  longer shows a skeleton / stale role during the round-trip.
- **CRUD failure-path spec coverage (#29)**: added error-branch
  tests for `updateRole`, `deleteRole`, `grantUserToRole`, and
  `removeUsersFromRole` — each asserts `httpErrorManager.handle`
  fires and the method returns `null`. Also covers `deleted:false`
  (tree unchanged) and the short-circuit branches (no selected
  role, empty `userIds`).
- **Button-bypass anti-pattern (#30)**: `spectator.component as
  unknown as { onX }` casts replaced with real
  `spectator.click(byTestId(...))` in `dot-roles-edit` and
  `dot-role-users-tab` specs. A broken `(click)` binding now fails
  the test.
- **`rootRoles` dead-mock cleanup (#31)**: replaced with the real
  `roles` / `roleTree` selectors in the tree spec.
- **`createRole` failure-path test in the Add spec (#32)**:
  asserts `$error` is set and the dialog stays open when the store
  returns `null`.

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

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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


Claude Code Review

Reviewed the new dot-roles portlet (store, services, adapters, tree utils, components), the wrapper JSPs, the shared DotHttpErrorManagerService change, and portlet registration.

Overall the PR is in good shape. The security-sensitive spots are handled well: the wrapper JSPs guard the reflected roleId with an anchored UUID Pattern.matches() before interpolating it into inline <script>, use textContent (not innerHTML) for the role name, and the Angular iframes mirror the same UUID check + encodeURIComponent before bypassSecurityTrustResourceUrl. Immutable tree helpers are pure and unit-tested, switchMap/rxMethod cancellation prevents stale-write races, and firstValueFrom(...take(1)) avoids leaked subscriptions. roles-beta is declared in portlet.xml and pinned by id in SerializationHelperTest.

Findings below are all non-blocking polish items — no correctness or security blockers.

New Issues

  • 🟡 Medium: core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-role-users-tab/dot-role-users-tab.component.ts:194-199 — single-row removal uses the bulk-phrased copy. roles.users.confirm.remove.message is Are you sure you want to remove {0} user(s) from this role? and onRemoveMember passes a user name as {0}, producing "…remove John Doe user(s) from this role?". The header roles.users.confirm.remove.header is also plural (Remove Users) for a single user. Either add a singular key (e.g. roles.users.confirm.remove.single using {0} as a name) or keep the count semantics and pass 1.
    Fix this →

  • 🟡 Medium: core-web/libs/portlets/dot-roles/src/lib/dot-roles-edit/dot-roles-edit.component.ts:144-145 — the delete accept label reads roles.action.delete, which resolves to Delete Role (Language.properties:7687), directly contradicting the adjacent comment (Plain "Delete" (not "Delete Role")) and diverging from the tree context-menu delete, which uses the plain Delete key (dot-roles-tree.component.ts:261). Pick one label for both delete confirmations.

  • 🟡 Medium: core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.ts:268-270 — the tree's delete confirm ignores the deleteRole result. The store only routes thrown HTTP errors through httpErrorManager; a 200 with deleted: false returns normally, so this path shows no feedback and silently leaves the node in the tree. The edit dialog explicitly handles that case (dot-roles-edit.component.ts:160-165) — the tree path should too.
    Assumption: the BE can return 200 { deleted:false } (the service doc at dot-roles-portlet.service.ts:276 documents rejections as 403/404/409, which throw). What to verify: whether DELETE /v1/roles/{id} ever responds 200 deleted:false; if it always throws on rejection, this is moot.

Notes (non-blocking)

  • dot-roles-tree.component.ts:261 uses this.#messageService.get('Delete') (a bare, non-namespaced key) while the rest of the portlet uses the roles.* namespace introduced in commit 4a1ed8cd. Consider roles.action.delete (once its value is settled per the finding above) for consistency.

· issue-36930-fe-signed

@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Aug 25, 2026
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: Done

Development

Successfully merging this pull request may close these issues.

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

1 participant