Issue 36930 fe signed - #37228
Conversation
…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 finished @hmoreras's task in 3m 38s —— View job Claude Code ReviewReviewed the new Overall the PR is in good shape. The security-sensitive spots are handled well: the wrapper JSPs guard the reflected Findings below are all non-blocking polish items — no correctness or security blockers. New Issues
Notes (non-blocking)
· |
Proposed Changes
Checklist
Additional Info
** any additional useful context or info **
Screenshots
Closed unmerged — superseded by #37234.
This PR fixes: #36930