diff --git a/core-web/apps/dotcms-ui/src/app/app.routes.ts b/core-web/apps/dotcms-ui/src/app/app.routes.ts index 24c40eb46f68..11c283a3ac40 100644 --- a/core-web/apps/dotcms-ui/src/app/app.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/app.routes.ts @@ -216,6 +216,14 @@ const PORTLETS_ANGULAR: Route[] = [ loadChildren: () => import('@dotcms/portlets/dot-users/portlet').then((m) => m.dotUsersRoutes) }, + { + path: 'roles-beta', + canActivate: [MenuGuardService], + canActivateChild: [MenuGuardService], + data: { reuseRoute: false }, + loadChildren: () => + import('@dotcms/portlets/dot-roles/portlet').then((m) => m.dotRolesRoutes) + }, { path: 'query-tool', canActivate: [MenuGuardService], diff --git a/core-web/libs/data-access/src/lib/dot-http-error-manager/dot-http-error-manager.service.spec.ts b/core-web/libs/data-access/src/lib/dot-http-error-manager/dot-http-error-manager.service.spec.ts index 4d65aee95fda..9541b513f833 100644 --- a/core-web/libs/data-access/src/lib/dot-http-error-manager/dot-http-error-manager.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-http-error-manager/dot-http-error-manager.service.spec.ts @@ -37,7 +37,9 @@ describe('DotHttpErrorManagerService', () => { 'dot.common.http.error.400.header': '400 Header', 'dot.common.http.error.400.message': '400 Message', 'dot.common.http.error.204.header': '204 Header', - 'dot.common.http.error.204.message': '204 Message' + 'dot.common.http.error.204.message': '204 Message', + 'dot.common.http.error.409.header': '409 Header', + 'dot.common.http.error.409.message': '409 Message' }); beforeEach(() => { @@ -310,4 +312,47 @@ describe('DotHttpErrorManagerService', () => { header: '204 Header' }); }); + + it('should handle 409 error and surface the BE message', () => { + jest.spyOn(dotDialogService, 'alert'); + + const conflict = new HttpErrorResponse({ + status: 409, + error: { + message: "Role 'test' has 2 child role(s) and cannot be deleted" + } + }); + + service.handle(conflict).subscribe((res) => { + result = res; + }); + + expect(result).toEqual({ + redirected: false, + status: 409 + }); + expect(dotDialogService.alert).toHaveBeenCalledWith({ + footerLabel: { accept: 'dot.common.dialog.accept' }, + message: "Role 'test' has 2 child role(s) and cannot be deleted", + header: '409 Header' + }); + }); + + it('should handle 409 error and fall back to the generic message when the body has none', () => { + jest.spyOn(dotDialogService, 'alert'); + + service.handle(new HttpErrorResponse({ status: 409, error: null })).subscribe((res) => { + result = res; + }); + + expect(result).toEqual({ + redirected: false, + status: 409 + }); + expect(dotDialogService.alert).toHaveBeenCalledWith({ + footerLabel: { accept: 'dot.common.dialog.accept' }, + message: '409 Message', + header: '409 Header' + }); + }); }); diff --git a/core-web/libs/data-access/src/lib/dot-http-error-manager/dot-http-error-manager.service.ts b/core-web/libs/data-access/src/lib/dot-http-error-manager/dot-http-error-manager.service.ts index 6ccab80bdf89..f2e7e5674774 100644 --- a/core-web/libs/data-access/src/lib/dot-http-error-manager/dot-http-error-manager.service.ts +++ b/core-web/libs/data-access/src/lib/dot-http-error-manager/dot-http-error-manager.service.ts @@ -39,6 +39,7 @@ export class DotHttpErrorManagerService { [HttpCode.FORBIDDEN]: this.handleForbidden.bind(this), [HttpCode.SERVER_ERROR]: this.handleServerError.bind(this), [HttpCode.BAD_REQUEST]: this.handleBadRequestError.bind(this), + [HttpCode.CONFLICT]: this.handleConflictError.bind(this), [HttpCode.NO_CONTENT]: this.handleNotContentError.bind(this) }; } @@ -147,6 +148,20 @@ export class DotHttpErrorManagerService { return false; } + private handleConflictError(response?: HttpErrorResponse): boolean { + const header = this.dotMessageService.get('dot.common.http.error.409.header'); + // 409s carry the actionable reason in the body (e.g. "Role has child + // roles and cannot be deleted", "duplicate roleKey"). Surface the BE + // message when present; fall back to a generic string otherwise. + const message = + this.getErrorMessage(response) || + this.dotMessageService.get('dot.common.http.error.409.message'); + + this.showErrorMessage(message, header); + + return false; + } + private handleNotContentError(response?: HttpErrorResponse): boolean { const header = this.dotMessageService.get('dot.common.http.error.204.header'); const message = diff --git a/core-web/libs/dotcms-js/src/lib/core/util/http-code.ts b/core-web/libs/dotcms-js/src/lib/core/util/http-code.ts index 272a9f5e5a89..501417009aec 100644 --- a/core-web/libs/dotcms-js/src/lib/core/util/http-code.ts +++ b/core-web/libs/dotcms-js/src/lib/core/util/http-code.ts @@ -4,5 +4,6 @@ export enum HttpCode { NOT_FOUND = 404, NO_CONTENT = 204, SERVER_ERROR = 500, - UNAUTHORIZED = 401 + UNAUTHORIZED = 401, + CONFLICT = 409 } diff --git a/core-web/libs/portlets/dot-roles/eslint.config.mjs b/core-web/libs/portlets/dot-roles/eslint.config.mjs new file mode 100644 index 000000000000..b56da2ab3209 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/eslint.config.mjs @@ -0,0 +1,34 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../../eslint.config.mjs'; + +export default [ + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + ...baseConfig, + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'dot', + style: 'camelCase' + } + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'dot', + style: 'kebab-case' + } + ] + } + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {} + } +]; diff --git a/core-web/libs/portlets/dot-roles/jest.config.ts b/core-web/libs/portlets/dot-roles/jest.config.ts new file mode 100644 index 000000000000..30efae14f306 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portlets-dot-roles-portlet', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/portlets/dot-roles', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$' + } + ] + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment' + ] +}; diff --git a/core-web/libs/portlets/dot-roles/project.json b/core-web/libs/portlets/dot-roles/project.json new file mode 100644 index 000000000000..613f43e37838 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/project.json @@ -0,0 +1,21 @@ +{ + "name": "portlets-dot-roles-portlet", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/portlets/dot-roles/src", + "prefix": "dot", + "projectType": "library", + "tags": ["type:feature", "scope:dotcms-ui", "portlet:roles-beta"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/portlets/dot-roles/jest.config.ts", + "tsConfig": "libs/portlets/dot-roles/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/core-web/libs/portlets/dot-roles/src/index.ts b/core-web/libs/portlets/dot-roles/src/index.ts new file mode 100644 index 000000000000..44c9365302f3 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/index.ts @@ -0,0 +1 @@ +export * from './lib/lib.routes'; diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-add/dot-roles-add.component.html b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-add/dot-roles-add.component.html new file mode 100644 index 000000000000..46a8ac3a604d --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-add/dot-roles-add.component.html @@ -0,0 +1,103 @@ +
+

{{ 'roles.add.title' | dm }}

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {{ 'roles.form.can-grant' | dm }} + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ + @if ($error()) { +

{{ $error() | dm }}

+ } + +
+ + +
+
diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-add/dot-roles-add.component.spec.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-add/dot-roles-add.component.spec.ts new file mode 100644 index 000000000000..b90c93413634 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-add/dot-roles-add.component.spec.ts @@ -0,0 +1,127 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; + +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotRolesAddComponent } from './dot-roles-add.component'; + +import { DotRolesStore } from '../dot-roles-page/store/dot-roles.store'; + +const MESSAGES = { + 'roles.add.title': 'Add Role', + 'roles.action.save': 'Save', + 'roles.action.cancel': 'Cancel', + 'roles.form.name': 'Role', + 'roles.form.key': 'Key', + 'roles.form.parent': 'Parent', + 'roles.form.description': 'Description', + 'roles.form.can-grant': 'Can Grant', + 'roles.form.users': 'Users', + 'roles.form.permissions': 'Permissions', + 'roles.form.tools': 'Tools' +}; + +describe('DotRolesAddComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotRolesAddComponent, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + detectChanges: false, + providers: [ + mockProvider(DotRolesStore, { + roleTree: jest.fn().mockReturnValue([]), + createRole: jest.fn().mockResolvedValue({ id: 'r-new', name: 'New' }) + }), + mockProvider(DynamicDialogRef, { close: jest.fn() }), + { provide: DynamicDialogConfig, useValue: { data: {} } }, + { provide: DotMessageService, useValue: new MockDotMessageService(MESSAGES) } + ] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should render the required inputs and disabled Save button', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('input-role-name'))).toBeTruthy(); + expect(spectator.query(byTestId('input-description'))).toBeTruthy(); + + const saveBtn = spectator.query(byTestId('btn-save')) as HTMLButtonElement; + expect(saveBtn.disabled).toBe(true); + }); + + it('should enable Save once the required roleName is filled', () => { + spectator.detectChanges(); + + spectator.typeInElement('New Role', byTestId('input-role-name')); + spectator.detectChanges(); + + const saveBtn = spectator.query(byTestId('btn-save')) as HTMLButtonElement; + expect(saveBtn.disabled).toBe(false); + }); + + it('should close the dialog when Cancel is clicked', () => { + const dialogRef = spectator.inject(DynamicDialogRef); + spectator.detectChanges(); + + spectator.click(byTestId('btn-cancel')); + + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('should set the inline error and keep the dialog open when createRole returns null', async () => { + const store = spectator.inject(DotRolesStore); + const dialogRef = spectator.inject(DynamicDialogRef); + // The `mockProvider` factory reuses the same `jest.fn()` across + // tests in this suite — clear before asserting so we only see + // calls from this test. + (dialogRef.close as jest.Mock).mockClear(); + (store.createRole as jest.Mock).mockResolvedValueOnce(null); + + spectator.detectChanges(); + spectator.typeInElement('New Role', byTestId('input-role-name')); + spectator.detectChanges(); + + spectator.click(byTestId('btn-save')); + await Promise.resolve(); + spectator.detectChanges(); + + expect(store.createRole).toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(spectator.query(byTestId('add-error'))).toBeTruthy(); + }); +}); + +describe('DotRolesAddComponent (opened from inline +)', () => { + const createComponent = createComponentFactory({ + component: DotRolesAddComponent, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + detectChanges: false, + providers: [ + mockProvider(DotRolesStore, { + roleTree: jest.fn().mockReturnValue([]), + createRole: jest.fn().mockResolvedValue({ id: 'r-new', name: 'New' }) + }), + mockProvider(DynamicDialogRef, { close: jest.fn() }), + { + provide: DynamicDialogConfig, + useValue: { data: { parentRoleId: 'r-categories' } } + }, + { provide: DotMessageService, useValue: new MockDotMessageService(MESSAGES) } + ] + }); + + it('should prefill the parent from the dialog data', () => { + const spectator = createComponent(); + spectator.detectChanges(); + + expect(spectator.component['form'].controls.parentRoleId.value).toBe('r-categories'); + }); +}); diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-add/dot-roles-add.component.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-add/dot-roles-add.component.ts new file mode 100644 index 000000000000..352c58e60076 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-add/dot-roles-add.component.ts @@ -0,0 +1,111 @@ +import { Component, computed, inject, signal, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { CheckboxModule } from 'primeng/checkbox'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { InputTextModule } from 'primeng/inputtext'; +import { SelectModule } from 'primeng/select'; +import { TextareaModule } from 'primeng/textarea'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotRolesStore } from '../dot-roles-page/store/dot-roles.store'; +import { DotRoleFormValue, DotRoleNode } from '../models/dot-roles.models'; + +interface ParentOption { + label: string; + value: string | null; +} + +/** + * Add Role dialog. POST /v1/roles already exists so this form is fully + * functional. Opened from the `New` button in the roles panel and from + * the inline `+` on a parent row (which prefills `Parent`). + */ +@Component({ + selector: 'dot-roles-add', + standalone: true, + imports: [ + ReactiveFormsModule, + ButtonModule, + InputTextModule, + TextareaModule, + CheckboxModule, + SelectModule, + DotMessagePipe + ], + templateUrl: './dot-roles-add.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotRolesAddComponent { + readonly #store = inject(DotRolesStore); + readonly #fb = inject(FormBuilder); + readonly #ref = inject(DynamicDialogRef); + readonly #config = inject(DynamicDialogConfig); + readonly #messageService = inject(DotMessageService); + + protected readonly $submitting = signal(false); + protected readonly $error = signal(null); + + protected readonly form = this.#fb.nonNullable.group({ + roleName: ['', Validators.required], + roleKey: [''], + parentRoleId: [null as string | null], + canEditUsers: [true], + canEditPermissions: [true], + canEditLayouts: [true], + description: [''] + }); + + protected readonly $parentOptions = computed(() => [ + { label: this.#messageService.get('roles.form.parent.root'), value: null }, + ...this.#flattenRoles(this.#store.roleTree()) + ]); + + constructor() { + const prefilledParent = this.#config.data?.parentRoleId ?? null; + if (prefilledParent) { + this.form.controls.parentRoleId.setValue(prefilledParent); + } + } + + protected onSave(): void { + if (this.form.invalid || this.$submitting()) { + return; + } + + this.$submitting.set(true); + this.$error.set(null); + + const value = this.form.getRawValue() as DotRoleFormValue; + + this.#store.createRole(value).then((created) => { + this.$submitting.set(false); + if (created) { + this.#ref.close(created); + } else { + this.$error.set('roles.add.error'); + } + }); + } + + protected onCancel(): void { + this.#ref.close(); + } + + #flattenRoles(nodes: DotRoleNode[], depth = 0): ParentOption[] { + return nodes.reduce((acc, node) => { + acc.push({ + label: `${' '.repeat(depth * 2)}${node.name}`, + value: node.id + }); + if (node.roleChildren?.length) { + acc.push(...this.#flattenRoles(node.roleChildren, depth + 1)); + } + + return acc; + }, []); + } +} diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-edit/dot-roles-edit.component.html b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-edit/dot-roles-edit.component.html new file mode 100644 index 000000000000..4cb7fd3d2435 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-edit/dot-roles-edit.component.html @@ -0,0 +1,131 @@ +
+

{{ 'roles.edit.title' | dm }}

+
+ +@if (readOnly) { +

+ {{ 'roles.edit.readonly' | dm }} +

+} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {{ 'roles.form.can-grant' | dm }} + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ + @if ($error()) { +

{{ $error() | dm }}

+ } + + + +
+ + +
+ + +
+
+ diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-edit/dot-roles-edit.component.spec.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-edit/dot-roles-edit.component.spec.ts new file mode 100644 index 000000000000..0746e55bb707 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-edit/dot-roles-edit.component.spec.ts @@ -0,0 +1,153 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; +import { EMPTY } from 'rxjs'; + +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +import { ConfirmationService } from 'primeng/api'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotRolesEditComponent } from './dot-roles-edit.component'; + +import { DotRolesStore } from '../dot-roles-page/store/dot-roles.store'; + +const MESSAGES = { + 'roles.edit.title': 'Edit Role', + 'roles.edit.readonly': 'read only', + 'roles.edit.error': 'update failed', + 'roles.delete.blocked': 'delete blocked', + 'roles.action.save': 'Save', + 'roles.action.cancel': 'Cancel', + 'roles.action.delete': 'Delete Role', + 'roles.form.name': 'Role', + 'roles.form.key': 'Key', + 'roles.form.parent': 'Parent', + 'roles.form.parent.root': 'None (top level)', + 'roles.form.description': 'Description', + 'roles.form.can-grant': 'Can grant', + 'roles.form.users': 'Users', + 'roles.form.permissions': 'Permissions', + 'roles.form.tools': 'Tools' +}; + +const BASE_ROLE = { + id: 'r-eco', + name: 'Eco Role', + roleKey: 'eco', + description: 'The eco team', + parent: 'r-eco', + editUsers: true, + editPermissions: true, + editLayouts: true +}; + +const dialogConfig = { data: { role: BASE_ROLE } }; + +describe('DotRolesEditComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotRolesEditComponent, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + detectChanges: false, + componentProviders: [ + mockProvider(ConfirmationService, { + confirm: jest.fn().mockImplementation((cfg) => cfg.accept?.()), + // p-confirmDialog subscribes to these on init + requireConfirmation$: EMPTY, + accept: EMPTY, + reject: EMPTY + }) + ], + providers: [ + mockProvider(DynamicDialogRef, { close: jest.fn() }), + mockProvider(DotRolesStore, { + roleTree: jest.fn().mockReturnValue([]), + updateRole: jest.fn().mockResolvedValue(BASE_ROLE), + deleteRole: jest + .fn() + .mockResolvedValue({ deleted: true, roleId: 'r-eco', usersAffected: 2 }) + }), + { provide: DynamicDialogConfig, useValue: dialogConfig }, + { provide: DotMessageService, useValue: new MockDotMessageService(MESSAGES) } + ] + }); + + beforeEach(() => { + dialogConfig.data = { role: BASE_ROLE }; + spectator = createComponent(); + const store = spectator.inject(DotRolesStore, true); + (store.updateRole as jest.Mock).mockClear(); + (store.updateRole as jest.Mock).mockResolvedValue(BASE_ROLE); + (store.deleteRole as jest.Mock).mockClear(); + (store.deleteRole as jest.Mock).mockResolvedValue({ + deleted: true, + roleId: 'r-eco', + usersAffected: 2 + }); + }); + + it('prefills the form with the role fields', () => { + spectator.detectChanges(); + + const nameInput = spectator.query(byTestId('input-role-name')) as HTMLInputElement; + const keyInput = spectator.query(byTestId('input-role-key')) as HTMLInputElement; + expect(nameInput.value).toBe('Eco Role'); + expect(keyInput.value).toBe('eco'); + }); + + it('calls store.updateRole and closes on submit', async () => { + const store = spectator.inject(DotRolesStore, true); + const dialogRef = spectator.inject(DynamicDialogRef); + spectator.detectChanges(); + + // The submit button carries `data-testid` on the native ` + + + +
+ @if (store.membersStatus() === 'LOADING' || store.selectedRoleStatus() === 'LOADING') { + + + + + {{ 'roles.users.column.name' | dm }} + {{ 'roles.users.column.email' | dm }} + {{ 'roles.users.column.granted-from' | dm }} + + + + + + + + + + + + + } @else if (store.membersStatus() === 'ERROR') { +

+ {{ 'roles.error.load-failed' | dm }} +

+ } @else if (store.members().length === 0) { +
+

+ {{ 'roles.users.empty.title' | dm }} +

+

+ {{ 'roles.users.empty.copy' | dm }} +

+
+ } @else { + + + + + {{ 'roles.users.column.name' | dm }} + {{ 'roles.users.column.email' | dm }} + {{ 'roles.users.column.granted-from' | dm }} + + + + + @let direct = isDirectGrant(member); + @let highlighted = member.userId === $highlightUserId(); + + + {{ member.firstName }} {{ member.lastName }} + {{ member.emailAddress || '—' }} + + + + + + @if (direct && store.canGrantUsers()) { + + + + } + + + + + } +
+ + + + + + + +
+ +
+ {{ user.firstName }} {{ user.lastName }} + {{ user.emailAddress }} +
+
+
+ + @if ($suggestionsLoading()) { + + @for (i of [0, 1, 2, 3, 4]; track i) { +
+ +
+ + +
+
+ } + } @else { + +
+ + search_off + +

+ {{ 'roles.users.search.empty.title' | dm }} +

+
+ } +
+
+
+
+ + diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-role-users-tab/dot-role-users-tab.component.spec.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-role-users-tab/dot-role-users-tab.component.spec.ts new file mode 100644 index 000000000000..016258a3725f --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-role-users-tab/dot-role-users-tab.component.spec.ts @@ -0,0 +1,163 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; +import { EMPTY, of } from 'rxjs'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +import { ConfirmationService } from 'primeng/api'; + +import { DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotRoleUsersTabComponent } from './dot-role-users-tab.component'; + +import { DotRolesPortletService } from '../../../services/dot-roles-portlet.service'; +import { DotRolesStore } from '../../store/dot-roles.store'; + +const MESSAGES = { + 'roles.users.grant': 'Grant to User', + 'roles.users.remove': 'Remove', + 'roles.users.confirm.remove.header': 'Remove user', + 'roles.users.confirm.remove.message': 'Remove {0}?', + 'roles.action.cancel': 'Cancel', + 'roles.users.empty.title': 'No users', + 'roles.users.empty.copy': 'Grant a user', + 'roles.users.search.placeholder': 'Search', + 'roles.users.column.name': 'Name', + 'roles.users.column.email': 'Email', + 'roles.users.column.granted-from': 'Granted From', + loading: 'Loading', + 'roles.error.load-failed': 'Failed' +}; + +describe('DotRoleUsersTabComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotRoleUsersTabComponent, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + detectChanges: false, + imports: [HttpClientTestingModule], + componentProviders: [ + mockProvider(DotRolesStore, { + members: jest.fn().mockReturnValue([]), + membersStatus: jest.fn().mockReturnValue('LOADED'), + selectedRole: jest.fn().mockReturnValue({ + id: 'r-eco', + name: 'Eco Role', + roleKey: 'eco', + editUsers: true + }), + selectedRoleId: jest.fn().mockReturnValue('r-eco'), + selectedRoleStatus: jest.fn().mockReturnValue('LOADED'), + canGrantUsers: jest.fn().mockReturnValue(true), + loadMembers: jest.fn(), + grantUserToRole: jest.fn().mockResolvedValue(null), + removeUsersFromRole: jest.fn().mockResolvedValue(null) + }), + mockProvider(ConfirmationService, { + confirm: jest.fn().mockImplementation((cfg) => cfg.accept?.()), + requireConfirmation$: EMPTY, + accept: EMPTY, + reject: EMPTY + }), + mockProvider(DotRolesPortletService, { + searchUsers: jest.fn().mockReturnValue(of([])) + }) + ], + providers: [ + { provide: DotMessageService, useValue: new MockDotMessageService(MESSAGES) }, + mockProvider(DotHttpErrorManagerService, { handle: jest.fn() }) + ] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should render the empty state when there are no members', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('members-empty'))).toBeTruthy(); + expect(spectator.query(byTestId('members-table'))).toBeNull(); + }); + + it('should render the members table when members are loaded', () => { + const store = spectator.inject(DotRolesStore, true); + (store.members as jest.Mock).mockReturnValue([ + { + userId: 'u-1', + firstName: 'Alan', + lastName: 'Cruz', + emailAddress: 'alan.cruz@dotcms.com', + grantedFromRoleId: 'r-eco', + grantedFromRoleName: 'Eco Role' + } + ]); + spectator.detectChanges(); + + expect(spectator.query(byTestId('members-table'))).toBeTruthy(); + expect(spectator.query(byTestId('member-row-u-1'))).toBeTruthy(); + }); + + it('should render a per-row Remove button ONLY for direct-grant members', () => { + const store = spectator.inject(DotRolesStore, true); + (store.members as jest.Mock).mockReturnValue([ + { + userId: 'u-1', + firstName: 'Alan', + lastName: 'Cruz', + emailAddress: 'alan.cruz@dotcms.com', + grantedFromRoleId: 'r-eco', + grantedFromRoleName: 'Eco Role' + }, + { + userId: 'u-2', + firstName: 'Elena', + lastName: 'Petrov', + emailAddress: 'elena.p@dotcms.com', + grantedFromRoleId: 'r-ancestor', + grantedFromRoleName: 'Ancestor' + } + ]); + spectator.detectChanges(); + + expect(spectator.query(byTestId('member-remove-u-1'))).toBeTruthy(); + expect(spectator.query(byTestId('member-remove-u-2'))).toBeNull(); + }); + + it('should confirm + call removeUsersFromRole with the row user id', async () => { + const store = spectator.inject(DotRolesStore, true); + const member = { + userId: 'u-1', + firstName: 'Alan', + lastName: 'Cruz', + emailAddress: 'alan.cruz@dotcms.com', + grantedFromRoleId: 'r-eco', + grantedFromRoleName: 'Eco Role' + }; + (store.members as jest.Mock).mockReturnValue([member]); + spectator.detectChanges(); + + // Click the row-level trash — PrimeNG wraps the button, so we + // reach through to the inner native + +} diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-detail-header/dot-roles-detail-header.component.spec.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-detail-header/dot-roles-detail-header.component.spec.ts new file mode 100644 index 000000000000..4fce5d25cc9d --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-detail-header/dot-roles-detail-header.component.spec.ts @@ -0,0 +1,99 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; + +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +import { DialogService } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotRolesDetailHeaderComponent } from './dot-roles-detail-header.component'; + +import { DotRolesStore } from '../../store/dot-roles.store'; + +const MESSAGES = { + 'roles.action.edit': 'Edit Role', + 'roles.header.users': 'users', + 'roles.header.tools-granted': 'tools granted', + 'roles.chip.system': 'System', + 'roles.chip.locked': 'Locked' +}; + +describe('DotRolesDetailHeaderComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotRolesDetailHeaderComponent, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + detectChanges: false, + componentProviders: [ + mockProvider(DotRolesStore, { + selectedRole: jest.fn().mockReturnValue(null), + selectedRoleStatus: jest.fn().mockReturnValue('INIT'), + selectedRoleIsParent: jest.fn().mockReturnValue(false), + memberCount: jest.fn().mockReturnValue(0), + isSystemRole: jest.fn().mockReturnValue(false), + canModifyRole: jest.fn().mockReturnValue(true) + }), + mockProvider(DialogService, { open: jest.fn() }) + ], + providers: [{ provide: DotMessageService, useValue: new MockDotMessageService(MESSAGES) }] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should render nothing when no role is selected', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('edit-role-btn'))).toBeNull(); + }); + + it('should render the role name and Edit Role button when a role is selected', () => { + const store = spectator.inject(DotRolesStore, true); + (store.selectedRole as jest.Mock).mockReturnValue({ + id: 'r-eco', + name: 'Eco Role', + children: [] + }); + (store.memberCount as jest.Mock).mockReturnValue(2); + spectator.detectChanges(); + + expect(spectator.query('h1')?.textContent).toContain('Eco Role'); + expect(spectator.query(byTestId('edit-role-btn'))).toBeTruthy(); + }); + + it('should render System and Locked chips when the role is system + locked', () => { + const store = spectator.inject(DotRolesStore, true); + (store.selectedRole as jest.Mock).mockReturnValue({ + id: 'r-cms', + name: 'CMS Admin', + system: true, + locked: true + }); + spectator.detectChanges(); + + expect(spectator.query(byTestId('chip-system'))).toBeTruthy(); + expect(spectator.query(byTestId('chip-locked'))).toBeTruthy(); + }); + + it('should open the Edit Role dialog when the button is clicked', () => { + const dialogService = spectator.inject(DialogService, true); + const store = spectator.inject(DotRolesStore, true); + (store.selectedRole as jest.Mock).mockReturnValue({ id: 'r-eco', name: 'Eco Role' }); + spectator.detectChanges(); + + spectator.click(byTestId('edit-role-btn')); + + expect(dialogService.open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + width: '700px', + closable: true, + closeOnEscape: true, + data: { role: expect.objectContaining({ id: 'r-eco' }) } + }) + ); + }); +}); diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-detail-header/dot-roles-detail-header.component.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-detail-header/dot-roles-detail-header.component.ts new file mode 100644 index 000000000000..76c9f264f0b9 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-detail-header/dot-roles-detail-header.component.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog'; +import { SkeletonModule } from 'primeng/skeleton'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotRolesEditComponent } from '../../../dot-roles-edit/dot-roles-edit.component'; +import { DotRolesStore } from '../../store/dot-roles.store'; + +@Component({ + selector: 'dot-roles-detail-header', + standalone: true, + imports: [ButtonModule, DynamicDialogModule, SkeletonModule, DotMessagePipe], + providers: [DialogService], + templateUrl: './dot-roles-detail-header.component.html', + host: { class: 'block' }, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotRolesDetailHeaderComponent { + protected readonly store = inject(DotRolesStore); + readonly #dialogService = inject(DialogService); + + protected readonly $icon = computed(() => + this.store.selectedRoleIsParent() ? 'folder' : 'shield' + ); + + protected onEditRole(): void { + const role = this.store.selectedRole(); + if (!role) { + return; + } + + this.#dialogService.open(DotRolesEditComponent, { + width: '700px', + closable: true, + closeOnEscape: true, + data: { role } + }); + } +} diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.html b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.html new file mode 100644 index 000000000000..32d121f21e49 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.html @@ -0,0 +1,94 @@ +
+ + {{ 'roles.panel.title' | dm }} + + +
+ +
+ + + search + + + +
+ +@if (store.status() === 'LOADING') { +

+ {{ 'roles.status.loading' | dm }} +

+} @else if (store.status() === 'ERROR') { +

+ {{ 'roles.error.load-failed' | dm }} +

+} @else { + @if ($treeNodes().length > 0) { + + + @let data = node.data; + @let isLeaf = node.leaf === true; + + + + {{ isLeaf ? 'shield' : 'folder' }} + + {{ node.label }} + + + + + } @else { +
+ search_off +

{{ 'roles.tree.empty' | dm }}

+
+ } +} + + + + + diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.spec.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.spec.ts new file mode 100644 index 000000000000..ae3eee726462 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.spec.ts @@ -0,0 +1,101 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; +import { EMPTY } from 'rxjs'; + +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +import { ConfirmationService } from 'primeng/api'; +import { DialogService } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotRolesTreeComponent } from './dot-roles-tree.component'; + +import { DotRolesStore } from '../../store/dot-roles.store'; + +const MESSAGES = { + 'roles.panel.title': 'ROLES', + 'roles.filter.placeholder': 'Filter roles', + 'roles.action.new': 'New', + 'roles.action.add-child': 'Add child', + 'roles.tree.empty': 'No roles', + loading: 'Loading', + 'roles.error.load-failed': 'Failed' +}; + +describe('DotRolesTreeComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotRolesTreeComponent, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + detectChanges: false, + componentProviders: [ + mockProvider(DotRolesStore, { + roles: jest.fn().mockReturnValue([]), + roleTree: jest.fn().mockReturnValue([]), + filteredRoles: jest.fn().mockReturnValue([]), + filter: jest.fn().mockReturnValue(''), + isSearching: jest.fn().mockReturnValue(false), + selectedRoleId: jest.fn().mockReturnValue(null), + status: jest.fn().mockReturnValue('LOADED'), + setFilter: jest.fn(), + selectRole: jest.fn(), + deleteRole: jest.fn().mockResolvedValue(null) + }), + mockProvider(DialogService, { open: jest.fn() }), + mockProvider(ConfirmationService, { + confirm: jest.fn().mockImplementation((cfg) => cfg.accept?.()), + requireConfirmation$: EMPTY, + accept: EMPTY, + reject: EMPTY + }) + ], + providers: [{ provide: DotMessageService, useValue: new MockDotMessageService(MESSAGES) }] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should render the New button and filter input', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('new-role-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('filter-input'))).toBeTruthy(); + }); + + it('should render the empty state when no roles match', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('tree-empty'))).toBeTruthy(); + }); + + it('should render the tree view when roles are loaded', () => { + const store = spectator.inject(DotRolesStore, true); + (store.filteredRoles as jest.Mock).mockReturnValue([ + { id: 'r-eco', name: 'Eco Role', children: [] } + ]); + spectator.detectChanges(); + + expect(spectator.query(byTestId('roles-tree-view'))).toBeTruthy(); + expect(spectator.query(byTestId('tree-empty'))).toBeNull(); + }); + + it('should open the Add Role dialog when the New button is clicked', () => { + const dialogService = spectator.inject(DialogService, true); + spectator.detectChanges(); + + spectator.click(byTestId('new-role-btn')); + + expect(dialogService.open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + width: '700px', + closable: true, + closeOnEscape: true, + data: { parentRoleId: null } + }) + ); + }); +}); diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.ts new file mode 100644 index 000000000000..4f493cac8d14 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.ts @@ -0,0 +1,308 @@ +import { Subject } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + signal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { ConfirmationService, MenuItem, TreeNode } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { ContextMenuModule } from 'primeng/contextmenu'; +import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; +import { + TreeNodeCollapseEvent, + TreeNodeContextMenuSelectEvent, + TreeNodeExpandEvent, + TreeNodeSelectEvent +} from 'primeng/types/tree'; + +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotFolderTreeComponent, DotMessagePipe } from '@dotcms/ui'; + +import { DotRolesAddComponent } from '../../../dot-roles-add/dot-roles-add.component'; +import { DotRolesEditComponent } from '../../../dot-roles-edit/dot-roles-edit.component'; +import { DotRoleNode } from '../../../models/dot-roles.models'; +import { DotRolesStore } from '../../store/dot-roles.store'; + +interface DotRolePrimeTreeNode extends TreeNode { + data: DotRoleNode; + key: string; + label: string; + children?: DotRolePrimeTreeNode[]; +} + +@Component({ + selector: 'dot-roles-tree', + standalone: true, + imports: [ + FormsModule, + ButtonModule, + InputTextModule, + IconFieldModule, + InputIconModule, + DynamicDialogModule, + ContextMenuModule, + ConfirmDialogModule, + DotFolderTreeComponent, + DotMessagePipe + ], + providers: [DialogService, ConfirmationService], + templateUrl: './dot-roles-tree.component.html', + host: { class: 'flex flex-col flex-1 min-h-0 p-4 gap-3' }, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotRolesTreeComponent { + protected readonly store = inject(DotRolesStore); + readonly #destroyRef = inject(DestroyRef); + readonly #dialogService = inject(DialogService); + readonly #confirmationService = inject(ConfirmationService); + readonly #messageService = inject(DotMessageService); + readonly #filterInput$ = new Subject(); + + /** + * Node targeted by the most recent right-click. Kept in a signal so the + * context-menu commands can act on it — PrimeNG's `MenuItem.command` + * fires without an event argument, so it needs a shared reference. + */ + readonly #contextNode = signal(null); + + /** + * Tree context menu: Edit + separator + Delete, no icons, per design. + * `command` reads from `#contextNode` set by `onNodeContextMenuSelect`. + * System / locked roles get a disabled menu — backend rejects them + * anyway (403), so we surface the constraint at click time. + */ + protected readonly $contextMenuItems = computed(() => { + const node = this.#contextNode()?.data; + const isReadOnly = node?.system === true || node?.locked === true; + + return [ + { + label: this.#messageService.get('roles.action.edit'), + disabled: isReadOnly, + command: () => this.#editContextNode() + }, + { separator: true }, + { + label: this.#messageService.get('roles.action.delete'), + disabled: isReadOnly, + command: () => this.#deleteContextNode() + } + ]; + }); + + /** + * Nodes that have been opened at least once — used for lazy-load + * dedup (skip refetch on re-open) and leaf detection (a node that + * has been opened but has zero children is a confirmed leaf). + * Add-only: never removed on collapse, so a re-open of a + * previously-loaded branch does NOT hit the backend again. + */ + readonly #fetchedRoleIds = signal(new Set()); + + /** + * Nodes currently open in the UI. Reflects live expand / collapse + * events. Separate from `#fetchedRoleIds` so collapsing a node + * doesn't invalidate the "we've already loaded these children" + * signal. Empty on first load → tree renders fully collapsed. + */ + readonly #openNodeIds = signal(new Set()); + + // `pt.nodeLabel` only reaches the top-level tree — nested `` + // instances fall back to PrimeNG defaults, so child labels don't + // truncate and the `+` button doesn't land at the trailing edge. + // Applying the label styles as a descendant selector on `root` + // cascades to every label regardless of depth. + protected readonly treePt = { + root: { + class: + 'w-full h-full border-none overflow-y-auto [--p-tree-padding:0] ' + + '[&_.p-tree-node-label]:flex-1 [&_.p-tree-node-label]:overflow-hidden ' + + '[&_.p-tree-node-label]:text-ellipsis [&_.p-tree-node-label]:whitespace-nowrap' + } + }; + + protected readonly $filterInput = computed(() => this.store.filter()); + + /** + * Auto-expand every branch when the store is showing search results + * so the match at the leaf level is visible without the admin + * clicking through ancestors. When no search is active the `expanded` + * state falls back to `#openNodeIds` (user's own open/close history). + */ + protected readonly $treeNodes = computed(() => + this.#toTreeNodes(this.store.filteredRoles(), this.store.isSearching()) + ); + + protected readonly $selectedNode = computed(() => { + const id = this.store.selectedRoleId(); + if (!id) { + return null; + } + + return this.#findNode(this.$treeNodes(), id); + }); + + constructor() { + this.#filterInput$ + .pipe(debounceTime(200), distinctUntilChanged(), takeUntilDestroyed(this.#destroyRef)) + .subscribe((value) => this.store.setFilter(value)); + } + + protected onFilterChange(value: string): void { + this.#filterInput$.next(value); + } + + protected onNodeSelect(event: TreeNodeSelectEvent): void { + const node = event.node as DotRolePrimeTreeNode; + this.store.selectRole(node.data.id); + } + + /** + * PrimeNG lazy-load hook. The backend only returns 2 levels deep per + * request, so any node whose children we haven't fetched yet triggers + * a load on first expand. + */ + protected onNodeExpand(event: TreeNodeExpandEvent): void { + const node = event.node as DotRolePrimeTreeNode; + const id = node.data.id; + + this.#openNodeIds.update((set) => new Set(set).add(id)); + + if (this.#fetchedRoleIds().has(id)) { + return; + } + + this.#fetchedRoleIds.update((set) => new Set(set).add(id)); + + // Only fetch when we don't already have children populated. + const hasLoadedChildren = (node.data.roleChildren?.length ?? 0) > 0; + if (!hasLoadedChildren && !node.data.user) { + this.store.loadRoleChildren(id); + } + } + + protected onNodeCollapse(event: TreeNodeCollapseEvent): void { + const node = event.node as DotRolePrimeTreeNode; + this.#openNodeIds.update((set) => { + const next = new Set(set); + next.delete(node.data.id); + + return next; + }); + } + + protected onAddRole(parentRoleId?: string): void { + this.#dialogService.open(DotRolesAddComponent, { + header: undefined, + width: '700px', + closable: true, + closeOnEscape: true, + data: { parentRoleId: parentRoleId ?? null } + }); + } + + /** + * Bound to ``'s `onNodeContextMenuSelect`. Selects + * the node in the store (so the detail area follows the right-click + * target) and stashes the tree-node reference for the menu commands. + */ + protected onNodeContextMenu(event: TreeNodeContextMenuSelectEvent): void { + const node = event.node as DotRolePrimeTreeNode; + this.#contextNode.set(node); + this.store.selectRole(node.data.id); + } + + // Fetch the full detail before opening — under active search the tree + // node is a thin `{id, name, locked}` and PUT is a full replace, so + // opening the dialog with the partial node would silently wipe + // `parent / roleKey / description / editUsers / editPermissions / + // editLayouts` on save. + async #editContextNode(): Promise { + const node = this.#contextNode(); + if (!node) { + return; + } + + const detail = await this.store.fetchRoleDetail(node.data.id); + if (!detail) { + return; + } + + this.#dialogService.open(DotRolesEditComponent, { + width: '700px', + closable: true, + closeOnEscape: true, + data: { role: detail } + }); + } + + #deleteContextNode(): void { + const node = this.#contextNode(); + if (!node) { + return; + } + + this.#confirmationService.confirm({ + message: this.#messageService.get('roles.confirm.delete.message', node.data.name), + header: this.#messageService.get('roles.confirm.delete.header'), + acceptLabel: this.#messageService.get('Delete'), + rejectLabel: this.#messageService.get('roles.action.cancel'), + rejectButtonStyleClass: 'p-button-text', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + position: 'center', + accept: () => { + this.store.deleteRole(node.data.id); + } + }); + } + + #toTreeNodes(nodes: DotRoleNode[], expandAll = false): DotRolePrimeTreeNode[] { + return nodes.map((node) => { + const children = this.#toTreeNodes(node.roleChildren ?? [], expandAll); + const hasChildren = children.length > 0; + // A node is a confirmed leaf when it's a user-role, OR we've + // fetched its children and got none back. Otherwise we keep + // the chevron so admins can drill into deeper levels. + const confirmedLeaf = + node.user === true || (this.#fetchedRoleIds().has(node.id) && !hasChildren); + + return { + key: node.id, + label: node.name, + data: node, + children, + leaf: confirmedLeaf, + expanded: expandAll || this.#openNodeIds().has(node.id) + }; + }); + } + + #findNode(nodes: DotRolePrimeTreeNode[], id: string): DotRolePrimeTreeNode | null { + for (const node of nodes) { + if (node.data.id === id) { + return node; + } + const found = node.children ? this.#findNode(node.children, id) : null; + if (found) { + return found; + } + } + + return null; + } +} diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/dot-roles-page.component.html b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/dot-roles-page.component.html new file mode 100644 index 000000000000..0a585c352d4b --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/dot-roles-page.component.html @@ -0,0 +1,77 @@ +@let selected = store.selectedRoleId(); + + + + +
+ +
+
+ + +
+ @if (selected) { + + + + + + {{ 'roles.tab.users' | dm }} + + + {{ 'roles.tab.permissions' | dm }} + + + {{ 'roles.tab.tools' | dm }} + + + + + + + + + + @if (store.activeTab() === 'permissions') { + + } + + + @if (store.activeTab() === 'tools') { + + } + + + + } @else { +
+ + left_panel_open + +

{{ 'roles.detail.empty' | dm }}

+
+ } +
+
+
diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/dot-roles-page.component.spec.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/dot-roles-page.component.spec.ts new file mode 100644 index 000000000000..82457f5ad1ce --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/dot-roles-page.component.spec.ts @@ -0,0 +1,116 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; + +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +import { DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotRolesPageComponent } from './dot-roles-page.component'; +import { DotRolesStore } from './store/dot-roles.store'; + +import { DotRolesPortletService } from '../services/dot-roles-portlet.service'; + +const MESSAGES = { + 'roles.detail.empty': 'Select a role', + 'roles.tab.users': 'Users', + 'roles.tab.permissions': 'Permissions', + 'roles.tab.tools': 'Tools', + 'roles.panel.title': 'ROLES', + 'roles.filter.placeholder': 'Filter roles', + 'roles.action.new': 'New', + 'roles.action.edit': 'Edit Role', + 'roles.tree.empty': 'No roles', + 'roles.permissions.select-role': 'Select a role', + 'roles.tools.select-role': 'Select a role', + 'roles.users.grant': 'Grant to User', + 'roles.users.remove': 'Remove', + 'roles.users.remove.blocked': 'blocked', + 'roles.users.empty.title': 'No users', + 'roles.users.empty.copy': 'Grant a user' +}; + +/** + * The page owns child components (tree, detail header, users tab, iframes) + * that all inject `DotRolesStore`. `componentProviders` swaps the store for a + * mock so every child sees the same stub without wiring the full DI tree. + */ +function baseStoreMock(overrides: Record = {}) { + return mockProvider(DotRolesStore, { + roles: jest.fn().mockReturnValue([]), + roleTree: jest.fn().mockReturnValue([]), + filter: jest.fn().mockReturnValue(''), + filteredRoles: jest.fn().mockReturnValue([]), + isSearching: jest.fn().mockReturnValue(false), + searchStatus: jest.fn().mockReturnValue('INIT'), + selectedRoleId: jest.fn().mockReturnValue(null), + selectedRole: jest.fn().mockReturnValue(null), + selectedRoleStatus: jest.fn().mockReturnValue('INIT'), + selectedRoleIsParent: jest.fn().mockReturnValue(false), + activeTab: jest.fn().mockReturnValue('users'), + status: jest.fn().mockReturnValue('LOADED'), + membersStatus: jest.fn().mockReturnValue('LOADED'), + members: jest.fn().mockReturnValue([]), + memberCount: jest.fn().mockReturnValue(0), + isSystemRole: jest.fn().mockReturnValue(false), + canModifyRole: jest.fn().mockReturnValue(true), + fetchRoleDetail: jest.fn(), + canGrantUsers: jest.fn().mockReturnValue(true), + setFilter: jest.fn(), + selectRole: jest.fn(), + setActiveTab: jest.fn(), + loadRootRoles: jest.fn(), + loadMembers: jest.fn(), + loadRoleChildren: jest.fn(), + createRole: jest.fn(), + ...overrides + }); +} + +describe('DotRolesPageComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotRolesPageComponent, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + detectChanges: false, + componentProviders: [baseStoreMock(), mockProvider(DotRolesPortletService)], + providers: [ + { provide: DotMessageService, useValue: new MockDotMessageService(MESSAGES) }, + mockProvider(DotHttpErrorManagerService, { handle: jest.fn() }) + ] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should call store.loadRootRoles on init', () => { + const store = spectator.inject(DotRolesStore, true); + spectator.detectChanges(); + + expect(store.loadRootRoles).toHaveBeenCalled(); + }); + + it('should render the roles tree', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('roles-tree'))).toBeTruthy(); + }); + + it('should render the empty state when no role is selected', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('empty-selection'))).toBeTruthy(); + expect(spectator.query(byTestId('role-detail-tabs'))).toBeNull(); + }); + + it('should render the detail tabs when a role is selected', () => { + const store = spectator.inject(DotRolesStore, true); + (store.selectedRoleId as jest.Mock).mockReturnValue('r-eco'); + (store.selectedRole as jest.Mock).mockReturnValue({ id: 'r-eco', name: 'Eco Role' }); + spectator.detectChanges(); + + expect(spectator.query(byTestId('empty-selection'))).toBeNull(); + expect(spectator.query(byTestId('role-detail-tabs'))).toBeTruthy(); + }); +}); diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/dot-roles-page.component.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/dot-roles-page.component.ts new file mode 100644 index 000000000000..e10984bd376c --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/dot-roles-page.component.ts @@ -0,0 +1,57 @@ +import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core'; + +import { SplitterModule } from 'primeng/splitter'; +import { TabsModule } from 'primeng/tabs'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotRolePermissionsIframeComponent } from './components/dot-role-permissions-iframe/dot-role-permissions-iframe.component'; +import { DotRoleToolsIframeComponent } from './components/dot-role-tools-iframe/dot-role-tools-iframe.component'; +import { DotRoleUsersTabComponent } from './components/dot-role-users-tab/dot-role-users-tab.component'; +import { DotRolesDetailHeaderComponent } from './components/dot-roles-detail-header/dot-roles-detail-header.component'; +import { DotRolesTreeComponent } from './components/dot-roles-tree/dot-roles-tree.component'; +import { DotRolesStore } from './store/dot-roles.store'; + +import { DotRoleTab } from '../models/dot-roles.models'; + +@Component({ + selector: 'dot-roles-page', + standalone: true, + imports: [ + SplitterModule, + TabsModule, + DotMessagePipe, + DotRolesTreeComponent, + DotRolesDetailHeaderComponent, + DotRoleUsersTabComponent, + DotRolePermissionsIframeComponent, + DotRoleToolsIframeComponent + ], + providers: [DotRolesStore], + templateUrl: './dot-roles-page.component.html', + host: { class: 'flex flex-1 min-h-0 block' }, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotRolesPageComponent implements OnInit { + protected readonly store = inject(DotRolesStore); + + // PT overrides for the full-height flex chain from `` down to + // ``. `flex-1` on `tabPanelsPt` (basis 0) is load-bearing: + // the default `1 1 auto` measures the basis from content, which for + // roles with many members squeezes the tablist and clips the active-tab + // underline. See `git log` for the full rationale. + protected readonly splitterPt = { root: { class: 'border-0! rounded-none!' } }; + protected readonly tabsPt = { root: { class: 'flex! flex-col! flex-1! min-h-0!' } }; + protected readonly tabPanelsPt = { + root: { class: 'block! flex-1! min-h-0! overflow-hidden! p-0!' } + }; + protected readonly tabPanelPt = { root: { class: 'block! h-full! p-0!' } }; + + ngOnInit(): void { + this.store.loadRootRoles(); + } + + protected onTabChange(tab: string | number): void { + this.store.setActiveTab(String(tab) as DotRoleTab); + } +} diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.store.spec.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.store.spec.ts new file mode 100644 index 000000000000..c3c73d6bd302 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.store.spec.ts @@ -0,0 +1,605 @@ +import { createServiceFactory, mockProvider, SpectatorService } from '@openng/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { DotHttpErrorManagerService } from '@dotcms/data-access'; + +import { DotRolesStore } from './dot-roles.store'; + +import { DotRoleDetail, DotRoleFormValue, DotRoleNode } from '../../models/dot-roles.models'; +import { + DotRolesPortletService, + DotRoleUserFilterResult +} from '../../services/dot-roles-portlet.service'; + +const ROLE_BY_KEY = { id: 'r-eco', roleKey: 'eco' }; +const ROLE_BY_ID = { id: 'r-nokey', roleKey: null }; + +/** + * Nested shape returned by `GET /v1/roles?loadChildrenRoles=true` — matches + * the real `RoleView` wire format (openapi.json misses the `roleChildren` + * field, but the Java class exposes it). + */ +const MOCK_NESTED_ROLES: DotRoleNode[] = [ + { + id: 'r-categories', + name: 'Categories', + roleKey: 'Categories', + roleChildren: [ + { + id: 'r-eco', + name: 'Eco Role', + parent: 'r-categories', + roleKey: 'eco', + roleChildren: [] + }, + { + id: 'r-snow', + name: 'Snow Role', + parent: 'r-categories', + roleKey: 'snow', + roleChildren: [] + } + ] + }, + { id: 'r-system', name: 'System', system: true, roleKey: 'system', roleChildren: [] } +]; + +const MOCK_ROLE_DETAIL: DotRoleDetail = { + id: 'r-eco', + name: 'Eco Role', + parent: 'r-categories', + roleKey: 'eco', + description: 'Eco team', + editUsers: true +}; + +const MOCK_USER_FILTER_RESULTS: DotRoleUserFilterResult[] = [ + { + userId: 'u-1', + firstName: 'Alan', + lastName: 'Cruz', + emailAddress: 'alan.cruz@dotcms.com' + }, + { + userId: 'u-2', + firstName: 'Elena', + lastName: 'Petrov', + emailAddress: 'elena.p@dotcms.com' + } +]; + +describe('DotRolesStore', () => { + let spectator: SpectatorService>; + let store: InstanceType; + let service: jest.Mocked; + + const createService = createServiceFactory({ + service: DotRolesStore, + providers: [ + mockProvider(DotRolesPortletService, { + loadRootRoles: jest.fn().mockReturnValue(of(MOCK_NESTED_ROLES)), + loadRoleById: jest.fn().mockReturnValue(of(MOCK_ROLE_DETAIL)), + loadRoleMembersByKey: jest.fn().mockReturnValue(of(MOCK_USER_FILTER_RESULTS)), + loadRoleMembersById: jest.fn().mockReturnValue(of(MOCK_USER_FILTER_RESULTS)), + createRole: jest.fn().mockReturnValue(of(MOCK_ROLE_DETAIL)), + updateRole: jest.fn().mockReturnValue(of(MOCK_ROLE_DETAIL)), + deleteRole: jest + .fn() + .mockReturnValue(of({ deleted: true, roleId: 'r-eco', usersAffected: 0 })), + grantUserToRole: jest.fn().mockReturnValue( + of({ + granted: true, + roleId: 'r-eco', + user: { userId: 'u-1' } + }) + ), + removeUsersFromRole: jest + .fn() + .mockReturnValue(of({ removedUserIds: ['u-1'], skipped: [] })), + searchRoles: jest.fn().mockReturnValue(of([])) + }), + mockProvider(DotHttpErrorManagerService) + ] + }); + + beforeEach(() => { + spectator = createService(); + store = spectator.service; + service = spectator.inject(DotRolesPortletService) as jest.Mocked; + jest.clearAllMocks(); + service.loadRootRoles.mockReturnValue(of(MOCK_NESTED_ROLES)); + service.loadRoleById.mockReturnValue(of(MOCK_ROLE_DETAIL)); + service.loadRoleMembersByKey.mockReturnValue(of(MOCK_USER_FILTER_RESULTS)); + service.loadRoleMembersById.mockReturnValue(of(MOCK_USER_FILTER_RESULTS)); + service.createRole.mockReturnValue(of(MOCK_ROLE_DETAIL)); + service.updateRole.mockReturnValue(of(MOCK_ROLE_DETAIL)); + service.deleteRole.mockReturnValue( + of({ deleted: true, roleId: 'r-eco', usersAffected: 0 }) + ); + service.grantUserToRole.mockReturnValue( + of({ granted: true, roleId: 'r-eco', user: { userId: 'u-1' } }) + ); + service.removeUsersFromRole.mockReturnValue(of({ removedUserIds: ['u-1'], skipped: [] })); + }); + + describe('initial state', () => { + it('should start with an empty roles list + init status', () => { + expect(store.roles()).toEqual([]); + expect(store.roleTree()).toEqual([]); + expect(store.status()).toBe('INIT'); + expect(store.selectedRoleId()).toBeNull(); + expect(store.activeTab()).toBe('users'); + expect(store.filter()).toBe(''); + }); + }); + + describe('loadRootRoles', () => { + it('should populate roles and set status to loaded', () => { + store.loadRootRoles(); + + expect(service.loadRootRoles).toHaveBeenCalledWith(true); + expect(store.roles()).toEqual(MOCK_NESTED_ROLES); + expect(store.status()).toBe('LOADED'); + }); + + it('should expose the nested response verbatim via roleTree', () => { + store.loadRootRoles(); + + const tree = store.roleTree(); + expect(tree.map((n) => n.id)).toEqual(['r-categories', 'r-system']); + const categories = tree.find((n) => n.id === 'r-categories'); + expect(categories?.roleChildren?.map((c) => c.id)).toEqual(['r-eco', 'r-snow']); + }); + + it('should set status to error and delegate to httpErrorManager on failure', () => { + const errorManager = spectator.inject(DotHttpErrorManagerService); + service.loadRootRoles.mockReturnValueOnce(throwError(() => new Error('boom'))); + + store.loadRootRoles(); + + expect(store.status()).toBe('ERROR'); + expect(errorManager.handle).toHaveBeenCalled(); + }); + }); + + describe('loadRoleChildren (lazy-load on expand)', () => { + beforeEach(() => { + store.loadRootRoles(); + }); + + it('should splice fetched children into the state tree', async () => { + service.loadRoleById.mockReturnValueOnce( + of({ + id: 'r-eco', + name: 'Eco Role', + parent: 'r-categories', + roleChildren: [ + { + id: 'r-eco-child', + name: 'Eco Child', + parent: 'r-eco', + roleChildren: [] + } + ] + }) + ); + + await store.loadRoleChildren('r-eco'); + + const categories = store.roles().find((n) => n.id === 'r-categories'); + const eco = categories?.roleChildren?.find((n) => n.id === 'r-eco'); + expect(eco?.roleChildren?.map((n) => n.id)).toEqual(['r-eco-child']); + }); + }); + + describe('selectRole', () => { + it('should set selectedRoleId and load the role detail', () => { + store.selectRole('r-eco'); + + expect(store.selectedRoleId()).toBe('r-eco'); + expect(service.loadRoleById).toHaveBeenCalledWith('r-eco', true); + expect(store.selectedRole()).toEqual(MOCK_ROLE_DETAIL); + }); + + it('should clear selection without loading when passed null', () => { + store.selectRole('r-eco'); + jest.clearAllMocks(); + + store.selectRole(null); + + expect(store.selectedRoleId()).toBeNull(); + expect(service.loadRoleById).not.toHaveBeenCalled(); + }); + + it('should clear members when switching roles', () => { + store.loadMembers(ROLE_BY_KEY); + + store.selectRole('r-snow'); + + expect(store.members()).toEqual([]); + }); + }); + + describe('loadMembers', () => { + it('should use the roleKey path when the selected role has a roleKey', () => { + store.loadMembers(ROLE_BY_KEY); + + expect(service.loadRoleMembersByKey).toHaveBeenCalledWith('eco'); + expect(service.loadRoleMembersById).not.toHaveBeenCalled(); + expect(store.members()).toHaveLength(2); + expect(store.membersStatus()).toBe('LOADED'); + }); + + it('should fall back to the roleId path when the role has no roleKey', () => { + store.loadMembers(ROLE_BY_ID); + + expect(service.loadRoleMembersById).toHaveBeenCalledWith('r-nokey'); + expect(service.loadRoleMembersByKey).not.toHaveBeenCalled(); + expect(store.members()).toHaveLength(2); + }); + + it('should walk the ancestor chain, tag each user with grantedFrom, and prefer the closest ancestor on duplicates', () => { + store.loadRootRoles(); + // r-eco lives under r-categories in MOCK_NESTED_ROLES, so the + // chain expected here is [r-eco, r-categories]. + service.loadRoleMembersByKey.mockImplementation((roleKey: string) => { + if (roleKey === 'eco') { + return of([ + { userId: 'u-1', firstName: 'Alan', lastName: 'Cruz', emailAddress: 'a@x' } + ]); + } + if (roleKey === 'Categories') { + return of([ + { + userId: 'u-2', + firstName: 'Chris', + lastName: 'Publisher', + emailAddress: 'c@x' + }, + // u-1 is granted at BOTH r-eco and r-categories — the + // direct grant on r-eco must win over the inherited one. + { userId: 'u-1', firstName: 'Alan', lastName: 'Cruz', emailAddress: 'a@x' } + ]); + } + + return of([]); + }); + + store.selectRole('r-eco'); + store.loadMembers({ id: 'r-eco', roleKey: 'eco' }); + + expect(service.loadRoleMembersByKey).toHaveBeenCalledWith('eco'); + expect(service.loadRoleMembersByKey).toHaveBeenCalledWith('Categories'); + + const members = store.members(); + expect(members).toHaveLength(2); + + const alan = members.find((m) => m.userId === 'u-1'); + const chris = members.find((m) => m.userId === 'u-2'); + expect(alan?.grantedFromRoleId).toBe('r-eco'); + expect(alan?.grantedFromRoleName).toBe('Eco Role'); + expect(chris?.grantedFromRoleId).toBe('r-categories'); + expect(chris?.grantedFromRoleName).toBe('Categories'); + }); + + it('should delegate to httpErrorManager and continue when one ancestor call fails', () => { + const errorManager = spectator.inject(DotHttpErrorManagerService); + service.loadRoleMembersByKey.mockReturnValueOnce(throwError(() => new Error('boom'))); + + store.loadMembers(ROLE_BY_KEY); + + // Partial failure of one ancestor call must not nuke the whole + // tab: the store surfaces the error via the http manager but + // still resolves to `loaded` with whatever succeeded. + expect(errorManager.handle).toHaveBeenCalled(); + expect(store.membersStatus()).toBe('LOADED'); + }); + }); + + describe('setFilter and filteredRoles', () => { + beforeEach(() => { + store.loadRootRoles(); + }); + + it('should return the full tree when the filter is empty', () => { + const filtered = store.filteredRoles(); + expect(filtered.map((n) => n.id)).toEqual(['r-categories', 'r-system']); + expect(store.isSearching()).toBe(false); + }); + + it('should NOT trigger server search for queries under 3 chars', () => { + const searchSpy = jest.spyOn(service, 'searchRoles'); + store.setFilter('ec'); + + expect(searchSpy).not.toHaveBeenCalled(); + expect(store.isSearching()).toBe(false); + // falls back to the full `roles` cache + expect(store.filteredRoles().map((n) => n.id)).toEqual(['r-categories', 'r-system']); + }); + + it('should trigger server search and replace filteredRoles for 3+ chars', () => { + const matchedTree: DotRoleNode[] = [ + { + id: 'r-categories', + name: 'Categories', + roleChildren: [{ id: 'r-eco', name: 'Eco Role', roleChildren: [] }] + } + ]; + (service.searchRoles as jest.Mock).mockReturnValueOnce(of(matchedTree)); + + store.setFilter('eco'); + + expect(service.searchRoles).toHaveBeenCalledWith('eco'); + expect(store.isSearching()).toBe(true); + expect(store.filteredRoles()).toEqual(matchedTree); + expect(store.searchStatus()).toBe('LOADED'); + }); + + it('should return an empty result when the search returns nothing', () => { + (service.searchRoles as jest.Mock).mockReturnValueOnce(of([])); + + store.setFilter('nomatch'); + + expect(store.filteredRoles()).toEqual([]); + expect(store.isSearching()).toBe(true); + }); + + it('should reset to the full tree when the filter is cleared', () => { + (service.searchRoles as jest.Mock).mockReturnValueOnce(of([{ id: 'x', name: 'x' }])); + store.setFilter('anything'); + expect(store.isSearching()).toBe(true); + + store.setFilter(''); + + expect(store.isSearching()).toBe(false); + expect(store.filteredRoles().map((n) => n.id)).toEqual(['r-categories', 'r-system']); + }); + }); + + describe('member count computeds', () => { + beforeEach(() => { + store.selectRole('r-eco'); + store.loadMembers(ROLE_BY_KEY); + }); + + it('should compute total memberCount', () => { + expect(store.memberCount()).toBe(2); + }); + }); + + describe('selectedRoleIsParent and isSystemRole', () => { + it('should mark the selected role as parent when the detail has roleChildren', () => { + service.loadRoleById.mockReturnValueOnce( + of({ + ...MOCK_ROLE_DETAIL, + roleChildren: [{ id: 'child', name: 'child' }] + }) + ); + + store.selectRole('r-eco'); + + expect(store.selectedRoleIsParent()).toBe(true); + }); + + it('should mark the selected role as leaf when the detail has no roleChildren', () => { + store.selectRole('r-eco'); + + expect(store.selectedRoleIsParent()).toBe(false); + }); + + it('isSystemRole should be true only for system roles', () => { + service.loadRoleById.mockReturnValueOnce(of({ ...MOCK_ROLE_DETAIL, system: true })); + + store.selectRole('r-system'); + + expect(store.isSystemRole()).toBe(true); + }); + }); + + describe('canGrantUsers', () => { + it('should reflect the editUsers flag from the selected role', () => { + service.loadRoleById.mockReturnValueOnce(of({ ...MOCK_ROLE_DETAIL, editUsers: false })); + + store.selectRole('r-system'); + + expect(store.canGrantUsers()).toBe(false); + }); + + it('should default to true when no role is selected', () => { + expect(store.canGrantUsers()).toBe(true); + }); + }); + + describe('setActiveTab', () => { + it('should update active tab', () => { + store.setActiveTab('permissions'); + expect(store.activeTab()).toBe('permissions'); + + store.setActiveTab('tools'); + expect(store.activeTab()).toBe('tools'); + }); + }); + + describe('createRole', () => { + const FORM: DotRoleFormValue = { + roleName: 'New Role', + roleKey: '', + parentRoleId: null, + canEditUsers: true, + canEditPermissions: true, + canEditLayouts: true, + description: '' + }; + + it('should POST, append the created role to the roots when parentRoleId is null', async () => { + store.loadRootRoles(); + jest.clearAllMocks(); + service.createRole.mockReturnValue( + of({ id: 'r-new-root', name: 'New Root', roleKey: 'new-root' }) + ); + + const created = await store.createRole(FORM); + + expect(service.createRole).toHaveBeenCalledWith(FORM); + // Local splice — no full reload. + expect(service.loadRootRoles).not.toHaveBeenCalled(); + expect(store.roles().map((n) => n.id)).toContain('r-new-root'); + expect(store.selectedRoleId()).toBe('r-new-root'); + expect(created?.id).toBe('r-new-root'); + }); + + it('should splice into the parent roleChildren when the parent is loaded', async () => { + store.loadRootRoles(); + jest.clearAllMocks(); + service.createRole.mockReturnValue( + of({ + id: 'r-eco-child', + name: 'Eco Child', + parent: 'r-eco', + roleKey: 'eco-child' + }) + ); + + await store.createRole({ ...FORM, parentRoleId: 'r-eco' }); + + // Full reload not triggered. The POST response is hydrated so + // the store also skips the follow-up `loadRoleById(created.id)` + // that used to fire — see the "seed selectedRole directly" + // comment in `createRole`. + expect(service.loadRootRoles).not.toHaveBeenCalled(); + expect(service.loadRoleById).not.toHaveBeenCalled(); + const categories = store.roles().find((n) => n.id === 'r-categories'); + const eco = categories?.roleChildren?.find((n) => n.id === 'r-eco'); + expect(eco?.roleChildren?.map((n) => n.id)).toContain('r-eco-child'); + }); + + it('should refresh just the parent subtree when the parent is not loaded', async () => { + store.loadRootRoles(); + jest.clearAllMocks(); + service.createRole.mockReturnValue( + of({ id: 'r-deep', name: 'Deep', parent: 'r-unloaded', roleKey: 'deep' }) + ); + service.loadRoleById.mockReturnValueOnce( + of({ + id: 'r-unloaded', + name: 'Unloaded Parent', + roleChildren: [{ id: 'r-deep', name: 'Deep', roleChildren: [] }] + }) + ); + + await store.createRole({ ...FORM, parentRoleId: 'r-unloaded' }); + + expect(service.loadRootRoles).not.toHaveBeenCalled(); + // One call for the parent subtree refresh + one for the selected role detail. + expect(service.loadRoleById).toHaveBeenCalledWith('r-unloaded', true); + }); + + it('should delegate to httpErrorManager and return null on failure', async () => { + const errorManager = spectator.inject(DotHttpErrorManagerService); + service.createRole.mockReturnValueOnce(throwError(() => new Error('boom'))); + + const result = await store.createRole(FORM); + + expect(errorManager.handle).toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); + + describe('updateRole', () => { + it('should delegate to httpErrorManager and return null on failure', async () => { + const errorManager = spectator.inject(DotHttpErrorManagerService); + service.updateRole.mockReturnValueOnce(throwError(() => new Error('boom'))); + + const result = await store.updateRole('r-eco', { + roleName: 'Eco', + roleKey: 'eco', + parentRoleId: null, + canEditUsers: true, + canEditPermissions: true, + canEditLayouts: true, + description: '' + }); + + expect(errorManager.handle).toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); + + describe('deleteRole', () => { + beforeEach(() => { + store.loadRootRoles(); + }); + + it('should delegate to httpErrorManager and return null on HTTP failure', async () => { + const errorManager = spectator.inject(DotHttpErrorManagerService); + service.deleteRole.mockReturnValueOnce(throwError(() => new Error('boom'))); + + const result = await store.deleteRole('r-eco'); + + expect(errorManager.handle).toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should leave the tree untouched when the response reports deleted:false', async () => { + service.deleteRole.mockReturnValueOnce( + of({ deleted: false, roleId: 'r-eco', usersAffected: 0 }) + ); + + const result = await store.deleteRole('r-eco'); + + const categories = store.roles().find((n) => n.id === 'r-categories'); + expect(categories?.roleChildren?.map((c) => c.id)).toContain('r-eco'); + expect(result?.deleted).toBe(false); + }); + }); + + describe('grantUserToRole', () => { + beforeEach(() => { + store.loadRootRoles(); + store.selectRole('r-eco'); + }); + + it('should delegate to httpErrorManager and return null on failure', async () => { + const errorManager = spectator.inject(DotHttpErrorManagerService); + service.grantUserToRole.mockReturnValueOnce(throwError(() => new Error('boom'))); + + const result = await store.grantUserToRole('u-1'); + + expect(errorManager.handle).toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should return null without hitting the endpoint when no role is selected', async () => { + store.selectRole(null); + + const result = await store.grantUserToRole('u-1'); + + expect(service.grantUserToRole).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); + + describe('removeUsersFromRole', () => { + beforeEach(() => { + store.loadRootRoles(); + store.selectRole('r-eco'); + }); + + it('should delegate to httpErrorManager and return null on failure', async () => { + const errorManager = spectator.inject(DotHttpErrorManagerService); + service.removeUsersFromRole.mockReturnValueOnce(throwError(() => new Error('boom'))); + + const result = await store.removeUsersFromRole(['u-1']); + + expect(errorManager.handle).toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should short-circuit and return null when userIds is empty', async () => { + const result = await store.removeUsersFromRole([]); + + expect(service.removeUsersFromRole).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.store.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.store.ts new file mode 100644 index 000000000000..99dfcab072c1 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.store.ts @@ -0,0 +1,655 @@ +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, firstValueFrom, forkJoin, of, pipe } from 'rxjs'; + +import { computed, inject } from '@angular/core'; + +import { catchError, map, switchMap, take, tap } from 'rxjs/operators'; + +import { DotHttpErrorManagerService } from '@dotcms/data-access'; + +import { + appendChildToParent, + collectAncestorChain, + findRoleInTree, + mergeTreesPreferParent, + patchNodeChildren, + patchNodeInPlace, + removeNodeFromTree +} from './dot-roles.tree-utils'; + +import { + DotRoleDetail, + DotRoleFormValue, + DotRoleMember, + DotRoleNode, + DotRoleTab, + DotRolesStatus +} from '../../models/dot-roles.models'; +import { + DotRoleDeletionResult, + DotRolesPortletService, + DotRoleUserGrantResult, + DotRoleUsersRemovalResult +} from '../../services/dot-roles-portlet.service'; + +export interface DotRolesState { + /** + * Nested role tree as returned by `GET /v1/roles?loadChildrenRoles=true` + * — root roles with their direct children populated. Grandchildren come + * back empty from the initial call and are lazy-loaded per node when + * the user expands them (see `loadRoleChildren`). + */ + roles: DotRoleNode[]; + /** Free-text filter typed into the `Filter roles` input. */ + filter: string; + /** + * Server-side role search results (from + * `/api/role/loadbyname/name/{q}/`). `null` = no active search → + * the tree shows the normal `roles` cache. Non-null (including + * `[]`) = a search is running/done → the tree shows this list. + * Populated by `searchRoles` when the filter has 3+ chars. + */ + searchResults: DotRoleNode[] | null; + searchStatus: DotRolesStatus; + /** Currently selected role id (drives the right-hand detail area). */ + selectedRoleId: string | null; + /** Detail of the currently selected role (name/desc/can-grant flags/parent). */ + selectedRole: DotRoleDetail | null; + /** Load status for the selected role's detail — drives header skeleton. */ + selectedRoleStatus: DotRolesStatus; + /** Active tab on the right-hand detail area. */ + activeTab: DotRoleTab; + /** Members of the selected role. */ + members: DotRoleMember[]; + status: DotRolesStatus; + membersStatus: DotRolesStatus; + error: string | null; +} + +const initialState: DotRolesState = { + roles: [], + filter: '', + searchResults: null, + searchStatus: 'INIT', + selectedRoleId: null, + selectedRole: null, + selectedRoleStatus: 'INIT', + activeTab: 'users', + members: [], + status: 'INIT', + membersStatus: 'INIT', + error: null +}; + +export const DotRolesStore = signalStore( + withState(initialState), + + withComputed(({ roles, searchResults, selectedRoleId, selectedRole, members }) => ({ + /** Alias for `roles` — the tree comes nested from the wire response. */ + roleTree: computed(() => roles()), + + /** + * Tree rendered by the tree component. When a server-side search + * is active (`searchResults` non-null) the tree switches to the + * search results — which include the full ancestor path of every + * matching role, so grandchildren that were never lazy-loaded + * still surface. Otherwise the normal `roles` cache is used. + */ + filteredRoles: computed(() => { + const results = searchResults(); + + return results !== null ? results : roles(); + }), + + /** True while the tree is showing search results (filter length >= 3). */ + isSearching: computed(() => searchResults() !== null), + + /** Total users granted this role. */ + memberCount: computed(() => members().length), + + /** True when the selected role is a system role (locked / immutable). */ + isSystemRole: computed(() => selectedRole()?.system ?? false), + + // System AND locked roles are rejected by the BE for edit/delete + // (RoleHelper.updateRole / deleteRole return 403). Header + tree + // context menu + edit dialog all key off this so the disabled state + // stays consistent across entry points. Per-domain flags + // (`editUsers`, `editPermissions`, `editLayouts`) are exposed as + // separate computeds — the BE gates each independently so a role + // may be editable overall but block user grants, etc. + canModifyRole: computed(() => { + const role = selectedRole(); + + return !!role && !role.system && !role.locked; + }), + + /** True when the selected role allows editing users assignments. */ + canEditRoleUsers: computed(() => { + const role = selectedRole(); + + return !!role && !role.system && !role.locked && (role.editUsers ?? true); + }), + + /** True when the selected role allows editing its permissions. */ + canEditRolePermissions: computed(() => { + const role = selectedRole(); + + return !!role && !role.system && !role.locked && (role.editPermissions ?? true); + }), + + /** True when the selected role allows editing its layouts / tool groups. */ + canEditRoleLayouts: computed(() => { + const role = selectedRole(); + + return !!role && !role.system && !role.locked && (role.editLayouts ?? true); + }), + + /** True when the selected role can accept user grants. */ + canGrantUsers: computed(() => selectedRole()?.editUsers ?? true), + + /** True when the selected role has children (folder icon in the header). */ + selectedRoleIsParent: computed(() => (selectedRole()?.roleChildren?.length ?? 0) > 0), + + /** Selected role id used by consumers that need to correlate. */ + selectedIdForCorrelation: computed(() => selectedRoleId()) + })), + + withMethods((store) => { + const service = inject(DotRolesPortletService); + const httpErrorManager = inject(DotHttpErrorManagerService); + + const loadRootRoles = rxMethod( + pipe( + tap(() => patchState(store, { status: 'LOADING', error: null })), + switchMap(() => + service.loadRootRoles(true).pipe( + tap((roles) => { + patchState(store, { roles, status: 'LOADED' }); + }), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { status: 'ERROR' }); + + return EMPTY; + }) + ) + ) + ) + ); + + const loadRoleDetail = rxMethod( + pipe( + tap(() => patchState(store, { selectedRoleStatus: 'LOADING' })), + switchMap((roleId) => + service.loadRoleById(roleId, true).pipe( + tap((selectedRole) => + patchState(store, { selectedRole, selectedRoleStatus: 'LOADED' }) + ), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { selectedRoleStatus: 'ERROR' }); + + return EMPTY; + }) + ) + ) + ) + ); + + /** + * Server-side role search backing the tree filter input. + * + * `switchMap` cancels prior in-flight searches when the user keeps + * typing (or hits Backspace), matching the canonical Dojo portlet's + * `filterRolesHandle` timeout gate. Queries under 3 characters + * clear the results — the tree falls back to the normal `roles` + * cache — matching legacy behavior (`view_roles_js_inc.jsp:311`). + */ + const runSearch = rxMethod( + pipe( + switchMap((filter) => { + const q = filter.trim(); + if (q.length < 3) { + patchState(store, { searchResults: null, searchStatus: 'INIT' }); + + return EMPTY; + } + patchState(store, { searchStatus: 'LOADING' }); + + return service.searchRoles(q).pipe( + tap((results) => { + patchState(store, { + searchResults: results, + searchStatus: 'LOADED' + }); + }), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { searchStatus: 'ERROR' }); + + return EMPTY; + }) + ); + }) + ) + ); + + // Ancestor-walk + parallel fan-out that populates `members`. Wrapped + // in `rxMethod` so `switchMap` cancels prior invocations when the + // user switches roles quickly — otherwise an earlier chain could + // resolve *after* a later one and overwrite `members` with stale + // data. Also used post-grant / post-remove to refresh the list. + const loadMembers = rxMethod<{ id: string; roleKey: string | null }>( + pipe( + tap(() => patchState(store, { membersStatus: 'LOADING' })), + switchMap((role) => { + // Under active search, ancestors of the picked role may + // live only in `searchResults` (the lazy tree hasn't + // loaded that branch yet). Merge both — prefer the copy + // that has `parent` populated so the ancestor walk can + // actually climb, since `unwrapLegacySearchNode` nodes + // don't carry `parent`. + const searchTree = store.isSearching() ? (store.searchResults() ?? []) : []; + const chain = collectAncestorChain( + mergeTreesPreferParent(store.roles(), searchTree), + role + ); + if (chain.length === 0) { + patchState(store, { members: [], membersStatus: 'LOADED' }); + + return EMPTY; + } + + const requests = chain.map((node) => { + const source$ = node.roleKey + ? service.loadRoleMembersByKey(node.roleKey) + : service.loadRoleMembersById(node.id); + + return source$.pipe( + map((users) => + users.map((u) => ({ + userId: u.userId, + firstName: u.firstName ?? '', + lastName: u.lastName ?? '', + emailAddress: u.emailAddress ?? '', + grantedFromRoleId: node.id, + grantedFromRoleName: node.name + })) + ), + catchError((error) => { + httpErrorManager.handle(error); + + return of([]); + }) + ); + }); + + return forkJoin(requests).pipe( + tap((batches) => { + const byUserId = new Map(); + for (const batch of batches) { + for (const member of batch) { + if (!byUserId.has(member.userId)) { + byUserId.set(member.userId, member); + } + } + } + patchState(store, { + members: Array.from(byUserId.values()), + membersStatus: 'LOADED' + }); + }), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { membersStatus: 'ERROR' }); + + return EMPTY; + }) + ); + }) + ) + ); + + return { + loadRootRoles, + loadRoleDetail, + + /** + * Update the free-text filter and (for queries with 3+ chars) + * kick off the server-side search via `runSearch`. The component + * already debounces user input, so we forward the value as-is. + */ + setFilter(filter: string): void { + patchState(store, { filter }); + runSearch(filter); + }, + + /** Select a role and load its detail + members. */ + selectRole(roleId: string | null): void { + patchState(store, { + selectedRoleId: roleId, + // Clear the previous role's detail so the header can + // render its skeleton on `selectedRoleStatus === 'LOADING'` + // instead of showing stale data while the new fetch is + // in flight. + selectedRole: null, + selectedRoleStatus: roleId ? 'LOADING' : 'INIT', + members: [], + membersStatus: 'INIT' + }); + + if (roleId) { + loadRoleDetail(roleId); + } + }, + + /** + * Load members for the currently selected role — including + * users granted to any ancestor of the role (dotCMS inherits + * grants downward, so a Reviewer's user list includes users + * granted Publisher / Legal even though those users were never + * granted Reviewer directly). + * + * Implementation: walks the ancestor chain in `state.roles` via + * `parent` id, fires one `/v1/users/filter?roleKey=X` per role + * in parallel (falling back to the id-based endpoint when a + * role has no `roleKey`), and merges results. Each user is + * tagged with the closest ancestor where they were directly + * granted (selected role first, then parent, grandparent, ...), + * so the Users tab can render the "Granted From" chip and + * disable removal on inherited rows. + * + * TODO: collapse this whole flow into a single call to + * `GET /v1/roles/{roleId}/users` once #37070 ships. That + * endpoint will do the ancestor walk + user enrichment + * (including email) server-side. + */ + loadMembers(role: { id: string; roleKey?: string | null }): void { + loadMembers({ id: role.id, roleKey: role.roleKey ?? null }); + }, + + /** + * Lazy-load a node's children when the user expands it in the + * roles tree. Fetches `/v1/roles/{roleId}?loadChildrenRoles=true` + * and splices the returned children into the state tree. + * + * `firstValueFrom(...pipe(take(1)))` disposes the HTTP + * subscription after one emission; spam-expanding tree nodes + * no longer leaks. + */ + async loadRoleChildren(roleId: string): Promise { + try { + const loaded = await firstValueFrom( + service.loadRoleById(roleId, true).pipe(take(1)) + ); + patchState(store, { + roles: patchNodeChildren(store.roles(), roleId, loaded.roleChildren ?? []) + }); + } catch (error) { + httpErrorManager.handle(error); + } + }, + + /** + * On-demand fetch of a role's full detail (no state mutation). + * Callers that need the full form payload (`roleKey`, `parent`, + * `description`, `editUsers/Permissions/Layouts`) must resolve + * it here before opening the Edit dialog — search-result nodes + * only carry `{id, name, locked}`, and PUT is a full replace. + */ + async fetchRoleDetail(roleId: string): Promise { + try { + return await firstValueFrom(service.loadRoleById(roleId, false).pipe(take(1))); + } catch (error) { + httpErrorManager.handle(error); + + return null; + } + }, + + setActiveTab(activeTab: DotRoleTab): void { + patchState(store, { activeTab }); + }, + + /** + * Create a role. Returns a promise so the calling component + * can close its dialog on success and surface errors on failure. + * + * Efficiency: the POST response carries the fully-hydrated new + * role (id, parent, roleKey, DBFQN, FQN...). We splice it into + * the parent's `roleChildren` in state — no follow-up fetch, + * no loss of lazy-loaded branches elsewhere in the tree. + * + * Fallback: when the target parent isn't currently in state + * (edge case — parent picker showed a role we haven't lazy- + * loaded above), we scope the refresh to just that parent's + * subtree via `loadRoleChildren` instead of reloading the whole + * tree. + */ + async createRole(form: DotRoleFormValue): Promise { + try { + const created = await firstValueFrom(service.createRole(form).pipe(take(1))); + + const parentId = form.parentRoleId ?? null; + if (!parentId) { + // Root role — append to state.roles. + patchState(store, { roles: [...store.roles(), created] }); + } else if (findRoleInTree(store.roles(), parentId)) { + // Parent is loaded — splice in place, keep the rest untouched. + patchState(store, { + roles: appendChildToParent(store.roles(), parentId, created) + }); + } else { + // Parent isn't in the loaded tree — await the + // sub-tree refresh so the tree is coherent before + // we resolve. A bare `subscribe` here used to race + // the resolve. + try { + const parentDetail = await firstValueFrom( + service.loadRoleById(parentId, true).pipe(take(1)) + ); + patchState(store, { + roles: patchNodeChildren( + store.roles(), + parentId, + parentDetail.roleChildren ?? [] + ) + }); + } catch (error) { + httpErrorManager.handle(error); + } + } + + // The POST response is already a hydrated `RoleView` — + // seed `selectedRole` directly instead of firing a + // follow-up GET, which would leave the header showing + // a skeleton / stale role for the round-trip. + patchState(store, { + selectedRoleId: created.id, + selectedRole: created, + selectedRoleStatus: 'LOADED' + }); + + return created; + } catch (error) { + httpErrorManager.handle(error); + + return null; + } + }, + + /** + * Update a role (PUT /v1/roles/{roleId}). Returns the hydrated + * updated role on success, or `null` on failure with the + * error routed through `httpErrorManager`. + * + * State reconciliation: + * - `selectedRole` refreshed with the response + * - If the `parent` didn't change, the tree node is patched in place + * - If the `parent` changed (reparent), the node is spliced out of + * the old parent and appended to the new one — no full-tree reload + * + * The response is trusted for parent because the BE encodes root as + * `parent === role.id`. `null` `parentRoleId` in the request becomes + * that self-referential shape in the response. + */ + async updateRole( + roleId: string, + form: DotRoleFormValue + ): Promise { + try { + const updated = await firstValueFrom( + service.updateRole(roleId, form).pipe(take(1)) + ); + + const previous = findRoleInTree(store.roles(), roleId); + const previousParentId = previous?.parent ?? null; + const nextParentId = + updated.parent && updated.parent !== updated.id ? updated.parent : null; + + if (previousParentId === nextParentId) { + patchState(store, { + roles: patchNodeInPlace(store.roles(), roleId, updated), + selectedRole: updated + }); + } else { + const detached = removeNodeFromTree(store.roles(), roleId); + const inserted = nextParentId + ? appendChildToParent(detached, nextParentId, updated) + : [...detached, updated]; + patchState(store, { roles: inserted, selectedRole: updated }); + } + + return updated; + } catch (error) { + httpErrorManager.handle(error); + + return null; + } + }, + + /** + * Delete a role (DELETE /v1/roles/{roleId}). Returns the + * deletion result on success (including `usersAffected`, the + * cascade blast radius) so the caller can surface it in a toast. + * + * State reconciliation: the node is removed from the tree; if it + * was the selected role, selection + members + selectedRole are + * cleared. On BE rejection (403 system/locked, 404, 409 has + * children/workflow), the error routes through `httpErrorManager` + * and this method returns `null`. + */ + async deleteRole(roleId: string): Promise { + try { + const result = await firstValueFrom(service.deleteRole(roleId).pipe(take(1))); + + // Only prune from the tree when the BE actually deleted. + // A 200 with `deleted:false` means the server rejected on + // its own terms (hierarchy constraint / workflow ref) — + // keeping the node in the tree matches reality. + if (result?.deleted) { + patchState(store, { + roles: removeNodeFromTree(store.roles(), roleId) + }); + + if (store.selectedRoleId() === roleId) { + patchState(store, { + selectedRoleId: null, + selectedRole: null, + members: [], + membersStatus: 'INIT' + }); + } + } + + return result; + } catch (error) { + httpErrorManager.handle(error); + + return null; + } + }, + + /** + * Grant a user membership in the currently-selected role + * (POST /v1/roles/{roleId}/users/{userId}). + * + * Refreshes members on success so the Users tab reflects the grant + * (and inherited-vs-direct labelling stays correct). The BE is + * idempotent — re-granting an already-held role returns `granted: + * true` and no error. + * + * Returns the granted result on success, `null` on failure. + */ + async grantUserToRole(userId: string): Promise { + const role = store.selectedRole(); + if (!role) { + return null; + } + + try { + const result = await firstValueFrom( + service.grantUserToRole(role.id, userId).pipe(take(1)) + ); + + // Reload members so the new row lands in the table with the + // correct grantedFromRoleId/Name (matching the selected role). + loadMembers({ id: role.id, roleKey: role.roleKey ?? null }); + + return result; + } catch (error) { + httpErrorManager.handle(error); + + return null; + } + }, + + /** + * Bulk-remove users from the currently-selected role + * (DELETE /v1/roles/{roleId}/users). Returns the + * partial-success report so the caller can surface which rows + * were skipped (typically inherited memberships that can only + * be revoked from the ancestor that grants them). + * + * State reconciliation: on any success, prune the removed users + * out of `members` immediately (optimistic), clear the current + * bulk-selection, and refetch members to reconcile with any BE + * changes we didn't observe. Returns `null` when the request + * itself fails (routed through `httpErrorManager`). + */ + async removeUsersFromRole( + userIds: string[] + ): Promise { + const role = store.selectedRole(); + if (!role || userIds.length === 0) { + return null; + } + + try { + const result = await firstValueFrom( + service.removeUsersFromRole(role.id, userIds).pipe(take(1)) + ); + + if (result.removedUserIds.length > 0) { + const removed = new Set(result.removedUserIds); + patchState(store, { + members: store.members().filter((m) => !removed.has(m.userId)) + }); + } + + // Refresh from the BE too — inherited-vs-direct labelling + // can shift if a user was ONLY direct on this role and now + // ends up inherited from an ancestor still granting them. + loadMembers({ id: role.id, roleKey: role.roleKey ?? null }); + + return result; + } catch (error) { + httpErrorManager.handle(error); + + return null; + } + } + }; + }) +); diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.tree-utils.spec.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.tree-utils.spec.ts new file mode 100644 index 000000000000..44b23f86b0fa --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.tree-utils.spec.ts @@ -0,0 +1,263 @@ +import { + appendChildToParent, + collectAncestorChain, + findRoleInTree, + patchNodeChildren, + patchNodeInPlace, + removeNodeFromTree +} from './dot-roles.tree-utils'; + +import { DotRoleNode } from '../../models/dot-roles.models'; + +/** + * Tree used across the suite: + * + * root-a + * ├─ child-a1 + * │ └─ grand-a1a + * └─ child-a2 + * root-b + * + * `parent` mirrors the Java `RoleAPI.findRoleHierarchy` convention where a + * root's `parent` points to itself. + */ +const node = ( + id: string, + parent: string, + children: DotRoleNode[] = [], + extra: Partial = {} +): DotRoleNode => ({ + id, + name: id.toUpperCase(), + parent, + roleChildren: children, + ...extra +}); + +const buildTree = (): DotRoleNode[] => [ + node('root-a', 'root-a', [ + node('child-a1', 'root-a', [node('grand-a1a', 'child-a1')]), + node('child-a2', 'root-a') + ]), + node('root-b', 'root-b') +]; + +describe('dot-roles.tree-utils', () => { + describe('patchNodeChildren', () => { + it('replaces children on a matching root node', () => { + const tree = buildTree(); + const next = patchNodeChildren(tree, 'root-b', [node('new-b1', 'root-b')]); + + expect(next[1].roleChildren?.map((n) => n.id)).toEqual(['new-b1']); + }); + + it('replaces children on a nested node', () => { + const tree = buildTree(); + const next = patchNodeChildren(tree, 'child-a1', [ + node('grand-a1a', 'child-a1'), + node('grand-a1b', 'child-a1') + ]); + + expect(next[0].roleChildren?.[0].roleChildren?.map((n) => n.id)).toEqual([ + 'grand-a1a', + 'grand-a1b' + ]); + }); + + it('is immutable — never mutates the input tree', () => { + const tree = buildTree(); + const snapshot = JSON.parse(JSON.stringify(tree)); + + patchNodeChildren(tree, 'root-a', []); + + expect(tree).toEqual(snapshot); + }); + + it('returns an equivalent tree when the id is not found', () => { + const tree = buildTree(); + const next = patchNodeChildren(tree, 'nonexistent', [node('x', 'x')]); + + expect(next).toEqual(tree); + }); + }); + + describe('patchNodeInPlace', () => { + it('replaces the node while preserving its existing children', () => { + const tree = buildTree(); + const replacement = node('child-a1', 'root-a', [], { name: 'Renamed' }); + const next = patchNodeInPlace(tree, 'child-a1', replacement); + + const patched = next[0].roleChildren?.[0]; + expect(patched?.name).toBe('Renamed'); + expect(patched?.roleChildren?.map((n) => n.id)).toEqual(['grand-a1a']); + }); + + it('handles a root replacement', () => { + const tree = buildTree(); + const replacement = node('root-a', 'root-a', [], { name: 'Renamed Root' }); + const next = patchNodeInPlace(tree, 'root-a', replacement); + + expect(next[0].name).toBe('Renamed Root'); + expect(next[0].roleChildren?.map((n) => n.id)).toEqual(['child-a1', 'child-a2']); + }); + + it('is immutable', () => { + const tree = buildTree(); + const snapshot = JSON.parse(JSON.stringify(tree)); + + patchNodeInPlace(tree, 'child-a1', node('child-a1', 'root-a', [])); + + expect(tree).toEqual(snapshot); + }); + + it('returns an equivalent tree when the id is not found', () => { + const tree = buildTree(); + const next = patchNodeInPlace(tree, 'nonexistent', node('x', 'x')); + + expect(next).toEqual(tree); + }); + }); + + describe('removeNodeFromTree', () => { + it('drops a root-level node', () => { + const next = removeNodeFromTree(buildTree(), 'root-b'); + + expect(next.map((n) => n.id)).toEqual(['root-a']); + }); + + it('drops a nested node while preserving siblings', () => { + const next = removeNodeFromTree(buildTree(), 'child-a1'); + + expect(next[0].roleChildren?.map((n) => n.id)).toEqual(['child-a2']); + }); + + it('drops a deeply nested node', () => { + const next = removeNodeFromTree(buildTree(), 'grand-a1a'); + + expect(next[0].roleChildren?.[0].roleChildren).toEqual([]); + }); + + it('is immutable', () => { + const tree = buildTree(); + const snapshot = JSON.parse(JSON.stringify(tree)); + + removeNodeFromTree(tree, 'child-a1'); + + expect(tree).toEqual(snapshot); + }); + + it('returns an equivalent tree when the id is not found', () => { + const tree = buildTree(); + + expect(removeNodeFromTree(tree, 'nonexistent')).toEqual(tree); + }); + }); + + describe('appendChildToParent', () => { + it('appends when the parent already has children', () => { + const child = node('new-child', 'root-a'); + const next = appendChildToParent(buildTree(), 'root-a', child); + + expect(next[0].roleChildren?.map((n) => n.id)).toEqual([ + 'child-a1', + 'child-a2', + 'new-child' + ]); + }); + + it('appends when the parent has undefined roleChildren', () => { + const tree: DotRoleNode[] = [{ id: 'r', name: 'R', parent: 'r' }]; + const next = appendChildToParent(tree, 'r', node('n', 'r')); + + expect(next[0].roleChildren?.map((n) => n.id)).toEqual(['n']); + }); + + it('appends to a nested parent', () => { + const child = node('new-grand', 'child-a1'); + const next = appendChildToParent(buildTree(), 'child-a1', child); + + expect(next[0].roleChildren?.[0].roleChildren?.map((n) => n.id)).toEqual([ + 'grand-a1a', + 'new-grand' + ]); + }); + + it('is immutable', () => { + const tree = buildTree(); + const snapshot = JSON.parse(JSON.stringify(tree)); + + appendChildToParent(tree, 'root-a', node('new', 'root-a')); + + expect(tree).toEqual(snapshot); + }); + + it('returns an equivalent tree when the parent id is not found', () => { + const tree = buildTree(); + const next = appendChildToParent(tree, 'nonexistent', node('new', 'x')); + + expect(next).toEqual(tree); + }); + }); + + describe('findRoleInTree', () => { + it('finds a root node', () => { + expect(findRoleInTree(buildTree(), 'root-b')?.id).toBe('root-b'); + }); + + it('finds a nested node', () => { + expect(findRoleInTree(buildTree(), 'child-a1')?.id).toBe('child-a1'); + }); + + it('finds a deeply nested node', () => { + expect(findRoleInTree(buildTree(), 'grand-a1a')?.id).toBe('grand-a1a'); + }); + + it('returns null when the id is not present', () => { + expect(findRoleInTree(buildTree(), 'nonexistent')).toBeNull(); + }); + }); + + describe('collectAncestorChain', () => { + it('returns [role] for a root node (self-parent)', () => { + const chain = collectAncestorChain(buildTree(), { id: 'root-a' }); + + expect(chain.map((n) => n.id)).toEqual(['root-a']); + }); + + it('walks parent → grandparent → root, in order', () => { + const chain = collectAncestorChain(buildTree(), { id: 'grand-a1a' }); + + expect(chain.map((n) => n.id)).toEqual(['grand-a1a', 'child-a1', 'root-a']); + }); + + it('falls back to a synthetic node when the role is not in the tree', () => { + const chain = collectAncestorChain(buildTree(), { + id: 'ghost', + roleKey: 'ghost-key' + }); + + expect(chain).toHaveLength(1); + expect(chain[0]).toEqual({ id: 'ghost', name: 'ghost', roleKey: 'ghost-key' }); + }); + + it('stops when a parent cannot be resolved in the tree', () => { + // `orphan` points to `missing` which is nowhere in the tree. + const orphan = node('orphan', 'missing'); + const tree = [orphan]; + const chain = collectAncestorChain(tree, { id: 'orphan' }); + + expect(chain.map((n) => n.id)).toEqual(['orphan']); + }); + + it('caps the walk at 20 to break accidental cycles', () => { + // Build a chain that self-loops via a two-node cycle: a -> b -> a. + const a: DotRoleNode = { id: 'a', name: 'A', parent: 'b' }; + const b: DotRoleNode = { id: 'b', name: 'B', parent: 'a' }; + const chain = collectAncestorChain([a, b], { id: 'a' }); + + // Without the guard this would loop forever; with the guard it + // returns at most 21 entries (the start + up to 20 iterations). + expect(chain.length).toBeLessThanOrEqual(21); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.tree-utils.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.tree-utils.ts new file mode 100644 index 000000000000..5cd32ab3f557 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-page/store/dot-roles.tree-utils.ts @@ -0,0 +1,192 @@ +import { DotRoleNode } from '../../models/dot-roles.models'; + +/** + * Immutable helpers over the `DotRoleNode[]` tree used by `DotRolesStore`. + * Kept pure so they can be unit-tested without spinning up the signal store, + * and so the store file stays focused on state orchestration. + */ + +/** + * Upper bound on ancestor-chain length. Matches the semantic cap that the + * Java `RoleAPI.findRoleHierarchy` code path assumes; if the BE ever bumps + * this it must be raised here too. Also guards against pathological cycles + * that would otherwise loop forever inside `collectAncestorChain`. + */ +export const MAX_ROLE_DEPTH = 20; + +/** + * Immutably splice `newChildren` into the tree under the node with `id`. + * Returns a new tree; unchanged branches are shared by reference. + */ +export function patchNodeChildren( + nodes: DotRoleNode[], + id: string, + newChildren: DotRoleNode[] +): DotRoleNode[] { + return nodes.map((node) => { + if (node.id === id) { + return { ...node, roleChildren: newChildren }; + } + if (node.roleChildren && node.roleChildren.length > 0) { + return { + ...node, + roleChildren: patchNodeChildren(node.roleChildren, id, newChildren) + }; + } + return node; + }); +} + +/** + * Immutably replace the node with `id`, preserving `roleChildren` from the + * previous version. Used by updateRole when the parent hasn't changed — the + * server response may omit deeper descendants we already lazy-loaded. + */ +export function patchNodeInPlace( + nodes: DotRoleNode[], + id: string, + replacement: DotRoleNode +): DotRoleNode[] { + return nodes.map((node) => { + if (node.id === id) { + return { ...replacement, roleChildren: node.roleChildren ?? [] }; + } + if (node.roleChildren && node.roleChildren.length > 0) { + return { + ...node, + roleChildren: patchNodeInPlace(node.roleChildren, id, replacement) + }; + } + return node; + }); +} + +/** Immutably drop the node with `id` from anywhere in the tree. */ +export function removeNodeFromTree(nodes: DotRoleNode[], id: string): DotRoleNode[] { + return nodes.reduce((acc, node) => { + if (node.id === id) { + return acc; + } + if (node.roleChildren && node.roleChildren.length > 0) { + acc.push({ + ...node, + roleChildren: removeNodeFromTree(node.roleChildren, id) + }); + } else { + acc.push(node); + } + return acc; + }, []); +} + +/** + * Immutably append a newly-created role to its parent's `roleChildren`. + * Sharing branches by reference keeps re-render churn minimal. + */ +export function appendChildToParent( + nodes: DotRoleNode[], + parentId: string, + child: DotRoleNode +): DotRoleNode[] { + return nodes.map((node) => { + if (node.id === parentId) { + return { + ...node, + roleChildren: [...(node.roleChildren ?? []), child] + }; + } + if (node.roleChildren && node.roleChildren.length > 0) { + return { + ...node, + roleChildren: appendChildToParent(node.roleChildren, parentId, child) + }; + } + return node; + }); +} + +/** + * Build the ancestor chain for a role, ordered `[role, parent, grandparent, + * ..., root]`. Matches the Java `RoleAPI.findRoleHierarchy` semantics (a + * self-referential `parent === id` marks the root). The chain drives the + * parallel `/v1/users/filter?roleKey=X` fan-out in `loadMembers`. + */ +export function collectAncestorChain( + tree: DotRoleNode[], + role: { id: string; roleKey?: string | null } +): DotRoleNode[] { + const start = findRoleInTree(tree, role.id) ?? { + id: role.id, + name: role.id, + roleKey: role.roleKey ?? undefined + }; + const chain: DotRoleNode[] = [start]; + let cursor: DotRoleNode | null = start; + // Guard against pathological data — see `MAX_ROLE_DEPTH` doc. + for (let i = 0; i < MAX_ROLE_DEPTH; i++) { + const parentId = cursor.parent; + if (!parentId || parentId === cursor.id) { + break; + } + const parentNode = findRoleInTree(tree, parentId); + if (!parentNode) { + break; + } + chain.push(parentNode); + cursor = parentNode; + } + + return chain; +} + +/** + * Merge two role trees at the root level so lookups can reach any node in + * either. When the same id appears in both, prefer the copy that carries a + * `parent` — search-result nodes lose that field + * (`unwrapLegacySearchNode` only keeps id/name/locked), and + * `collectAncestorChain` needs `parent` to climb. + * + * Only dedupes at the roots; child branches are not merged deeply because + * both trees are self-consistent hierarchies rooted at different depths. + */ +export function mergeTreesPreferParent( + primary: DotRoleNode[], + fallback: DotRoleNode[] +): DotRoleNode[] { + if (fallback.length === 0) { + return primary; + } + const seen = new Map(); + for (const node of primary) { + seen.set(node.id, node); + } + for (const node of fallback) { + const prior = seen.get(node.id); + if (!prior) { + seen.set(node.id, node); + } else if (!prior.parent && node.parent) { + // Fallback carries a `parent`; primary doesn't — prefer the + // fallback so the ancestor walk can actually climb. + seen.set(node.id, node); + } + } + + return Array.from(seen.values()); +} + +/** Walk the tree looking for a node id. Returns the node or `null`. */ +export function findRoleInTree(nodes: DotRoleNode[], id: string): DotRoleNode | null { + for (const node of nodes) { + if (node.id === id) { + return node; + } + if (node.roleChildren && node.roleChildren.length > 0) { + const found = findRoleInTree(node.roleChildren, id); + if (found) { + return found; + } + } + } + + return null; +} diff --git a/core-web/libs/portlets/dot-roles/src/lib/dot-roles-shell/dot-roles-shell.component.ts b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-shell/dot-roles-shell.component.ts new file mode 100644 index 000000000000..9c873811ec6d --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/dot-roles-shell/dot-roles-shell.component.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { DotRolesPageComponent } from '../dot-roles-page/dot-roles-page.component'; + +@Component({ + selector: 'dot-roles-shell', + standalone: true, + imports: [DotRolesPageComponent], + template: '', + host: { class: 'flex flex-col h-full min-h-0 block' }, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotRolesShellComponent {} diff --git a/core-web/libs/portlets/dot-roles/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-roles/src/lib/lib.routes.ts new file mode 100644 index 000000000000..4df5b85d1158 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/lib.routes.ts @@ -0,0 +1,10 @@ +import { Route } from '@angular/router'; + +import { DotRolesShellComponent } from './dot-roles-shell/dot-roles-shell.component'; + +export const dotRolesRoutes: Route[] = [ + { + path: '', + component: DotRolesShellComponent + } +]; diff --git a/core-web/libs/portlets/dot-roles/src/lib/models/dot-roles.models.ts b/core-web/libs/portlets/dot-roles/src/lib/models/dot-roles.models.ts new file mode 100644 index 000000000000..5aa279e5174e --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/models/dot-roles.models.ts @@ -0,0 +1,72 @@ +/** + * Shape returned by `GET /v1/roles` and `GET /v1/roles/{roleid}` — mirrors + * the backend `RoleView`. The wire response is **nested**: each node carries + * its direct children under `roleChildren`. The backend loads only 2 levels + * per request, so grandchildren come back as `roleChildren: []` even when + * they exist. The store lazy-loads deeper levels on `onNodeExpand` in the + * roles tree. + * + * `user: true` marks the role as an individual user-role (roleKey = userId). + * User-roles are always leaves in the tree. + */ +export interface DotRoleNode { + readonly id: string; + readonly name: string; + readonly roleKey?: string; + readonly parent?: string; + readonly system?: boolean; + readonly locked?: boolean; + readonly user?: boolean; + readonly editUsers?: boolean; + readonly editPermissions?: boolean; + readonly editLayouts?: boolean; + /** Direct children — populated by `/v1/roles?loadChildrenRoles=true`. */ + readonly roleChildren?: DotRoleNode[]; +} + +/** Full role detail. Same shape as `DotRoleNode` today. */ +export type DotRoleDetail = DotRoleNode & { + readonly description?: string; + readonly DBFQN?: string; + readonly FQN?: string; +}; + +/** + * Payload for POST /v1/roles (RoleForm). + * Also the shape the Angular Edit Role dialog collects; it PUTs the same + * body against /v1/roles/{roleId} once the endpoint ships (see #36936). + */ +export interface DotRoleFormValue { + roleName: string; + roleKey?: string; + parentRoleId?: string | null; + canEditUsers: boolean; + canEditPermissions: boolean; + canEditLayouts: boolean; + description?: string; +} + +/** + * User row rendered in the Users tab table. + * + * `grantedFromRoleId` / `grantedFromRoleName` identify the ancestor role + * where the user was directly granted. When it matches the currently- + * selected role, the row is a direct grant and can be removed from this + * tab; when it matches an ancestor, the row is inherited and can only + * be removed by editing the ancestor role. The store walks the ancestor + * chain via `RoleAPI.findRoleHierarchy` semantics to populate this. + */ +export interface DotRoleMember { + readonly userId: string; + readonly firstName: string; + readonly lastName: string; + readonly emailAddress: string; + readonly grantedFromRoleId: string; + readonly grantedFromRoleName: string; +} + +export type DotRoleTab = 'users' | 'permissions' | 'tools'; + +// Re-export the shared component-status union so this portlet's status +// fields stay compatible with the rest of the codebase (CLAUDE.md rule). +export type { ComponentStatus as DotRolesStatus } from '@dotcms/dotcms-models'; diff --git a/core-web/libs/portlets/dot-roles/src/lib/services/dot-roles-portlet.service.ts b/core-web/libs/portlets/dot-roles/src/lib/services/dot-roles-portlet.service.ts new file mode 100644 index 000000000000..7962fb3bcb30 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/services/dot-roles-portlet.service.ts @@ -0,0 +1,352 @@ +import { Observable } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; + +import { map, switchMap } from 'rxjs/operators'; + +import { DotCMSResponse } from '@dotcms/dotcms-models'; + +import { + DotRoleUserFilterResult, + LegacyRoleSearchNode, + RoleHierarchyEntry, + sanitizeRoleForm, + toRoleMemberResults, + unwrapLegacySearchNode +} from './dot-roles.adapters'; + +import { DotRoleDetail, DotRoleFormValue, DotRoleNode } from '../models/dot-roles.models'; + +// Re-export public types from the adapters module so external consumers +// (store, tests, components) keep importing them from the service. +export type { DotRoleUserFilterResult } from './dot-roles.adapters'; + +/** + * `UserResource.filter` defaults `per_page=40` — without an explicit override + * a role with more than 40 members silently truncates and the Grant popover + * only shows the first 40 candidates. This value is a bridge until #37070 + * ships a proper server-paged members endpoint. Keeps realistic dotCMS roles + * (typically well under 500 members) whole without hammering the endpoint. + */ +export const USER_FILTER_PAGE_SIZE = 500; + +/** Wire response for DELETE /v1/roles/{roleId} — matches `RoleDeletionView`. */ +export interface DotRoleDeletionResult { + readonly deleted: boolean; + readonly roleId: string; + /** How many users had the role at the moment of the (cascading) deletion. */ + readonly usersAffected: number; +} + +/** Minimal user payload returned by grant / member endpoints. */ +export interface DotRoleMemberUser { + readonly userId: string; + readonly email?: string; + readonly fullName?: string; +} + +/** Wire response for POST /v1/roles/{roleId}/users/{userId} — `RoleUserGrantView`. */ +export interface DotRoleUserGrantResult { + readonly granted: boolean; + readonly roleId: string; + readonly user: DotRoleMemberUser; +} + +/** Per-user skip entry from the bulk-removal response. */ +export interface DotRoleUsersRemovalSkip { + readonly userId: string; + /** `not_found` | `inherited` | `error` — mirrors `SkippedUserView` constants. */ + readonly reason: 'not_found' | 'inherited' | 'error'; +} + +/** Wire response for DELETE /v1/roles/{roleId}/users — `RoleUsersRemovalView`. */ +export interface DotRoleUsersRemovalResult { + readonly removedUserIds: string[]; + readonly skipped: DotRoleUsersRemovalSkip[]; +} + +/** Wire envelope of the legacy Dojo `ItemFileReadStore` search response. */ +interface LegacyRoleSearchResponse { + readonly identifier?: string; + readonly label?: string; + readonly items?: Array<{ + readonly id?: string; + readonly name?: string; + readonly top?: boolean; + readonly children?: LegacyRoleSearchNode[]; + }>; +} + +/** + * Portlet-scoped data access for the Roles and Tools Angular Beta. + * + * Blocked write flows (Edit / Delete role, Grant / Remove user) are called + * out in the individual methods; they resolve to the dedicated v1 endpoints + * once #36936, #36937, #36938, #36939 land. + */ +// `providedIn: 'root'` — the service is stateless (an HTTP-client wrapper), +// so a singleton is safe and lets other portlets / tests inject it without +// having to add it to their `providers` array. Previously component-scoped, +// which worked as long as consumers always rendered under `dot-roles-page`. +@Injectable({ providedIn: 'root' }) +export class DotRolesPortletService { + #http = inject(HttpClient); + + /** + * GET /v1/roles — root roles + their direct children. + * The backend only fills 2 levels; deeper levels are lazy-loaded per + * expand via `loadRoleById`. + */ + loadRootRoles(loadChildren = true): Observable { + return this.#http + .get>(`/api/v1/roles?loadChildrenRoles=${loadChildren}`) + .pipe(map((response) => response.entity ?? [])); + } + + /** + * GET /v1/roles/{roleId} — role detail with its direct children. + * Used both to populate the role detail area on selection and to + * lazy-load grandchildren when the roles tree expands a node. + */ + loadRoleById(roleId: string, loadChildren = true): Observable { + return this.#http + .get< + DotCMSResponse + >(`/api/v1/roles/${roleId}?loadChildrenRoles=${loadChildren}`) + .pipe(map((response) => response.entity)); + } + + /** + * GET /api/role/loadbyname/name/{query}/ — server-side role search + * used by the tree filter. Returns the ancestor path of every role + * whose name matches (case-insensitive substring) so the tree can + * render the matches with their branches expanded — client-side + * filtering can only see roles already in memory, which misses + * unloaded grandchildren. + * + * The endpoint is under `/api/role/*` (pre-v1) and marked + * `deprecated=true` on `RoleResource.loadByName`; it is the only + * REST surface today that performs the deep-tree search, and the + * canonical Dojo portlet has always used it. Migrating this to a + * v1 endpoint (`GET /v1/roles?search=X`) is a BE follow-up outside + * the Beta scope. Kept isolated behind this service method so the + * eventual swap is one file. + * + * Response shape (Dojo `ItemFileReadStore` legacy format): + * ``` + * { identifier: "id", label: "name", items: [{ id: "root", top: true, children: [...] }] } + * ``` + * Each nested node carries `{ id, name, locked, children }` — with + * dashes in `id` replaced by underscores (a Dojo tree DnD-drop + * artifact). We reverse that mapping and drop the synthetic + * "Roles" wrapper node before handing the tree back to the store. + */ + searchRoles(query: string): Observable { + const url = `/api/role/loadbyname/name/${encodeURIComponent(query)}/`; + + return this.#http.get(url).pipe( + map((response) => { + const items = response?.items ?? []; + if (items.length === 0) { + return []; + } + const root = items[0]; + + return (root?.children ?? []).map(unwrapLegacySearchNode); + }) + ); + } + + /** + * GET /v1/users/filter?roleKey=X — users granted the given role. + * + * Fast path (used when the selected role has a `roleKey`). Returns + * users with email/name in a single call. The endpoint requires a + * `roleKey` — roles created via the UI without one need the id-based + * fallback below. + */ + loadRoleMembersByKey(roleKey: string): Observable { + const url = + `/api/v1/users/filter?roleKey=${encodeURIComponent(roleKey)}` + + `&per_page=${USER_FILTER_PAGE_SIZE}`; + + return this.#http + .get>(url) + .pipe(map((response) => response.entity ?? [])); + } + + /** + * GET /v1/users/filter?query=X — free-text user search for the Grant + * popover. Empty `query` returns the first page (used to seed the picker + * on open). `per_page` overrides the endpoint's 40-default; same bridge + * as `loadRoleMembersByKey`. + */ + searchUsers(query: string): Observable { + const params = new URLSearchParams({ per_page: String(USER_FILTER_PAGE_SIZE) }); + if (query) { + params.set('query', query); + } + + return this.#http + .get>(`/api/v1/users/filter?${params}`) + .pipe(map((response) => response.entity ?? [])); + } + + /** + * GET /v1/roles/{roleId}/rolehierarchyanduserroles — fallback used when + * the selected role has no `roleKey`. The endpoint returns a mixed list + * of Role objects: the role itself (or its ancestors when + * `roleHierarchyForAssign=true`), and one entry per user assigned, + * where `role.user === true` and `role.roleKey` holds the userId. + * + * We filter for user-roles and map to `DotRoleUserFilterResult`. The + * response does not carry email — the endpoint returns Role objects, + * not User objects — so members loaded via this path show empty email. + * + * TODO: retire this fallback and the ancestor-walk fan-out in the store + * once `GET /v1/roles/{roleId}/users` ships (issue #37070). That + * endpoint will return `List` with email + granted-from + * metadata in a single call, replacing this whole flow. + */ + loadRoleMembersById(roleId: string): Observable { + const url = `/api/v1/roles/${encodeURIComponent( + roleId + )}/rolehierarchyanduserroles?roleHierarchyForAssign=false`; + + return this.#http + .get>(url) + .pipe(map((response) => toRoleMemberResults(response.entity ?? []))); + } + + /** + * POST /v1/roles — create role (Add Role dialog). + * + * The `cms_role.role_key` column has a UNIQUE constraint at the DB + * level and legacy roles ship with `role_key = ''`, so posting an + * empty string on new roles reliably hits a `duplicate key value + * violates unique constraint` from Postgres. We strip empty-string + * optional fields to `undefined` here (JSON.stringify then omits + * them), which lets the backend persist NULL and satisfy the + * uniqueness contract. + */ + createRole(form: DotRoleFormValue): Observable { + return this.#http + .post>('/api/v1/roles', sanitizeRoleForm(form)) + .pipe(map((response) => response.entity)); + } + + /** + * PUT /v1/roles/{roleId} — update an existing role (Edit Role dialog). + * + * Same body shape as `POST /v1/roles`; response mirrors `GET /v1/roles/{roleId}` + * (a hydrated `RoleView`). Payload sanitization is shared with `createRole` so + * empty `roleKey` / `description` don't hit the DB UNIQUE constraint on + * `role_key`. `parentRoleId = null` reparents to root (BE sets + * `role.setParent(role.getId())`). + * + * Backend error semantics — surfaced by the caller via `httpErrorManager`: + * 400 → cycle in hierarchy (reparent under a descendant) + * 403 → system/locked role, or admin gate + * 404 → role or parent role not found + * 409 → duplicate `roleKey` or duplicate `roleName` under same parent + */ + updateRole(roleId: string, form: DotRoleFormValue): Observable { + return this.#http + .put< + DotCMSResponse + >(`/api/v1/roles/${encodeURIComponent(roleId)}`, sanitizeRoleForm(form)) + .pipe(map((response) => response.entity)); + } + + /** + * DELETE /v1/roles/{roleId} — delete a role (#36939). + * + * Cascading deletion: the response's `usersAffected` reports how many users + * had the role at the moment of deletion (their assignments were dropped). + * Permissions granted to the role and layout / tool-group assignments are + * also removed. Deletion is rejected by the backend for: + * 403 → system or locked roles + * 404 → role not found + * 409 → role has children, or a workflow action references it + * + * Backend errors surface through `httpErrorManager`; the store just + * returns `null` on failure. + */ + deleteRole(roleId: string): Observable { + return this.#http + .delete< + DotCMSResponse + >(`/api/v1/roles/${encodeURIComponent(roleId)}`) + .pipe(map((response) => response.entity)); + } + + /** + * POST /v1/roles/{roleId}/users/{userId} — grant a role to a user (#36937). + * + * Idempotent by design: the BE returns `granted: true` even when the user + * already held the role (directly or via inheritance) — retries are safe. + * The user payload in the response is deliberately slim (id / email / + * fullName); consumers needing more must call the users API. + * + * Error semantics: + * 403 → role's `editUsers` flag is false (workflow / system roles) + * 404 → role or user not found + */ + grantUserToRole(roleId: string, userId: string): Observable { + const url = `/api/v1/roles/${encodeURIComponent(roleId)}/users/${encodeURIComponent( + userId + )}`; + + return this.#http + .post>(url, null) + .pipe(map((response) => response.entity)); + } + + /** + * DELETE /v1/roles/{roleId}/users — bulk-remove members (#36938). + * + * Partial-success semantics: the BE returns `removedUserIds` for the + * users whose direct membership was removed, and a `skipped` list per + * user for the rest (reason: `not_found` / `inherited` / `error`). The + * batch never fails as a whole once the role resolves, so consumers + * should ALWAYS act on both arrays — a 200 does not imply "all removed". + * + * Angular's `HttpClient.delete` needs the body under `options.body`; the + * body wraps `userIds` under `RoleUsersForm` on the BE. + */ + removeUsersFromRole(roleId: string, userIds: string[]): Observable { + return this.#http + .delete< + DotCMSResponse + >(`/api/v1/roles/${encodeURIComponent(roleId)}/users`, { body: { userIds } }) + .pipe(map((response) => response.entity)); + } + + /** + * Reparent a role via `PUT /v1/roles/{roleId}`. Loads the role first so + * the request preserves fields the drag-and-drop UI is not collecting + * (roleKey, description, edit flags) — PUT is a full replace and would + * otherwise wipe them. Pass `null` to move to root. + * + * Staged for the tree drag-and-drop feature — kept here (not deleted) + * so the wiring point stays in one predictable place; the store method + * that will call this lands with the DnD ticket. If DnD is dropped + * from the roadmap, delete this method with the ticket that decides so. + */ + reparentRole(roleId: string, newParentId: string | null): Observable { + return this.loadRoleById(roleId, false).pipe( + switchMap((role) => + this.updateRole(roleId, { + roleName: role.name, + roleKey: role.roleKey, + parentRoleId: newParentId, + canEditUsers: role.editUsers ?? true, + canEditPermissions: role.editPermissions ?? true, + canEditLayouts: role.editLayouts ?? true, + description: role.description + }) + ) + ); + } +} diff --git a/core-web/libs/portlets/dot-roles/src/lib/services/dot-roles.adapters.spec.ts b/core-web/libs/portlets/dot-roles/src/lib/services/dot-roles.adapters.spec.ts new file mode 100644 index 000000000000..73713cd1b6aa --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/services/dot-roles.adapters.spec.ts @@ -0,0 +1,200 @@ +import { + LegacyRoleSearchNode, + RoleHierarchyEntry, + sanitizeRoleForm, + toRoleMemberResults, + unwrapLegacySearchNode +} from './dot-roles.adapters'; + +import { DotRoleFormValue } from '../models/dot-roles.models'; + +describe('dot-roles.adapters', () => { + describe('unwrapLegacySearchNode', () => { + it('restores dashes in the id (Dojo tree DnD artifact)', () => { + const legacy: LegacyRoleSearchNode = { + id: 'abc1a2b3_c4d5_e6f7_8899_aabbccddeeff', + name: 'Editor' + }; + + const result = unwrapLegacySearchNode(legacy); + + expect(result.id).toBe('abc1a2b3-c4d5-e6f7-8899-aabbccddeeff'); + }); + + it('preserves name and locked flag', () => { + const legacy: LegacyRoleSearchNode = { + id: 'a', + name: 'System Role', + locked: true + }; + + expect(unwrapLegacySearchNode(legacy)).toMatchObject({ + name: 'System Role', + locked: true + }); + }); + + it('leaves `locked` undefined when the source omits it (no default)', () => { + const result = unwrapLegacySearchNode({ id: 'a', name: 'Free' }); + + expect(result.locked).toBeUndefined(); + }); + + it('adapts nested children recursively', () => { + const legacy: LegacyRoleSearchNode = { + id: 'aaaaaaaa_1111_1111_1111_111111111111', + name: 'Parent', + children: [ + { + id: 'bbbbbbbb_2222_2222_2222_222222222222', + name: 'Child A', + children: [{ id: 'cccccccc_3333_3333_3333_333333333333', name: 'Grand A' }] + } + ] + }; + + const result = unwrapLegacySearchNode(legacy); + + expect(result.roleChildren?.[0].id).toBe('bbbbbbbb-2222-2222-2222-222222222222'); + expect(result.roleChildren?.[0].roleChildren?.[0].id).toBe( + 'cccccccc-3333-3333-3333-333333333333' + ); + }); + + it('normalizes missing children to an empty array', () => { + const result = unwrapLegacySearchNode({ id: 'a', name: 'A' }); + + expect(result.roleChildren).toEqual([]); + }); + }); + + describe('toRoleMemberResults', () => { + it('filters non-user entries out (ancestors of the role are skipped)', () => { + const entries: RoleHierarchyEntry[] = [ + { id: 'r1', name: 'Reviewer', user: false }, + { id: 'u1', roleKey: 'user-1', name: 'Jane Doe', user: true } + ]; + + const result = toRoleMemberResults(entries); + + expect(result).toHaveLength(1); + expect(result[0].userId).toBe('user-1'); + }); + + it('prefers roleKey as userId, falls back to id', () => { + const entries: RoleHierarchyEntry[] = [ + { id: 'ur-1', roleKey: 'user-key-1', name: 'A', user: true }, + { id: 'ur-2', name: 'B', user: true } + ]; + + expect(toRoleMemberResults(entries).map((r) => r.userId)).toEqual([ + 'user-key-1', + 'ur-2' + ]); + }); + + it('splits `name` on the first space (firstName / lastName)', () => { + const entries: RoleHierarchyEntry[] = [ + { id: 'u', roleKey: 'u', name: 'María del Carmen', user: true } + ]; + + const [row] = toRoleMemberResults(entries); + expect(row.firstName).toBe('María'); + expect(row.lastName).toBe('del Carmen'); + }); + + it('handles single-word names (lastName is empty)', () => { + const [row] = toRoleMemberResults([ + { id: 'u', roleKey: 'u', name: 'Prince', user: true } + ]); + + expect(row.firstName).toBe('Prince'); + expect(row.lastName).toBe(''); + }); + + it('tolerates a missing name', () => { + const [row] = toRoleMemberResults([{ id: 'u', roleKey: 'u', user: true }]); + + expect(row.firstName).toBe(''); + expect(row.lastName).toBe(''); + }); + + it('always returns an empty email (endpoint returns Role objects, not Users)', () => { + const [row] = toRoleMemberResults([{ id: 'u', roleKey: 'u', name: 'X Y', user: true }]); + + expect(row.emailAddress).toBe(''); + }); + + it('returns an empty array when the input has no user rows', () => { + const entries: RoleHierarchyEntry[] = [ + { id: 'a', name: 'A', user: false }, + { id: 'b', name: 'B' } + ]; + + expect(toRoleMemberResults(entries)).toEqual([]); + }); + }); + + describe('sanitizeRoleForm', () => { + const base: DotRoleFormValue = { + roleName: 'New Role', + roleKey: 'new-role', + parentRoleId: 'parent-1', + description: 'desc', + canEditUsers: true, + canEditPermissions: true, + canEditLayouts: true + }; + + it('leaves populated values untouched', () => { + expect(sanitizeRoleForm(base)).toEqual(base); + }); + + it('drops an empty roleKey to undefined so JSON.stringify omits it (avoids UNIQUE violation)', () => { + const result = sanitizeRoleForm({ ...base, roleKey: '' }); + + expect(result.roleKey).toBeUndefined(); + }); + + it('drops a whitespace-only roleKey to undefined', () => { + const result = sanitizeRoleForm({ ...base, roleKey: ' ' }); + + expect(result.roleKey).toBeUndefined(); + }); + + it('trims a padded roleKey', () => { + const result = sanitizeRoleForm({ ...base, roleKey: ' editor ' }); + + expect(result.roleKey).toBe('editor'); + }); + + it('drops an empty description to undefined', () => { + const result = sanitizeRoleForm({ ...base, description: '' }); + + expect(result.description).toBeUndefined(); + }); + + it('drops a whitespace-only description', () => { + const result = sanitizeRoleForm({ ...base, description: '\n \t' }); + + expect(result.description).toBeUndefined(); + }); + + it('coerces a null parentRoleId to undefined so the BE reparents to root', () => { + const result = sanitizeRoleForm({ ...base, parentRoleId: null }); + + expect(result.parentRoleId).toBeUndefined(); + }); + + it('preserves booleans / other primitives verbatim', () => { + const result = sanitizeRoleForm({ + ...base, + canEditUsers: false, + canEditLayouts: false + }); + + expect(result.canEditUsers).toBe(false); + expect(result.canEditLayouts).toBe(false); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-roles/src/lib/services/dot-roles.adapters.ts b/core-web/libs/portlets/dot-roles/src/lib/services/dot-roles.adapters.ts new file mode 100644 index 000000000000..b43526d83f64 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/lib/services/dot-roles.adapters.ts @@ -0,0 +1,105 @@ +import { DotRoleFormValue, DotRoleNode } from '../models/dot-roles.models'; + +/** + * Pure request/response adapters used by `DotRolesPortletService`. Extracted + * out of the service so the delicate mapping logic (legacy Dojo shape, + * hierarchy-vs-user split, empty-string sanitization for the `role_key` + * UNIQUE constraint) can be unit-tested without HTTP mocking. + */ + +/** + * User row shape returned by `/v1/users/filter?roleKey=X`. The endpoint + * accepts `roleKey` (not `roleId`) as the filter parameter. Same wire + * format used by the dot-users portlet. + */ +export interface DotRoleUserFilterResult { + readonly userId: string; + readonly firstName?: string; + readonly lastName?: string; + readonly emailAddress?: string; +} + +/** Legacy `RoleResource.buildFilteredJsonTree` node shape. */ +export interface LegacyRoleSearchNode { + readonly id: string; + readonly name: string; + readonly locked?: boolean; + readonly children?: LegacyRoleSearchNode[]; +} + +/** Row shape from `/v1/roles/{roleId}/rolehierarchyanduserroles`. */ +export interface RoleHierarchyEntry { + readonly id: string; + readonly name?: string; + readonly roleKey?: string; + readonly user?: boolean; +} + +/** + * Matches a UUID-shape id with underscores where a real UUID would have + * dashes — that's the exact wire form the legacy Dojo serializer produces + * (`r.getId().replace('-', '_')` in `RoleResource.buildFilteredJsonTree`). + * Restricting the reverse-replace to this shape avoids mangling user role + * keys that legitimately contain underscores (e.g. `DOTCMS_BACK_END_USER`). + */ +const LEGACY_UUID_WITH_UNDERSCORES = + /^[a-fA-F0-9]{8}_[a-fA-F0-9]{4}_[a-fA-F0-9]{4}_[a-fA-F0-9]{4}_[a-fA-F0-9]{12}$/; + +/** + * Adapt a `LegacyRoleSearchNode` into the modern `DotRoleNode` shape. + * The legacy payload underscores dashes in the id — we only reverse the + * substitution when the id actually matches the UUID-with-underscores + * shape, so ids that legitimately contain underscores are preserved. + */ +export function unwrapLegacySearchNode(node: LegacyRoleSearchNode): DotRoleNode { + return { + id: LEGACY_UUID_WITH_UNDERSCORES.test(node.id) ? node.id.replace(/_/g, '-') : node.id, + name: node.name, + locked: node.locked, + roleChildren: (node.children ?? []).map(unwrapLegacySearchNode) + }; +} + +/** + * Filter and adapt the mixed Role list from + * `/v1/roles/{roleId}/rolehierarchyanduserroles` down to user rows. + * The endpoint returns Role objects for both real roles and users (users + * are Role rows with `user === true` and `roleKey === userId`), and never + * carries email — so the `emailAddress` is always empty here. + */ +export function toRoleMemberResults( + entries: readonly RoleHierarchyEntry[] +): DotRoleUserFilterResult[] { + return entries + .filter((entry) => entry.user === true) + .map((entry) => { + const [firstName = '', ...rest] = (entry.name ?? '').split(' '); + + return { + userId: entry.roleKey ?? entry.id, + firstName, + lastName: rest.join(' '), + emailAddress: '' + }; + }); +} + +/** + * Trim optional string fields and drop them when empty so JSON.stringify + * omits them from the request body — the `cms_role.role_key` column has a + * UNIQUE constraint at the DB level and legacy roles ship with + * `role_key = ''`, so posting `""` reliably trips a duplicate-key violation + * from Postgres. Sending `undefined` lets the BE persist NULL and satisfy + * the uniqueness contract. + */ +export function sanitizeRoleForm(form: DotRoleFormValue): DotRoleFormValue { + const trimmedKey = form.roleKey?.trim(); + const trimmedDescription = form.description?.trim(); + + return { + ...form, + roleKey: trimmedKey ? trimmedKey : undefined, + description: trimmedDescription ? trimmedDescription : undefined, + parentRoleId: form.parentRoleId ?? undefined + }; +} diff --git a/core-web/libs/portlets/dot-roles/src/test-setup.ts b/core-web/libs/portlets/dot-roles/src/test-setup.ts new file mode 100644 index 000000000000..21c728666dee --- /dev/null +++ b/core-web/libs/portlets/dot-roles/src/test-setup.ts @@ -0,0 +1,31 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +import { setupResizeObserverMock } from '@dotcms/utils-testing'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true +}); + +// PrimeNG Tabs (and other layout components) call ResizeObserver on mount. +setupResizeObserverMock(); + +// PrimeNG ConfirmDialog / ContextMenu read `window.matchMedia` for their +// responsive breakpoints; jsdom doesn't ship an implementation, so stub it +// with a "never matches" mock — good enough for unit tests where breakpoint +// behavior isn't asserted. +if (typeof window !== 'undefined' && !window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() + })) + }); +} diff --git a/core-web/libs/portlets/dot-roles/tsconfig.json b/core-web/libs/portlets/dot-roles/tsconfig.json new file mode 100644 index 000000000000..8e2e06fb8ff3 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "target": "es2022", + "moduleResolution": "bundler", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve", + "lib": ["dom", "dom.iterable", "es2022"] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/core-web/libs/portlets/dot-roles/tsconfig.lib.json b/core-web/libs/portlets/dot-roles/tsconfig.lib.json new file mode 100644 index 000000000000..ea591ee88915 --- /dev/null +++ b/core-web/libs/portlets/dot-roles/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "jest.config.ts", "src/test-setup.ts"] +} diff --git a/core-web/libs/portlets/dot-roles/tsconfig.spec.json b/core-web/libs/portlets/dot-roles/tsconfig.spec.json new file mode 100644 index 000000000000..48633a8d638e --- /dev/null +++ b/core-web/libs/portlets/dot-roles/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "bundler", + "isolatedModules": true + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/core-web/libs/ui/src/lib/components/dot-folder-tree/dot-folder-tree.component.html b/core-web/libs/ui/src/lib/components/dot-folder-tree/dot-folder-tree.component.html index 07f1327d4300..8d30c354cf5e 100644 --- a/core-web/libs/ui/src/lib/components/dot-folder-tree/dot-folder-tree.component.html +++ b/core-web/libs/ui/src/lib/components/dot-folder-tree/dot-folder-tree.component.html @@ -8,10 +8,12 @@ [scrollHeight]="$scrollHeight() ?? undefined" [metaKeySelection]="$metaKeySelection()" [pt]="$pt() ?? undefined" + [contextMenu]="$contextMenu() ?? undefined" [attr.data-testid]="$treeTestId()" (onNodeSelect)="onNodeSelect.emit($event)" (onNodeExpand)="onNodeExpand.emit($event)" - (onNodeCollapse)="onNodeCollapse.emit($event)"> + (onNodeCollapse)="onNodeCollapse.emit($event)" + (onNodeContextMenuSelect)="onNodeContextMenuSelect.emit($event)"> @if (nodeLabelTemplate(); as labelTpl) { diff --git a/core-web/libs/ui/src/lib/components/dot-folder-tree/dot-folder-tree.component.ts b/core-web/libs/ui/src/lib/components/dot-folder-tree/dot-folder-tree.component.ts index 3d2c615b6fbd..d068f6465fa6 100644 --- a/core-web/libs/ui/src/lib/components/dot-folder-tree/dot-folder-tree.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-folder-tree/dot-folder-tree.component.ts @@ -13,9 +13,11 @@ import { } from '@angular/core'; import type { TreeNode } from 'primeng/api'; +import { ContextMenu } from 'primeng/contextmenu'; import { Tree, TreeModule } from 'primeng/tree'; import type { TreeNodeCollapseEvent, + TreeNodeContextMenuSelectEvent, TreeNodeExpandEvent, TreeNodeSelectEvent } from 'primeng/types/tree'; @@ -106,9 +108,18 @@ export class DotFolderTreeComponent { */ $styleClass = input('w-full h-full', { alias: 'styleClass' }); + /** + * Optional right-click context menu bound to the underlying ``. + * When provided, PrimeNG opens it on node right-click and emits the + * clicked node through `onNodeContextMenuSelect`. Consumers own the + * menu template and its `MenuItem[]` model. + */ + $contextMenu = input(null, { alias: 'contextMenu' }); + onNodeSelect = output(); onNodeExpand = output(); onNodeCollapse = output(); + onNodeContextMenuSelect = output(); loadMore = output(); /** diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json index 7f66943813db..afdef7a3ec29 100644 --- a/core-web/tsconfig.base.json +++ b/core-web/tsconfig.base.json @@ -115,6 +115,7 @@ "@dotcms/ai/adapter": ["libs/sdk/ai/src/adapter/index.ts"], "@dotcms/ai/spec": ["libs/sdk/ai/src/spec/index.ts"], "@dotcms/vue": ["./libs/sdk/vue/src/index.ts"], + "@dotcms/portlets/dot-roles/portlet": ["libs/portlets/dot-roles/src/index.ts"], "@dotcms/portlets/dot-agents/portlet": ["libs/portlets/dot-agents/src/index.ts"] } }, diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index b12f96349739..d118df0fd69f 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -707,6 +707,7 @@ com.dotcms.repackage.javax.portlet.title.templates=Templates com.dotcms.repackage.javax.portlet.title.time-machine=Time Machine com.dotcms.repackage.javax.portlet.title.users=Users com.dotcms.repackage.javax.portlet.title.users-beta=Users (Beta) +com.dotcms.repackage.javax.portlet.title.roles-beta=Roles (Beta) com.dotcms.repackage.javax.portlet.title.vanity-urls=Vanity URLs com.dotcms.repackage.javax.portlet.title.web-forms=Web Forms com.dotcms.repackage.javax.portlet.title.workflow-schemes=Workflows @@ -5484,6 +5485,8 @@ dot.common.remove=Remove dot.common.view=View ({0}) dot.common.http.error.400.header=Bad request error dot.common.http.error.400.message=Unfortunately there was an error. Please try again, and if the error persists, please contact support. +dot.common.http.error.409.header=Action cannot be completed +dot.common.http.error.409.message=The action conflicts with the current state of the resource. Please try again, and if the error persists, please contact support. editpage.personalization.confirm.header=Personalization editpage.personalization.confirm.message=

Are you sure you want to personalize the content for {0}?

editpage.personalization.delete.confirm.header=Delete personalization @@ -7685,6 +7688,60 @@ users.dialog.replacement-picker.empty=No matches users.dialog.replacement-picker.error=Couldn’t load users. Try again. users.dialog.replacement-picker.loading=Searching… +## Roles Portlet (Beta) — Tools will move to its own portlet in a follow-up +roles.panel.title=ROLES +roles.filter.placeholder=Filter roles +roles.action.new=New +roles.action.edit=Edit Role +roles.action.save=Save +roles.action.cancel=Cancel +roles.action.delete=Delete Role +roles.action.add-child=Add child role +roles.tree.empty=No roles match your filter. +roles.detail.empty=Select a role from the panel on the left. +roles.header.users=users +roles.header.tools-granted=tools granted +roles.chip.system=System +roles.chip.locked=Locked +roles.tab.users=Users +roles.tab.permissions=Permissions +roles.tab.tools=Tools +roles.add.title=Add Role +roles.add.error=Could not create the role. Please try again. +roles.edit.title=Edit Role +roles.edit.readonly=This is a system or locked role. It cannot be edited. +roles.edit.error=Could not save the role. Please try again. +roles.delete.blocked=Deleting a role requires DELETE /api/v1/roles/{roleId} (issue #36939). +roles.delete.error=Could not delete the role. Please try again. +roles.confirm.delete.header=Delete Role +roles.confirm.delete.message=Deleting “{0}” will remove its user assignments, permissions, and tool groups. This action cannot be undone. Child roles must be deleted or reparented first. +roles.users.confirm.remove.header=Remove Users +roles.users.confirm.remove.message=Are you sure you want to remove {0} user(s) from this role? +roles.form.name=Role +roles.form.key=Key +roles.form.parent=Parent +roles.form.parent.root=None (top level) +roles.form.can-grant=Can Grant +roles.form.users=Users +roles.form.permissions=Permissions +roles.form.tools=Tools +roles.form.description=Description +roles.users.grant=Grant to User +roles.users.cannot-grant=This role does not accept user assignments. Edit the role and check "Users" under "Can Grant" to enable this. +roles.users.search.placeholder=Search users +roles.users.search.empty.title=No users found +roles.users.remove=Remove +roles.users.remove.tooltip=Delete from role +roles.users.empty.title=This role has no users yet +roles.users.empty.copy=Grant this role to a user using the selector above. +roles.users.column.name=Name +roles.users.column.email=Email +roles.users.column.granted-from=Granted From +roles.permissions.select-role=Select a role to view its permissions. +roles.tools.select-role=Select a role to view its tools. +roles.status.loading=Loading… +roles.error.load-failed=Failed to load data. Please try again. + ## Tags Portlet tags.search.placeholder=Search tags.export=Export diff --git a/dotCMS/src/main/webapp/WEB-INF/portlet.xml b/dotCMS/src/main/webapp/WEB-INF/portlet.xml index f7d52cd61e2b..147c70fd00eb 100644 --- a/dotCMS/src/main/webapp/WEB-INF/portlet.xml +++ b/dotCMS/src/main/webapp/WEB-INF/portlet.xml @@ -536,6 +536,13 @@ /users + + roles-beta + Roles (Beta) + com.dotcms.spring.portlet.PortletController + /roles-beta + + dotAuth dotAuth diff --git a/dotCMS/src/main/webapp/html/portlet/ext/roleadmin/view_role_iframe_stubs_inc.jsp b/dotCMS/src/main/webapp/html/portlet/ext/roleadmin/view_role_iframe_stubs_inc.jsp new file mode 100644 index 000000000000..35ecdf2c17f0 --- /dev/null +++ b/dotCMS/src/main/webapp/html/portlet/ext/roleadmin/view_role_iframe_stubs_inc.jsp @@ -0,0 +1,81 @@ +<%-- + Neutralizes the portlet-scoped boot chain that `view_roles_js_inc.jsp` + kicks off when the shared JS is loaded from an iframe wrapper that + renders only ONE widget (permissions or tools). + + The shared file registers several `dojo.addOnLoad` callbacks + unconditionally at include time: + - `buildRolesTree()` — builds the roles tree AND the dijit + `roleTreeMenu` context menu, dereferencing multiple DOM elements + (`noRolesFound`, `rolesTreeWrapper`, ...) and the dijit widget + `roleTreeMenu` (line 222) whose absence produces + `Cannot read properties of undefined (reading 'bindDomNode')`. + - The anonymous users-grid init at line 765 — instantiates a + `dojox.grid.DataGrid` on `usersGrid`. + - `initializePortletInfoList()` — creates a `FilteringSelect` on + `portletList`. + + Two-part strategy: + + 1. Provide hidden DOM stubs for the shared file's `dojo.byId(...)` + targets so the anonymous callbacks (which we cannot override) + don't NPE. + + 2. Override the two NAMED initializers (`buildRolesTree`, + `initializePortletInfoList`) with no-ops in a ` diff --git a/dotCMS/src/main/webapp/html/portlet/ext/roleadmin/view_role_permissions_wrapper.jsp b/dotCMS/src/main/webapp/html/portlet/ext/roleadmin/view_role_permissions_wrapper.jsp new file mode 100644 index 000000000000..ab4aabfd9538 --- /dev/null +++ b/dotCMS/src/main/webapp/html/portlet/ext/roleadmin/view_role_permissions_wrapper.jsp @@ -0,0 +1,144 @@ +<%-- + Wrapper JSP that renders ONLY the Roles Admin permissions widget so it + can be embedded inside the Angular Roles and Tools portlet + (`dot-role-permissions-iframe`). + + Scope note (interim, tracked in the Roles migration epic #36909): + the Angular Beta portlet embeds this page via `