diff --git a/src/Umbraco.Web.UI.Client/examples/user-permission/constants.ts b/src/Umbraco.Web.UI.Client/examples/user-permission/constants.ts new file mode 100644 index 0000000000..712fdd2d1b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/user-permission/constants.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const EXAMPLE_USER_PERMISSION_DICTIONARY_ACTION_1 = 'Example.Dictionary.Action1'; +// eslint-disable-next-line @typescript-eslint/naming-convention +export const EXAMPLE_USER_PERMISSION_DICTIONARY_ACTION_2 = 'Example.Dictionary.Action2'; diff --git a/src/Umbraco.Web.UI.Client/examples/user-permission/entity-action/action-1.ts b/src/Umbraco.Web.UI.Client/examples/user-permission/entity-action/action-1.ts new file mode 100644 index 0000000000..5892340ed6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/user-permission/entity-action/action-1.ts @@ -0,0 +1,9 @@ +import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; + +export class ExampleAction1EntityAction extends UmbEntityActionBase { + override async execute() { + alert('Example action 1 executed'); + } +} + +export { ExampleAction1EntityAction as default }; diff --git a/src/Umbraco.Web.UI.Client/examples/user-permission/entity-action/action-2.ts b/src/Umbraco.Web.UI.Client/examples/user-permission/entity-action/action-2.ts new file mode 100644 index 0000000000..586276b3bf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/user-permission/entity-action/action-2.ts @@ -0,0 +1,9 @@ +import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; + +export class ExampleAction2EntityAction extends UmbEntityActionBase { + override async execute() { + alert('Example action 2 executed'); + } +} + +export { ExampleAction2EntityAction as default }; diff --git a/src/Umbraco.Web.UI.Client/examples/user-permission/entity-action/manifests.ts b/src/Umbraco.Web.UI.Client/examples/user-permission/entity-action/manifests.ts new file mode 100644 index 0000000000..f551ad6a6a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/user-permission/entity-action/manifests.ts @@ -0,0 +1,47 @@ +import { + EXAMPLE_USER_PERMISSION_DICTIONARY_ACTION_1, + EXAMPLE_USER_PERMISSION_DICTIONARY_ACTION_2, +} from '../constants.js'; +import { UMB_DICTIONARY_ENTITY_TYPE } from '@umbraco-cms/backoffice/dictionary'; +import { UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS } from '@umbraco-cms/backoffice/user-permission'; + +export const manifests: Array = [ + { + type: 'entityAction', + kind: 'default', + alias: 'Example.EntityAction.Action1', + name: 'Action 1 Entity Action', + forEntityTypes: [UMB_DICTIONARY_ENTITY_TYPE], + api: () => import('./action-1.js'), + weight: 10000, + meta: { + label: 'Action 1', + icon: 'icon-car', + }, + conditions: [ + { + alias: UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS, + allOf: [EXAMPLE_USER_PERMISSION_DICTIONARY_ACTION_1], + }, + ], + }, + { + type: 'entityAction', + kind: 'default', + alias: 'Example.EntityAction.Action2', + name: 'Action 2 Entity Action', + forEntityTypes: [UMB_DICTIONARY_ENTITY_TYPE], + api: () => import('./action-2.js'), + weight: 9000, + meta: { + label: 'Action 2', + icon: 'icon-bus', + }, + conditions: [ + { + alias: UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS, + allOf: [EXAMPLE_USER_PERMISSION_DICTIONARY_ACTION_2], + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/examples/user-permission/entity-user-permission/manifests.ts b/src/Umbraco.Web.UI.Client/examples/user-permission/entity-user-permission/manifests.ts new file mode 100644 index 0000000000..1284ac67f5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/user-permission/entity-user-permission/manifests.ts @@ -0,0 +1,30 @@ +import { + EXAMPLE_USER_PERMISSION_DICTIONARY_ACTION_1, + EXAMPLE_USER_PERMISSION_DICTIONARY_ACTION_2, +} from '../constants.js'; +import { UMB_DICTIONARY_ENTITY_TYPE } from '@umbraco-cms/backoffice/dictionary'; + +export const manifests: Array = [ + { + type: 'entityUserPermission', + alias: 'Example.EntityUserPermission.Entity.Action1', + name: 'Action 1 for Entity User Permission', + forEntityTypes: [UMB_DICTIONARY_ENTITY_TYPE], + meta: { + verbs: [EXAMPLE_USER_PERMISSION_DICTIONARY_ACTION_1], + label: 'Action 1', + description: 'Description for action 1', + }, + }, + { + type: 'entityUserPermission', + alias: 'Example.EntityUserPermission.Entity.Action2', + name: 'Action 2 for Entity User Permission', + forEntityTypes: [UMB_DICTIONARY_ENTITY_TYPE], + meta: { + verbs: [EXAMPLE_USER_PERMISSION_DICTIONARY_ACTION_2], + label: 'Action 2', + description: 'Description for action 2', + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/examples/user-permission/index.ts b/src/Umbraco.Web.UI.Client/examples/user-permission/index.ts new file mode 100644 index 0000000000..1a61d99b0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/user-permission/index.ts @@ -0,0 +1,9 @@ +import { manifests as entityUserPermissionManifests } from './entity-user-permission/manifests.js'; +import { manifests as entityActionManifests } from './entity-action/manifests.js'; +import { manifests as localizationManifests } from './localization/manifests.js'; + +export const manifests: Array = [ + ...entityUserPermissionManifests, + ...entityActionManifests, + ...localizationManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/examples/user-permission/localization/en.ts b/src/Umbraco.Web.UI.Client/examples/user-permission/localization/en.ts new file mode 100644 index 0000000000..bf0bbd9046 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/user-permission/localization/en.ts @@ -0,0 +1,6 @@ +export default { + user: { + // eslint-disable-next-line @typescript-eslint/naming-convention + permissionsEntityGroup_dictionary: 'Dictionary', + }, +}; diff --git a/src/Umbraco.Web.UI.Client/examples/user-permission/localization/manifests.ts b/src/Umbraco.Web.UI.Client/examples/user-permission/localization/manifests.ts new file mode 100644 index 0000000000..6db4d196da --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/user-permission/localization/manifests.ts @@ -0,0 +1,12 @@ +export const manifests: Array = [ + { + type: 'localization', + alias: 'Example.UserPermission.Localization.En', + name: 'en-US User Permission Localization Example', + js: () => import('./en.js'), + weight: 1, + meta: { + culture: 'en', + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/constants.ts new file mode 100644 index 0000000000..38b821c8a0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/constants.ts @@ -0,0 +1 @@ +export * from './fallback-permission-condition/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/constants.ts new file mode 100644 index 0000000000..0cf453245a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/constants.ts @@ -0,0 +1 @@ +export const UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS = 'Umb.Condition.UserPermission.Fallback'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/fallback-user-permission.condition.test.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/fallback-user-permission.condition.test.ts new file mode 100644 index 0000000000..45fdc6ac09 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/fallback-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 { UmbFallbackUserPermissionCondition } from './fallback-user-permission.condition'; +import { UMB_USER_PERMISSION_DOCUMENT_READ } from '@umbraco-cms/backoffice/document'; +import { UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS } from './constants'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + currentUserContext = new UmbCurrentUserContext(this); + + constructor() { + super(); + new UmbNotificationContext(this); + new UmbCurrentUserStore(this); + } + + async init() { + await this.currentUserContext.load(); + } +} + +describe('UmbFallbackUserPermissionCondition', () => { + let hostElement: UmbTestControllerHostElement; + let condition: UmbFallbackUserPermissionCondition; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + document.body.appendChild(hostElement); + await hostElement.init(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('Fallback Permissions', () => { + it('should permit the condition when allOf is satisfied', (done) => { + let callbackCount = 0; + + // We expect to find the read permission in the fallback permissions + condition = new UmbFallbackUserPermissionCondition(hostElement, { + host: hostElement, + config: { + alias: UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_DOCUMENT_READ], + }, + onChange: () => { + callbackCount++; + if (callbackCount === 1) { + expect(condition.permitted).to.be.true; + condition.hostDisconnected(); + done(); + } + }, + }); + }); + + it('should forbid the condition when allOf is not satisfied', (done) => { + let callbackCount = 0; + + // We expect to find the read permission in the fallback permissions + condition = new UmbFallbackUserPermissionCondition(hostElement, { + host: hostElement, + config: { + alias: UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_DOCUMENT_READ, 'non-existing-permission'], + }, + onChange: () => { + callbackCount++; + if (callbackCount === 1) { + expect(condition.permitted).to.be.false; + condition.hostDisconnected(); + done(); + } + }, + }); + + // The onChange callback is not called when the condition is false, so we need to wait and check manually + setTimeout(() => { + expect(condition.permitted).to.be.false; + condition.hostDisconnected(); + done(); + }, 200); + }); + + it('should permit the condition when oneOf is satisfied', (done) => { + let callbackCount = 0; + + // We expect to find the read permission in the fallback permissions + condition = new UmbFallbackUserPermissionCondition(hostElement, { + host: hostElement, + config: { + alias: UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS, + oneOf: [UMB_USER_PERMISSION_DOCUMENT_READ, 'non-existing-permission'], + }, + onChange: () => { + /* The onChange callback is not called when the condition is false, so this should never be called + But in case it is, we want to fail the test */ + callbackCount++; + if (callbackCount === 1) { + expect(condition.permitted).to.be.true; + condition.hostDisconnected(); + done(); + } + }, + }); + }); + + it('should forbid the condition when oneOf is not satisfied', (done) => { + let callbackCount = 0; + + // We expect to find the read permission in the fallback permissions + condition = new UmbFallbackUserPermissionCondition(hostElement, { + host: hostElement, + config: { + alias: UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS, + oneOf: ['non-existing-permission', 'another-non-existing-permission'], + }, + onChange: () => { + /* The onChange callback is not called when the condition is false, so this should never be called + But in case it is, we want to fail the test */ + callbackCount++; + if (callbackCount === 1) { + expect(condition.permitted).to.be.false; + condition.hostDisconnected(); + done(); + } + }, + }); + + // The onChange callback is not called when the condition is false, so we need to wait and check manually + setTimeout(() => { + expect(condition.permitted).to.be.false; + condition.hostDisconnected(); + done(); + }, 200); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/fallback-user-permission.condition.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/fallback-user-permission.condition.ts new file mode 100644 index 0000000000..73cb09f055 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/fallback-user-permission.condition.ts @@ -0,0 +1,55 @@ +import type { UmbFallbackUserPermissionConditionConfig } from './types.js'; +import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; +import type { UmbConditionControllerArguments, UmbExtensionCondition } from '@umbraco-cms/backoffice/extension-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbConditionBase } from '@umbraco-cms/backoffice/extension-registry'; + +export class UmbFallbackUserPermissionCondition + extends UmbConditionBase + implements UmbExtensionCondition +{ + constructor( + host: UmbControllerHost, + args: UmbConditionControllerArguments, + ) { + super(host, args); + + this.consumeContext(UMB_CURRENT_USER_CONTEXT, (context) => { + this.observe( + context?.currentUser, + (currentUser) => { + const fallbackPermissions = currentUser?.fallbackPermissions || []; + this.#check(fallbackPermissions); + }, + 'umbUserFallbackPermissionConditionObserver', + ); + }); + } + + #check(verbs: Array) { + /* we default to true se we don't require both allOf and oneOf to be defined + but they can be combined for more complex scenarios */ + let allOfPermitted = true; + let oneOfPermitted = true; + + // check if all of the verbs are present + if (this.config.allOf?.length) { + allOfPermitted = this.config.allOf.every((verb) => verbs.includes(verb)); + } + + // check if at least one of the verbs is present + if (this.config.oneOf?.length) { + oneOfPermitted = this.config.oneOf.some((verb) => verbs.includes(verb)); + } + + // if neither allOf or oneOf is defined we default to false + if (!allOfPermitted && !oneOfPermitted) { + allOfPermitted = false; + oneOfPermitted = false; + } + + this.permitted = allOfPermitted && oneOfPermitted; + } +} + +export { UmbFallbackUserPermissionCondition as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/manifests.ts new file mode 100644 index 0000000000..88fd75d576 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'condition', + name: 'User Fallback Permission Condition', + alias: UMB_FALLBACK_USER_PERMISSION_CONDITION_ALIAS, + api: () => import('./fallback-user-permission.condition.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/types.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/types.ts new file mode 100644 index 0000000000..7674070ed8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/fallback-permission-condition/types.ts @@ -0,0 +1,24 @@ +import type { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-api'; + +export type UmbFallbackUserPermissionConditionConfig = + UmbConditionConfigBase<'Umb.Condition.UserPermission.Fallback'> & { + /** + * The user must have all of the permissions in this array for the condition to be met. + * @example + * ["Umb.PermissionOne", "Umb.PermissionTwo"] + */ + allOf?: Array; + + /** + * The user must have at least one of the permissions in this array for the condition to be met. + * @example + * ["Umb.PermissionOne", "Umb.PermissionTwo"] + */ + oneOf?: Array; + }; + +declare global { + interface UmbExtensionConditionConfigMap { + UmbFallbackUserPermissionConditionConfig: UmbFallbackUserPermissionConditionConfig; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/index.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/index.ts index 64b0d29e78..75d21235fe 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/index.ts @@ -1,4 +1,5 @@ export * from './components/index.js'; +export * from './constants.js'; export * from './modals/index.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/manifests.ts index 7c55267b3e..4f1dbebc4a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/manifests.ts @@ -1,3 +1,4 @@ import { manifests as userPermissionModalManifests } from './modals/manifests.js'; +import { manifests as fallbackConditionPermission } from './fallback-permission-condition/manifests.js'; -export const manifests: Array = [...userPermissionModalManifests]; +export const manifests: Array = [...userPermissionModalManifests, ...fallbackConditionPermission];