diff --git a/src/Umbraco.Web.UI.Client/mocks/data/sets/user-permissions/user-group.data.ts b/src/Umbraco.Web.UI.Client/mocks/data/sets/user-permissions/user-group.data.ts index 0051018c4559..61113da3b084 100644 --- a/src/Umbraco.Web.UI.Client/mocks/data/sets/user-permissions/user-group.data.ts +++ b/src/Umbraco.Web.UI.Client/mocks/data/sets/user-permissions/user-group.data.ts @@ -58,6 +58,46 @@ export const data: Array = [ }, verbs: ['Umb.Document.Delete', 'Umb.Document.Read'], }, + { + $type: 'ElementPermissionPresentationModel', + element: { id: 'permissions-element-read-only-id' }, + verbs: ['Umb.Element.Read'], + }, + { + $type: 'ElementContainerPermissionPresentationModel', + elementContainer: { id: 'permissions-element-read-only-id' }, + verbs: ['Umb.ElementContainer.Create'], + }, + { + $type: 'ElementPermissionPresentationModel', + element: { id: 'permissions-folder-read-only-id' }, + verbs: ['Umb.Element.Create'], + }, + { + $type: 'ElementContainerPermissionPresentationModel', + elementContainer: { id: 'permissions-folder-read-only-id' }, + verbs: ['Umb.ElementContainer.Read'], + }, + { + $type: 'ElementPermissionPresentationModel', + element: { id: 'permissions-both-read-id' }, + verbs: ['Umb.Element.Read'], + }, + { + $type: 'ElementContainerPermissionPresentationModel', + elementContainer: { id: 'permissions-both-read-id' }, + verbs: ['Umb.ElementContainer.Read'], + }, + { + $type: 'ElementPermissionPresentationModel', + element: { id: 'permissions-neither-read-id' }, + verbs: ['Umb.Element.Create'], + }, + { + $type: 'ElementContainerPermissionPresentationModel', + elementContainer: { id: 'permissions-neither-read-id' }, + verbs: ['Umb.ElementContainer.Create'], + }, ], sections: [ 'Umb.Section.Content', diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/element-create-options-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/element-create-options-modal.element.ts index fc50f8b1b313..b715f7cf9397 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/element-create-options-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/element-create-options-modal.element.ts @@ -1,5 +1,9 @@ import { UmbElementTypeStructureRepository } from '../../repository/structure/index.js'; import { UMB_CREATE_ELEMENT_WORKSPACE_PATH_PATTERN } from '../../paths.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_CREATE, +} from '../../user-permissions/constants.js'; import type { UmbAllowedElementTypeModel } from '../../repository/structure/index.js'; import type { UmbElementEntityTypeUnion } from '../../entity.js'; import type { @@ -8,7 +12,7 @@ import type { } from './element-create-options-modal.token.js'; import { css, customElement, html, nothing, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; import { UmbExtensionsApiInitializer } from '@umbraco-cms/backoffice/extension-api'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { createExtensionApiByAlias, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; import type { ManifestEntityCreateOptionAction } from '@umbraco-cms/backoffice/entity-create-option-action'; import type { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api'; @@ -29,8 +33,24 @@ export class UmbElementCreateOptionsModalElement extends UmbModalBaseElement< @state() private _hrefList: Array = []; + constructor() { + super(); + createExtensionApiByAlias(this, UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, [ + { + config: { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_CREATE], + }, + onChange: (permitted: boolean) => { + if (permitted && this._allowedElementTypes.length === 0) { + this.#retrieveAllowedElementTypes(); + } + }, + }, + ]); + } + override async firstUpdated() { - this.#retrieveAllowedElementTypes(); this.#initCreateOptionActions(); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/manifests.ts index cddd737da40a..be28a2af3581 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/manifests.ts @@ -1,6 +1,7 @@ import { UMB_ELEMENT_FOLDER_ENTITY_TYPE, UMB_ELEMENT_ROOT_ENTITY_TYPE } from '../../entity.js'; +import { UMB_USER_PERMISSION_ELEMENT_FOLDER_CREATE } from '../../folder/user-permissions/constants.js'; import { - UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS, UMB_USER_PERMISSION_ELEMENT_CREATE, } from '../../user-permissions/constants.js'; import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; @@ -21,8 +22,9 @@ export const manifests: Array = [ }, conditions: [ { - alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, - allOf: [UMB_USER_PERMISSION_ELEMENT_CREATE], + alias: UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS, + element: { allOf: [UMB_USER_PERMISSION_ELEMENT_CREATE] }, + folder: { allOf: [UMB_USER_PERMISSION_ELEMENT_FOLDER_CREATE] }, }, { alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/manifests.ts index 2e3e9f2aadf6..76f96d936b4b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/manifests.ts @@ -1,3 +1,8 @@ +import { + UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_READ, +} from '../../user-permissions/constants.js'; +import { UMB_USER_PERMISSION_ELEMENT_FOLDER_READ } from '../../folder/user-permissions/constants.js'; import { UMB_CURRENT_USER_ALLOW_ELEMENT_RECYCLE_BIN_CONDITION_ALIAS } from '../conditions/allow-element-recycle-bin.condition.js'; import { UMB_ELEMENT_MENU_ALIAS } from '../../menu/constants.js'; import { UMB_ELEMENT_WORKSPACE_ALIAS } from '../../workspace/constants.js'; @@ -20,7 +25,14 @@ const menuItem: ManifestMenuItemTreeKind = { menus: [UMB_ELEMENT_MENU_ALIAS], treeAlias: UMB_ELEMENT_RECYCLE_BIN_TREE_ALIAS, }, - conditions: [{ alias: UMB_CURRENT_USER_ALLOW_ELEMENT_RECYCLE_BIN_CONDITION_ALIAS }], + conditions: [ + { alias: UMB_CURRENT_USER_ALLOW_ELEMENT_RECYCLE_BIN_CONDITION_ALIAS }, + { + alias: UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS, + element: { allOf: [UMB_USER_PERMISSION_ELEMENT_READ] }, + folder: { allOf: [UMB_USER_PERMISSION_ELEMENT_FOLDER_READ] }, + }, + ], }; const workspaceContext: ManifestWorkspaceContextMenuStructureKind = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/workspace/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/workspace/manifests.ts index 07702345d343..e4e4f4a38db9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/workspace/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/workspace/manifests.ts @@ -1,3 +1,8 @@ +import { + UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_READ, +} from '../../../user-permissions/constants.js'; +import { UMB_USER_PERMISSION_ELEMENT_FOLDER_READ } from '../../../folder/user-permissions/constants.js'; import { UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS } from '../../collection/constants.js'; import { UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../entity.js'; import { UMB_ELEMENT_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS } from './constants.js'; @@ -32,6 +37,11 @@ const workspaceView: ManifestWorkspaceViewCollectionKind = { alias: UMB_WORKSPACE_CONDITION_ALIAS, match: UMB_ELEMENT_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS, }, + { + alias: UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS, + element: { allOf: [UMB_USER_PERMISSION_ELEMENT_READ] }, + folder: { allOf: [UMB_USER_PERMISSION_ELEMENT_FOLDER_READ] }, + }, ], }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/constants.ts index 2f19abf8e00f..3f29c614dabb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/constants.ts @@ -1 +1,3 @@ export const UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS = 'Umb.Condition.UserPermission.Element'; +export const UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS = + 'Umb.Condition.UserPermission.ElementOrElementFolder'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/element-or-element-folder-user-permission.condition.test.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/element-or-element-folder-user-permission.condition.test.ts new file mode 100644 index 000000000000..ca351915e76b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/element-or-element-folder-user-permission.condition.test.ts @@ -0,0 +1,142 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbCurrentUserContext, UmbCurrentUserStore } from '@umbraco-cms/backoffice/current-user'; +import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; +import { UmbAncestorsEntityContext, UmbEntityContext, type UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import { useMockSet } from '@umbraco-cms/internal/mock-manager'; +import { UmbElementOrElementFolderUserPermissionCondition } from './element-or-element-folder-user-permission.condition.js'; +import { UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS } from './constants.js'; +import { UMB_USER_PERMISSION_ELEMENT_READ } from '../constants.js'; +import { UMB_USER_PERMISSION_ELEMENT_FOLDER_READ } from '../../folder/user-permissions/constants.js'; +import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; + +@customElement('test-controller-host-combined-permission') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + currentUserContext = new UmbCurrentUserContext(this); + entityContext = new UmbEntityContext(this); + ancestorsContext = new UmbAncestorsEntityContext(this); + + constructor() { + super(); + new UmbNotificationContext(this); + new UmbCurrentUserStore(this); + } + + async init() { + await this.currentUserContext.load(); + } + + setEntity(entity: UmbEntityModel) { + this.entityContext.setUnique(entity.unique); + this.entityContext.setEntityType(entity.entityType); + } + + setEntityAncestors(ancestors: Array) { + this.ancestorsContext.setAncestors(ancestors); + } +} + +const CONDITION_CONFIG = { + alias: UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS, + element: { allOf: [UMB_USER_PERMISSION_ELEMENT_READ] }, + folder: { allOf: [UMB_USER_PERMISSION_ELEMENT_FOLDER_READ] }, +}; + +describe('UmbElementOrElementFolderUserPermissionCondition', () => { + let hostElement: UmbTestControllerHostElement; + let condition: UmbElementOrElementFolderUserPermissionCondition; + + before(async () => { + await useMockSet('userPermissions'); + }); + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + document.body.appendChild(hostElement); + await hostElement.init(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('Element read only', () => { + it('should be permitted when user has element read but not folder read', (done) => { + hostElement.setEntity({ unique: 'permissions-element-read-only-id', entityType: UMB_ELEMENT_ENTITY_TYPE }); + hostElement.setEntityAncestors([]); + + condition = new UmbElementOrElementFolderUserPermissionCondition(hostElement, { + host: hostElement, + config: CONDITION_CONFIG, + onChange: () => { + expect(condition.permitted).to.be.true; + condition.hostDisconnected(); + done(); + }, + }); + }); + }); + + describe('Folder read only', () => { + it('should be permitted when user has folder read but not element read', (done) => { + hostElement.setEntity({ unique: 'permissions-folder-read-only-id', entityType: UMB_ELEMENT_ENTITY_TYPE }); + hostElement.setEntityAncestors([]); + + condition = new UmbElementOrElementFolderUserPermissionCondition(hostElement, { + host: hostElement, + config: CONDITION_CONFIG, + onChange: () => { + expect(condition.permitted).to.be.true; + condition.hostDisconnected(); + done(); + }, + }); + }); + }); + + describe('Both read', () => { + it('should be permitted when user has both element read and folder read', (done) => { + hostElement.setEntity({ unique: 'permissions-both-read-id', entityType: UMB_ELEMENT_ENTITY_TYPE }); + hostElement.setEntityAncestors([]); + + condition = new UmbElementOrElementFolderUserPermissionCondition(hostElement, { + host: hostElement, + config: CONDITION_CONFIG, + onChange: () => { + expect(condition.permitted).to.be.true; + condition.hostDisconnected(); + done(); + }, + }); + }); + }); + + describe('Neither read', () => { + it('should not be permitted when user has neither element read nor folder read', (done) => { + hostElement.setEntity({ unique: 'permissions-neither-read-id', entityType: UMB_ELEMENT_ENTITY_TYPE }); + hostElement.setEntityAncestors([]); + + condition = new UmbElementOrElementFolderUserPermissionCondition(hostElement, { + host: hostElement, + config: CONDITION_CONFIG, + onChange: () => { + // onChange fires only when permitted transitions; if it ever transitions to true that is a failure + if (condition.permitted) { + condition.hostDisconnected(); + done(new Error('Expected condition to remain not permitted')); + } + }, + }); + + // Safe to assert after 200ms: mock contexts resolve synchronously, so both + // sub-conditions have evaluated well within this window. The initial default + // is also false, so a pass here confirms the condition never became true. + setTimeout(() => { + expect(condition.permitted).to.be.false; + condition.hostDisconnected(); + done(); + }, 200); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/element-or-element-folder-user-permission.condition.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/element-or-element-folder-user-permission.condition.ts new file mode 100644 index 000000000000..0fda647c73db --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/element-or-element-folder-user-permission.condition.ts @@ -0,0 +1,46 @@ +import type { UmbElementOrElementFolderUserPermissionConditionConfig } from './types.js'; +import { UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS } from './constants.js'; +import { UmbElementUserPermissionCondition } from './element-user-permission.condition.js'; +import { UmbElementFolderUserPermissionCondition } from '../../folder/user-permissions/conditions/element-folder-user-permission.condition.js'; +import { UMB_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS } from '../../folder/user-permissions/conditions/constants.js'; +import { UmbConditionBase } from '@umbraco-cms/backoffice/extension-registry'; +import type { UmbConditionControllerArguments, UmbExtensionCondition } from '@umbraco-cms/backoffice/extension-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +/** Permits when the user has the configured element OR element folder (container) permissions. */ +export class UmbElementOrElementFolderUserPermissionCondition + extends UmbConditionBase + implements UmbExtensionCondition +{ + #elementCondition: UmbElementUserPermissionCondition | undefined; + #folderCondition: UmbElementFolderUserPermissionCondition | undefined; + + constructor( + host: UmbControllerHost, + args: UmbConditionControllerArguments, + ) { + super(host, args); + + if (this.config.element) { + this.#elementCondition = new UmbElementUserPermissionCondition(this, { + host: this, + config: { alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, ...this.config.element }, + onChange: () => this.#evaluate(), + }); + } + + if (this.config.folder) { + this.#folderCondition = new UmbElementFolderUserPermissionCondition(this, { + host: this, + config: { alias: UMB_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS, ...this.config.folder }, + onChange: () => this.#evaluate(), + }); + } + } + + #evaluate() { + this.permitted = (this.#elementCondition?.permitted ?? false) || (this.#folderCondition?.permitted ?? false); + } +} + +export { UmbElementOrElementFolderUserPermissionCondition as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/index.ts index 7d81657be568..792ed9b0c145 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/index.ts @@ -1 +1,2 @@ +export { UmbElementOrElementFolderUserPermissionCondition } from './element-or-element-folder-user-permission.condition.js'; export { UmbElementUserPermissionCondition } from './element-user-permission.condition.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/manifests.ts index 1fbce7d90895..7e293444063d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/manifests.ts @@ -1,4 +1,8 @@ -import { UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS } from './constants.js'; +import { + UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS, + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, +} from './constants.js'; +import { UmbElementOrElementFolderUserPermissionCondition } from './element-or-element-folder-user-permission.condition.js'; import { UmbElementUserPermissionCondition } from './element-user-permission.condition.js'; export const manifests: Array = [ @@ -8,4 +12,10 @@ export const manifests: Array = [ alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, api: UmbElementUserPermissionCondition, }, + { + type: 'condition', + name: 'Element or Element Folder User Permission Condition', + alias: UMB_ELEMENT_OR_ELEMENT_FOLDER_USER_PERMISSION_CONDITION_ALIAS, + api: UmbElementOrElementFolderUserPermissionCondition, + }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/types.ts index dd7cfbefff97..d24cf4e7f25b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/types.ts @@ -16,8 +16,33 @@ export type UmbElementUserPermissionConditionConfig = UmbConditionConfigBase<'Um oneOf?: Array; }; +export type UmbPermissionVerbsConfig = { + allOf?: Array; + oneOf?: Array; +}; + +export type UmbElementOrElementFolderUserPermissionConditionConfig = + UmbConditionConfigBase<'Umb.Condition.UserPermission.ElementOrElementFolder'> & { + /** + * Permission verbs to check against element permissions. + * The condition is met if the user has the required element permissions OR the required folder permissions. + * @example + * { allOf: ["Umb.Element.Read"] } + */ + element?: UmbPermissionVerbsConfig; + + /** + * Permission verbs to check against element folder (container) permissions. + * The condition is met if the user has the required element permissions OR the required folder permissions. + * @example + * { allOf: ["Umb.ElementContainer.Read"] } + */ + folder?: UmbPermissionVerbsConfig; + }; + declare global { interface UmbExtensionConditionConfigMap { UmbElementUserPermissionConditionConfig: UmbElementUserPermissionConditionConfig; + UmbElementOrElementFolderUserPermissionConditionConfig: UmbElementOrElementFolderUserPermissionConditionConfig; } }