Document permission inheritance in UI (#18935)

* check the full path for permissions

* fix race condition

* wip update permission when variants change

* Populate ancestor keys on document tree response items.

* Populate ancestor keys on document collection response items.

* Update OpenApi.json

* generate server models

* update types

* map data

* add ancestor context

* set ancestors in context

* use ancestor context in tree

* clean up

* provide ancestor context from a collection item

* provide ancestor context from structure context

* Use array of objects rather than Ids for the ancestor collection.

* Update OpenApi.json.

* add ancestor data to mocks

* set ancestors ids in mocks

* omit ancestors for recycle bin item

* use correct models for document blueprint mock data

* remove constructor

* mock documents for testing

* add user group permission test data

* wip document user permission condition tests

* generate new server models

* update data efter server models update

* clean up

* Update entity-actions-table-column-view.element.ts

* longer time for not found to appear

* use arg

* observe alias

* set new the right place

* remove const

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
This commit is contained in:
Mads Rasmussen
2025-04-09 11:08:28 +02:00
committed by GitHub
parent a0e3ca601e
commit 6f38a57c8a
28 changed files with 515 additions and 90 deletions

View File

@@ -1,30 +1,25 @@
import type { UmbMockDocumentModel } from '../document/document.data.js';
import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
import {
DocumentVariantStateModel,
type DocumentBlueprintItemResponseModel,
type DocumentBlueprintResponseModel,
type DocumentBlueprintTreeItemResponseModel,
} from '@umbraco-cms/backoffice/external/backend-api';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface UmbMockDocumentBlueprintModel extends UmbMockDocumentModel {}
export type UmbMockDocumentBlueprintModel = DocumentBlueprintResponseModel &
DocumentBlueprintItemResponseModel &
DocumentBlueprintTreeItemResponseModel;
export const data: Array<UmbMockDocumentBlueprintModel> = [
{
ancestors: [],
urls: [
{
culture: 'en-US',
url: '/',
},
],
template: null,
id: 'the-simplest-document-id',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
documentType: {
id: 'the-simplest-document-type-id',
icon: 'icon-document',
},
hasChildren: false,
noAccess: false,
isProtected: false,
isTrashed: false,
isFolder: false,
name: 'The Simplest Document Blueprint',
variants: [
{
state: DocumentVariantStateModel.DRAFT,

View File

@@ -8,10 +8,10 @@ import type { UmbMockDocumentBlueprintModel } from './document-blueprint.data.js
import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
import { UmbId } from '@umbraco-cms/backoffice/id';
import type {
CreateDocumentRequestModel,
DocumentItemResponseModel,
DocumentResponseModel,
DocumentTreeItemResponseModel,
CreateDocumentBlueprintRequestModel,
DocumentBlueprintItemResponseModel,
DocumentBlueprintResponseModel,
DocumentBlueprintTreeItemResponseModel,
DocumentValueResponseModel,
} from '@umbraco-cms/backoffice/external/backend-api';
@@ -23,41 +23,34 @@ export class UmbDocumentBlueprintMockDB extends UmbEntityMockDbBase<UmbMockDocum
createMockDocumentBlueprintMapper,
detailResponseMapper,
);
constructor(data: Array<UmbMockDocumentBlueprintModel>) {
super(data);
}
}
const treeItemMapper = (model: UmbMockDocumentBlueprintModel): Omit<DocumentTreeItemResponseModel, 'type'> => {
const treeItemMapper = (model: UmbMockDocumentBlueprintModel): DocumentBlueprintTreeItemResponseModel => {
const documentType = umbDocumentTypeMockDb.read(model.documentType.id);
if (!documentType) throw new Error(`Document type with id ${model.documentType.id} not found`);
return {
ancestors: model.ancestors,
documentType: {
icon: documentType.icon,
id: documentType.id,
},
hasChildren: model.hasChildren,
id: model.id,
isProtected: model.isProtected,
isTrashed: model.isTrashed,
noAccess: model.noAccess,
isFolder: model.isFolder,
name: model.name,
parent: model.parent,
variants: model.variants,
createDate: model.createDate,
};
};
const createMockDocumentBlueprintMapper = (request: CreateDocumentRequestModel): UmbMockDocumentBlueprintModel => {
const createMockDocumentBlueprintMapper = (
request: CreateDocumentBlueprintRequestModel,
): UmbMockDocumentBlueprintModel => {
const documentType = umbDocumentTypeMockDb.read(request.documentType.id);
if (!documentType) throw new Error(`Document type with id ${request.documentType.id} not found`);
const now = new Date().toString();
return {
ancestors: [],
documentType: {
id: documentType.id,
icon: documentType.icon,
@@ -65,10 +58,8 @@ const createMockDocumentBlueprintMapper = (request: CreateDocumentRequestModel):
},
hasChildren: false,
id: request.id ? request.id : UmbId.new(),
createDate: now,
isProtected: false,
isTrashed: false,
noAccess: false,
isFolder: false,
name: request.variants[0].name,
parent: request.parent,
values: request.values as DocumentValueResponseModel[],
variants: request.variants.map((variantRequest) => {
@@ -82,35 +73,27 @@ const createMockDocumentBlueprintMapper = (request: CreateDocumentRequestModel):
publishDate: null,
};
}),
urls: [],
};
};
const detailResponseMapper = (model: UmbMockDocumentBlueprintModel): DocumentResponseModel => {
const detailResponseMapper = (model: UmbMockDocumentBlueprintModel): DocumentBlueprintResponseModel => {
return {
documentType: model.documentType,
id: model.id,
isTrashed: model.isTrashed,
template: model.template,
urls: model.urls,
values: model.values,
variants: model.variants,
};
};
const itemMapper = (model: UmbMockDocumentBlueprintModel): DocumentItemResponseModel => {
const itemMapper = (model: UmbMockDocumentBlueprintModel): DocumentBlueprintItemResponseModel => {
return {
documentType: {
collection: model.documentType.collection,
icon: model.documentType.icon,
id: model.documentType.id,
},
hasChildren: model.hasChildren,
id: model.id,
isProtected: model.isProtected,
isTrashed: model.isTrashed,
parent: model.parent,
variants: model.variants,
name: model.name,
};
};

View File

@@ -0,0 +1,129 @@
import type { UmbMockDocumentModel } from '../document.data.js';
import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
const permissionsTestDocument = {
ancestors: [],
urls: [
{
culture: null,
url: '/',
},
],
template: null,
id: 'permissions-document-id',
createDate: '2023-02-06T15:32:05.350038',
parent: null,
documentType: {
id: 'the-simplest-document-type-id',
icon: 'icon-document',
},
hasChildren: false,
noAccess: false,
isProtected: false,
isTrashed: false,
values: [],
variants: [
{
state: DocumentVariantStateModel.PUBLISHED,
publishDate: '2023-02-06T15:32:24.957009',
culture: null,
segment: null,
name: 'Permissions',
createDate: '2023-02-06T15:32:05.350038',
updateDate: '2023-02-06T15:32:24.957009',
},
],
};
export const data: Array<UmbMockDocumentModel> = [
permissionsTestDocument,
{
...permissionsTestDocument,
ancestors: [{ id: 'permissions-document-id' }],
hasChildren: false,
id: 'permissions-1-document-id',
parent: { id: 'permissions-document-id' },
urls: [
{
culture: null,
url: '/permission-1',
},
],
variants: permissionsTestDocument.variants.map((variant) => ({
...variant,
name: 'Permissions 1',
})),
},
{
...permissionsTestDocument,
ancestors: [{ id: 'permissions-document-id' }],
hasChildren: true,
id: 'permissions-2-document-id',
parent: { id: 'permissions-document-id' },
urls: [
{
culture: null,
url: '/permissions-2',
},
],
variants: permissionsTestDocument.variants.map((variant) => ({
...variant,
name: 'Permissions 2',
})),
},
{
...permissionsTestDocument,
ancestors: [{ id: 'permissions-document-id' }, { id: 'permissions-2-document-id' }],
hasChildren: true,
id: 'permission-2-1-document-id',
parent: { id: 'permissions-2-document-id' },
urls: [
{
culture: null,
url: '/permissions-1/permissions-2-1',
},
],
variants: permissionsTestDocument.variants.map((variant) => ({
...variant,
name: 'Permissions 2.1',
})),
},
{
...permissionsTestDocument,
ancestors: [{ id: 'permissions-document-id' }, { id: 'permissions-2-document-id' }],
hasChildren: false,
id: 'permissions-2-2-document-id',
parent: { id: 'permissions-2-document-id' },
urls: [
{
culture: null,
url: '/permissions-1/permissions-2-2',
},
],
variants: permissionsTestDocument.variants.map((variant) => ({
...variant,
name: 'Permissions 2.2',
})),
},
{
...permissionsTestDocument,
ancestors: [
{ id: 'permissions-document-id' },
{ id: 'permissions-2-document-id' },
{ id: 'permissions-2-2-document-id' },
],
hasChildren: false,
id: 'permission-2-2-1-document-id',
parent: { id: 'permissions-2-2-document-id' },
urls: [
{
culture: null,
url: '/permissions-1/permissions-2-2/permissions-2-2-1',
},
],
variants: permissionsTestDocument.variants.map((variant) => ({
...variant,
name: 'Permissions 2.2.1',
})),
},
];

View File

@@ -1,3 +1,4 @@
import { data as permissionsTestData } from './data/permissions-test.data.js';
import type {
DocumentItemResponseModel,
DocumentResponseModel,
@@ -1240,4 +1241,5 @@ export const data: Array<UmbMockDocumentModel> = [
},
],
},
...permissionsTestData,
];

View File

@@ -78,10 +78,24 @@ const createMockDocumentMapper = (request: CreateDocumentRequestModel): UmbMockD
const documentType = umbDocumentTypeMockDb.read(request.documentType.id);
if (!documentType) throw new Error(`Document type with id ${request.documentType.id} not found`);
const isRoot = request.parent === null || request.parent === undefined;
let ancestors: Array<{ id: string }> = [];
if (!isRoot) {
const parentId = request.parent!.id;
const parentAncestors = umbDocumentMockDb.tree.getAncestorsOf({ descendantId: parentId }).map((ancestor) => {
return {
id: ancestor.id,
};
});
ancestors = [...parentAncestors, { id: parentId }];
}
const now = new Date().toString();
return {
ancestors: [],
ancestors,
documentType: {
id: documentType.id,
icon: documentType.icon,

View File

@@ -26,7 +26,29 @@ export const data: Array<UmbMockUserGroupModel> = [
'Umb.Document.PublicAccess',
'Umb.Document.Rollback',
],
permissions: [],
permissions: [
{
$type: 'DocumentPermissionPresentationModel',
document: {
id: 'permissions-document-id',
},
verbs: ['Umb.Document.Read'],
},
{
$type: 'DocumentPermissionPresentationModel',
document: {
id: 'permissions-2-document-id',
},
verbs: ['Umb.Document.Create', 'Umb.Document.Read'],
},
{
$type: 'DocumentPermissionPresentationModel',
document: {
id: 'permissions-2-2-document-id',
},
verbs: ['Umb.Document.Delete', 'Umb.Document.Read'],
},
],
sections: [
UMB_CONTENT_SECTION_ALIAS,
'Umb.Section.Media',

View File

@@ -1,16 +1,12 @@
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import { html, nothing, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { html, nothing, customElement, property } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
const elementName = 'umb-entity-actions-table-column-view';
@customElement(elementName)
@customElement('umb-entity-actions-table-column-view')
export class UmbEntityActionsTableColumnViewElement extends UmbLitElement {
@property({ attribute: false })
value?: UmbEntityModel;
@state()
_isOpen = false;
override render() {
if (!this.value) return nothing;
@@ -23,6 +19,6 @@ export class UmbEntityActionsTableColumnViewElement extends UmbLitElement {
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbEntityActionsTableColumnViewElement;
'umb-entity-actions-table-column-view': UmbEntityActionsTableColumnViewElement;
}
}

View File

@@ -0,0 +1 @@
export * from './contexts/ancestors/constants.js';

View File

@@ -0,0 +1,4 @@
import type { UmbAncestorsEntityContext } from './ancestors.entity-context.js';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
export const UMB_ANCESTORS_ENTITY_CONTEXT = new UmbContextToken<UmbAncestorsEntityContext>('UmbAncestorsEntityContext');

View File

@@ -0,0 +1,38 @@
import { UMB_ANCESTORS_ENTITY_CONTEXT } from './ancestors.entity-context-token.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
/**
* A entity context for the ancestors
* @class UmbAncestorsEntityContext
* @augments {UmbContextBase<UmbAncestorsEntityContext>}
* @implements {UmbAncestorsEntityContext}
*/
export class UmbAncestorsEntityContext extends UmbContextBase<UmbAncestorsEntityContext> {
#ancestors = new UmbArrayState<UmbEntityModel>([], (x) => x.unique);
ancestors = this.#ancestors.asObservable();
constructor(host: UmbControllerHost) {
super(host, UMB_ANCESTORS_ENTITY_CONTEXT);
}
/**
* Gets the ancestors state
* @returns {Array<UmbEntityModel>} - The ancestors state
* @memberof UmbAncestorsEntityContext
*/
getAncestors(): Array<UmbEntityModel> {
return this.#ancestors.getValue();
}
/**
* Sets the ancestors state
* @param {Array<UmbEntityModel>} ancestors - The ancestors state
* @memberof UmbAncestorsEntityContext
*/
setAncestors(ancestors: Array<UmbEntityModel>) {
this.#ancestors.setValue(ancestors);
}
}

View File

@@ -0,0 +1 @@
export { UMB_ANCESTORS_ENTITY_CONTEXT } from './ancestors.entity-context-token.js';

View File

@@ -0,0 +1 @@
export { UmbAncestorsEntityContext } from './ancestors.entity-context.js';

View File

@@ -1,3 +1,5 @@
export { UMB_ENTITY_CONTEXT } from './entity.context-token.js';
export { UmbEntityContext } from './entity.context.js';
export * from './constants.js';
export * from './contexts/ancestors/index.js';
export type * from './types.js';

View File

@@ -5,6 +5,7 @@ import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UMB_VARIANT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import { UmbArrayState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbAncestorsEntityContext } from '@umbraco-cms/backoffice/entity';
interface UmbMenuVariantTreeStructureWorkspaceContextBaseArgs {
treeRepositoryAlias: string;
@@ -21,6 +22,8 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
#parent = new UmbObjectState<UmbVariantStructureItemModel | undefined>(undefined);
public readonly parent = this.#parent.asObservable();
#ancestorContext = new UmbAncestorsEntityContext(this);
constructor(host: UmbControllerHost, args: UmbMenuVariantTreeStructureWorkspaceContextBaseArgs) {
// TODO: set up context token
super(host, 'UmbMenuStructureWorkspaceContext');
@@ -85,6 +88,15 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
};
});
const ancestorEntities = data.map((treeItem) => {
return {
unique: treeItem.unique,
entityType: treeItem.entityType,
};
});
this.#ancestorContext.setAncestors(ancestorEntities);
structureItems.push(...ancestorItems);
const parent = structureItems[structureItems.length - 2];

View File

@@ -33,7 +33,7 @@ export class UmbRouteNotFoundElement extends UmbLitElement {
align-items: center;
height: 100%;
opacity: 0;
animation: fadeIn 6s 0.2s forwards;
animation: fadeIn 5s 5s forwards;
}
@keyframes fadeIn {

View File

@@ -171,6 +171,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase<
return (await this._getDataPromise) as GetDataType;
}
this.resetState();
this.setIsNew(false);
this.#entityContext.setUnique(unique);
this.loading.addState({ unique: LOADING_STATE_UNIQUE, message: `Loading ${this.getEntityType()} Details` });
await this.#init;
@@ -182,7 +183,6 @@ export abstract class UmbEntityDetailWorkspaceContextBase<
if (data) {
this._data.setPersisted(data);
this._data.setCurrent(data);
this.setIsNew(false);
this.observe(
response.asObservable(),
@@ -246,8 +246,8 @@ export abstract class UmbEntityDetailWorkspaceContextBase<
data = { ...data, ...this.modalContext.data.preset };
}
this.#entityContext.setUnique(data.unique);
this.setIsNew(true);
this.#entityContext.setUnique(data.unique);
this._data.setPersisted(data);
this._data.setCurrent(data);
}

View File

@@ -37,6 +37,12 @@ export class UmbDocumentCollectionServerDataSource implements UmbCollectionDataS
const variant = item.variants[0];
const model: UmbDocumentCollectionItemModel = {
ancestors: item.ancestors.map((ancestor) => {
return {
unique: ancestor.id,
entityType: UMB_DOCUMENT_ENTITY_TYPE,
};
}),
unique: item.id,
entityType: UMB_DOCUMENT_ENTITY_TYPE,
contentTypeAlias: item.documentType.alias,

View File

@@ -1,5 +1,6 @@
import type { UmbDocumentEntityType } from '../entity.js';
import type { UmbDocumentItemVariantModel } from '../item/repository/types.js';
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import type { UmbCollectionFilterModel } from '@umbraco-cms/backoffice/collection';
export interface UmbDocumentCollectionFilterModel extends UmbCollectionFilterModel {
@@ -12,6 +13,7 @@ export interface UmbDocumentCollectionFilterModel extends UmbCollectionFilterMod
}
export interface UmbDocumentCollectionItemModel {
ancestors: Array<UmbEntityModel>;
unique: string;
entityType: UmbDocumentEntityType;
creator?: string | null;

View File

@@ -0,0 +1,36 @@
import type { UmbDocumentCollectionItemModel } from '../../../types.js';
import { UmbAncestorsEntityContext } from '@umbraco-cms/backoffice/entity';
import { html, nothing, customElement, property } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
@customElement('umb-document-entity-actions-table-column-view')
export class UmbDocumentEntityActionsTableColumnViewElement extends UmbLitElement {
@property({ attribute: false })
public get value(): UmbDocumentCollectionItemModel | undefined {
return this._value;
}
public set value(value: UmbDocumentCollectionItemModel | undefined) {
this._value = value;
this.#ancestorContext.setAncestors(this._value?.ancestors ?? []);
}
private _value?: UmbDocumentCollectionItemModel | undefined;
#ancestorContext = new UmbAncestorsEntityContext(this);
override render() {
if (!this._value) return nothing;
return html`
<umb-entity-actions-table-column-view
.value=${{ unique: this._value.unique, entityType: this._value.entityType }}>
</umb-entity-actions-table-column-view>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
['umb-document-entity-actions-table-column-view']: UmbDocumentEntityActionsTableColumnViewElement;
}
}

View File

@@ -19,6 +19,7 @@ import type {
UmbTableSelectedEvent,
} from '@umbraco-cms/backoffice/components';
import './column-layouts/document-entity-actions-table-column-view.element.js';
import './column-layouts/document-table-column-name.element.js';
import './column-layouts/document-table-column-state.element.js';
@@ -143,11 +144,8 @@ export class UmbDocumentTableCollectionViewElement extends UmbLitElement {
if (column.alias === 'entityActions') {
return {
columnAlias: 'entityActions',
value: html`<umb-entity-actions-table-column-view
.value=${{
entityType: item.entityType,
unique: item.unique,
}}></umb-entity-actions-table-column-view>`,
value: html`<umb-document-entity-actions-table-column-view
.value=${item}></umb-document-entity-actions-table-column-view>`,
};
}

View File

@@ -16,14 +16,14 @@ export class UmbDocumentSaveAndPublishWorkspaceAction extends UmbWorkspaceAction
will first be triggered when the condition is changed to permitted */
this.disable();
const condition = new UmbDocumentUserPermissionCondition(host, {
new UmbDocumentUserPermissionCondition(host, {
host,
config: {
alias: 'Umb.Condition.UserPermission.Document',
allOf: [UMB_USER_PERMISSION_DOCUMENT_UPDATE, UMB_USER_PERMISSION_DOCUMENT_PUBLISH],
},
onChange: () => {
if (condition.permitted) {
onChange: (permitted) => {
if (permitted) {
this.enable();
} else {
this.disable();

View File

@@ -2,7 +2,7 @@ import type { UmbDocumentTreeItemModel } from '../../tree/index.js';
import type { UmbTreeRootModel } from '@umbraco-cms/backoffice/tree';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface UmbDocumentRecycleBinTreeItemModel extends UmbDocumentTreeItemModel {}
export interface UmbDocumentRecycleBinTreeItemModel extends Omit<UmbDocumentTreeItemModel, 'ancestors'> {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface UmbDocumentRecycleBinTreeRootModel extends UmbTreeRootModel {}

View File

@@ -66,6 +66,12 @@ const getAncestorsOf = (args: UmbTreeAncestorsOfRequestArgs) =>
const mapper = (item: DocumentTreeItemResponseModel): UmbDocumentTreeItemModel => {
return {
ancestors: item.ancestors.map((ancestor) => {
return {
unique: ancestor.id,
entityType: UMB_DOCUMENT_ENTITY_TYPE,
};
}),
unique: item.id,
parent: {
unique: item.parent ? item.parent.id : null,

View File

@@ -3,6 +3,7 @@ import { UmbDocumentItemDataResolver } from '../../item/index.js';
import { UmbDefaultTreeItemContext } from '@umbraco-cms/backoffice/tree';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin';
import { UmbAncestorsEntityContext } from '@umbraco-cms/backoffice/entity';
export class UmbDocumentTreeItemContext extends UmbDefaultTreeItemContext<
UmbDocumentTreeItemModel,
@@ -10,12 +11,14 @@ export class UmbDocumentTreeItemContext extends UmbDefaultTreeItemContext<
> {
// TODO: Provide this together with the EntityContext, ideally this takes part via a extension-type [NL]
#isTrashedContext = new UmbIsTrashedEntityContext(this);
#ancestorsContext = new UmbAncestorsEntityContext(this);
#item = new UmbDocumentItemDataResolver(this);
readonly name = this.#item.name;
readonly icon = this.#item.icon;
readonly isDraft = this.#item.isDraft;
readonly ancestors = this._treeItem.asObservablePart((item) => item?.ancestors ?? []);
readonly isTrashed = this._treeItem.asObservablePart((item) => item?.isTrashed ?? false);
constructor(host: UmbControllerHost) {
@@ -24,6 +27,10 @@ export class UmbDocumentTreeItemContext extends UmbDefaultTreeItemContext<
this.observe(this.isTrashed, (isTrashed) => {
this.#isTrashedContext.setIsTrashed(isTrashed);
});
this.observe(this.ancestors, (ancestors) => {
this.#ancestorsContext.setAncestors(ancestors);
});
}
public override setTreeItem(treeItem: UmbDocumentTreeItemModel | undefined) {

View File

@@ -7,8 +7,10 @@ import type {
} from '@umbraco-cms/backoffice/tree';
import type { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models';
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
export interface UmbDocumentTreeItemModel extends UmbTreeItemModel {
ancestors: Array<UmbEntityModel>;
entityType: UmbDocumentEntityType;
noAccess: boolean;
isTrashed: boolean;

View File

@@ -0,0 +1,134 @@
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 { UmbDocumentUserPermissionCondition } from './document-user-permission.condition';
import { UmbAncestorsEntityContext, UmbEntityContext, type UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import {
UMB_DOCUMENT_ENTITY_TYPE,
UMB_DOCUMENT_USER_PERMISSION_CONDITION_ALIAS,
UMB_USER_PERMISSION_DOCUMENT_READ,
} from '@umbraco-cms/backoffice/document';
@customElement('test-controller-host')
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<UmbEntityModel>) {
this.ancestorsContext.setAncestors(ancestors);
}
}
describe('UmbDocumentUserPermissionCondition', () => {
let hostElement: UmbTestControllerHostElement;
let condition: UmbDocumentUserPermissionCondition;
beforeEach(async () => {
hostElement = new UmbTestControllerHostElement();
document.body.appendChild(hostElement);
await hostElement.init();
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('Specific permissions', () => {
it('should return true if a user has permissions', (done) => {
// Sets the current entity data
hostElement.setEntity({
unique: 'permissions-document-id',
entityType: UMB_DOCUMENT_ENTITY_TYPE,
});
// This entity does not have any ancestors.
hostElement.setEntityAncestors([]);
// We expect to find the read permission on the current entity
condition = new UmbDocumentUserPermissionCondition(hostElement, {
host: hostElement,
config: {
alias: UMB_DOCUMENT_USER_PERMISSION_CONDITION_ALIAS,
allOf: [UMB_USER_PERMISSION_DOCUMENT_READ],
},
onChange: (permitted) => {
expect(permitted).to.be.true;
done();
},
});
});
});
describe('Inherited permissions', () => {
it('should inherit permissions from closest ancestor with specific permissions set', (done) => {
// Sets the current entity data
hostElement.setEntity({
unique: 'permissions-document-1-id',
entityType: UMB_DOCUMENT_ENTITY_TYPE,
});
// Sets the ancestors of the current entity. These are the ancestors that will be checked for permissions.
hostElement.setEntityAncestors([{ unique: 'permissions-document-id', entityType: UMB_DOCUMENT_ENTITY_TYPE }]);
// We expect to find the read permission on the ancestor
condition = new UmbDocumentUserPermissionCondition(hostElement, {
host: hostElement,
config: {
alias: UMB_DOCUMENT_USER_PERMISSION_CONDITION_ALIAS,
allOf: [UMB_USER_PERMISSION_DOCUMENT_READ],
},
onChange: (permitted) => {
expect(permitted).to.be.true;
done();
},
});
});
});
describe('Fallback Permissions', () => {
it('should use the fallback permissions if no specific permissions are set for the entity or ancestors', (done) => {
// Sets the current entity to a document without permissions
hostElement.setEntity({
unique: 'no-permissions-document-id',
entityType: UMB_DOCUMENT_ENTITY_TYPE,
});
// Sets the ancestors of the current entity. These are the ancestors that will be checked for permissions.
// This ancestor does not have any permissions either.
hostElement.setEntityAncestors([
{ unique: 'no-permissions-parent-document-id', entityType: UMB_DOCUMENT_ENTITY_TYPE },
]);
// We expect to find the read permission in the fallback permissions
condition = new UmbDocumentUserPermissionCondition(hostElement, {
host: hostElement,
config: {
alias: UMB_DOCUMENT_USER_PERMISSION_CONDITION_ALIAS,
allOf: [UMB_USER_PERMISSION_DOCUMENT_READ],
},
onChange: (permitted) => {
expect(permitted).to.be.true;
done();
},
});
});
});
});

View File

@@ -1,7 +1,7 @@
import { isDocumentUserPermission } from '../utils.js';
import type { UmbDocumentUserPermissionConditionConfig } from './types.js';
import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user';
import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity';
import { UMB_ANCESTORS_ENTITY_CONTEXT, UMB_ENTITY_CONTEXT, type UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import type { UmbConditionControllerArguments, UmbExtensionCondition } from '@umbraco-cms/backoffice/extension-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
@@ -20,6 +20,7 @@ export class UmbDocumentUserPermissionCondition extends UmbControllerBase implem
#documentPermissions: Array<DocumentPermissionPresentationModel> = [];
#fallbackPermissions: string[] = [];
#onChange: UmbOnChangeCallbackType;
#ancestors: Array<UmbEntityUnique> = [];
constructor(
host: UmbControllerHost,
@@ -54,6 +55,17 @@ export class UmbDocumentUserPermissionCondition extends UmbControllerBase implem
'umbUserPermissionEntityContextObserver',
);
});
this.consumeContext(UMB_ANCESTORS_ENTITY_CONTEXT, (instance) => {
this.observe(
instance?.ancestors,
(ancestors) => {
this.#ancestors = ancestors.map((item) => item.unique);
this.#checkPermissions();
},
'observeAncestors',
);
});
}
#checkPermissions() {
@@ -68,21 +80,29 @@ export class UmbDocumentUserPermissionCondition extends UmbControllerBase implem
return;
}
/* If there are document permission we check if there are permissions for the current document
If there aren't we use the fallback permissions */
// If there are document permissions, we need to check the full path to see if any permissions are defined for the current document
// If we find multiple permissions in the same path, we will apply the closest one
if (hasDocumentPermissions) {
const permissionsForCurrentDocument = this.#documentPermissions.find(
(permission) => permission.document.id === this.#unique,
);
// Path including the current document and all ancestors
const path = [...this.#ancestors, this.#unique].filter((unique) => unique !== null);
// Reverse the path to find the closest document permission quickly
const reversedPath = [...path].reverse();
const documentPermissionsMap = new Map(this.#documentPermissions.map((p) => [p.document.id, p]));
// Find the closest document permission in the path
const closestDocumentPermission = reversedPath.find((id) => documentPermissionsMap.has(id));
// Retrieve the corresponding permission data
const match = closestDocumentPermission ? documentPermissionsMap.get(closestDocumentPermission) : undefined;
// no permissions for the current document - use the fallback permissions
if (!permissionsForCurrentDocument) {
if (!match) {
this.#check(this.#fallbackPermissions);
return;
}
// we found permissions for the current document - check them
this.#check(permissionsForCurrentDocument.verbs);
// we found permissions - check them
this.#check(match.verbs);
}
}

View File

@@ -99,7 +99,13 @@ export class UmbDocumentWorkspaceContext
allOf: [UMB_USER_PERMISSION_DOCUMENT_CREATE],
},
onChange: (permitted: boolean) => {
if (permitted === this.#userCanCreate) return;
this.#userCanCreate = permitted;
this.#setReadOnlyStateForUserPermission(
UMB_USER_PERMISSION_DOCUMENT_CREATE,
this.#userCanCreate,
'You do not have permission to create documents.',
);
},
},
]);
@@ -110,11 +116,31 @@ export class UmbDocumentWorkspaceContext
allOf: [UMB_USER_PERMISSION_DOCUMENT_UPDATE],
},
onChange: (permitted: boolean) => {
if (permitted === this.#userCanUpdate) return;
this.#userCanUpdate = permitted;
this.#setReadOnlyStateForUserPermission(
UMB_USER_PERMISSION_DOCUMENT_UPDATE,
this.#userCanUpdate,
'You do not have permission to update documents.',
);
},
},
]);
this.observe(this.variants, () => {
this.#setReadOnlyStateForUserPermission(
UMB_USER_PERMISSION_DOCUMENT_CREATE,
this.#userCanCreate,
'You do not have permission to create documents.',
);
this.#setReadOnlyStateForUserPermission(
UMB_USER_PERMISSION_DOCUMENT_UPDATE,
this.#userCanUpdate,
'You do not have permission to update documents.',
);
});
this.routes.setRoutes([
{
path: UMB_CREATE_FROM_BLUEPRINT_DOCUMENT_WORKSPACE_PATH_PATTERN.toString(),
@@ -147,13 +173,6 @@ export class UmbDocumentWorkspaceContext
const parentUnique = info.match.params.parentUnique === 'null' ? null : info.match.params.parentUnique;
const documentTypeUnique = info.match.params.documentTypeUnique;
await this.create({ entityType: parentEntityType, unique: parentUnique }, documentTypeUnique);
this.#setReadOnlyStateForUserPermission(
UMB_USER_PERMISSION_DOCUMENT_CREATE,
this.#userCanCreate,
'You do not have permission to create documents.',
);
new UmbWorkspaceIsNewRedirectController(
this,
this,
@@ -168,11 +187,6 @@ export class UmbDocumentWorkspaceContext
this.removeUmbControllerByAlias(UmbWorkspaceIsNewRedirectControllerAlias);
const unique = info.match.params.unique;
await this.load(unique);
this.#setReadOnlyStateForUserPermission(
UMB_USER_PERMISSION_DOCUMENT_UPDATE,
this.#userCanUpdate,
'You do not have permission to update documents.',
);
},
},
]);