diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 950c3255ea..11b9035cba 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -41,11 +41,11 @@ describe("AppComponent", () => { fixture.detectChanges(); })); - afterEach(() => { + afterEach(waitForAsync(() => { environment.demo_mode = false; jasmine.DEFAULT_TIMEOUT_INTERVAL = intervalBefore; return TestBed.inject(Database).destroy(); - }); + })); it("should be created", () => { expect(component).toBeTruthy(); diff --git a/src/app/core/common-components/entity-form/entity-form.service.spec.ts b/src/app/core/common-components/entity-form/entity-form.service.spec.ts index 2bfa0fdacc..3a04a2b807 100644 --- a/src/app/core/common-components/entity-form/entity-form.service.spec.ts +++ b/src/app/core/common-components/entity-form/entity-form.service.spec.ts @@ -108,6 +108,46 @@ describe("EntityFormService", () => { expect(formGroup.valid).toBeTrue(); }); + it("should use create permissions to disable fields when creating a new entity", () => { + const formFields = [{ id: "name" }, { id: "dateOfBirth" }]; + TestBed.inject(EntityAbility).update([ + { subject: "Child", action: "read", fields: ["name", "dateOfBirth"] }, + { subject: "Child", action: "update", fields: ["name"] }, + { subject: "Child", action: "create", fields: ["dateOfBirth"] }, + ]); + + const formGroup = service.createFormGroup(formFields, new Child()); + + expect(formGroup.get("name").disabled).toBeTrue(); + expect(formGroup.get("dateOfBirth").enabled).toBeTrue(); + }); + + it("should always keep properties disabled if user does not have 'update' permissions for them", () => { + const formFields = [{ id: "name" }, { id: "dateOfBirth" }]; + TestBed.inject(EntityAbility).update([ + { subject: "Child", action: "read", fields: ["name", "dateOfBirth"] }, + { subject: "Child", action: "update", fields: ["name"] }, + ]); + + const child = new Child(); + child._rev = "foo"; // "not new" state + + const formGroup = service.createFormGroup(formFields, child); + + expect(formGroup.get("name").enabled).toBeTrue(); + expect(formGroup.get("dateOfBirth").disabled).toBeTrue(); + + formGroup.disable(); + + expect(formGroup.get("name").disabled).toBeTrue(); + expect(formGroup.get("dateOfBirth").disabled).toBeTrue(); + + formGroup.enable(); + + expect(formGroup.get("name").enabled).toBeTrue(); + expect(formGroup.get("dateOfBirth").disabled).toBeTrue(); + }); + it("should create a error if form is invalid", () => { const formFields = [{ id: "schoolId" }, { id: "start" }]; const formGroup = service.createFormGroup( diff --git a/src/app/core/common-components/entity-form/entity-form.service.ts b/src/app/core/common-components/entity-form/entity-form.service.ts index a78ba87f6c..85526a3975 100644 --- a/src/app/core/common-components/entity-form/entity-form.service.ts +++ b/src/app/core/common-components/entity-form/entity-form.service.ts @@ -112,11 +112,13 @@ export class EntityFormService { * @param formFields * @param entity * @param forTable + * @param withPermissionCheck if true, fields without 'update' permissions will stay disabled when enabling form */ public createFormGroup( formFields: ColumnConfig[], entity: T, forTable = false, + withPermissionCheck = true, ): EntityForm { const formConfig = {}; const copy = entity.copy(); @@ -130,10 +132,18 @@ export class EntityFormService { } const group = this.fb.group>(formConfig); - const sub = group.valueChanges.subscribe( + const valueChangesSubscription = group.valueChanges.subscribe( () => (this.unsavedChanges.pending = group.dirty), ); - this.subscriptions.push(sub); + this.subscriptions.push(valueChangesSubscription); + + if (withPermissionCheck) { + this.disableReadOnlyFormControls(group, entity); + const statusChangesSubscription = group.statusChanges + .pipe(filter((status) => status !== "DISABLED")) + .subscribe(() => this.disableReadOnlyFormControls(group, entity)); + this.subscriptions.push(statusChangesSubscription); + } return group; } @@ -194,6 +204,18 @@ export class EntityFormService { return newVal; } + private disableReadOnlyFormControls( + form: EntityForm, + entity: T, + ) { + const action = entity.isNew ? "create" : "update"; + Object.keys(form.controls).forEach((fieldId) => { + if (this.ability.cannot(action, entity, fieldId)) { + form.get(fieldId).disable({ onlySelf: true, emitEvent: false }); + } + }); + } + /** * This function applies the changes of the formGroup to the entity. * If the form is invalid or the entity does not pass validation after applying the changes, an error will be thrown. @@ -228,7 +250,7 @@ export class EntityFormService { } private checkFormValidity(form: EntityForm) { - // errors regarding invalid fields wont be displayed unless marked as touched + // errors regarding invalid fields won't be displayed unless marked as touched form.markAllAsTouched(); if (form.invalid) { throw new InvalidFormFieldError(); diff --git a/src/app/core/common-components/entity-form/entity-form/entity-form.component.spec.ts b/src/app/core/common-components/entity-form/entity-form/entity-form.component.spec.ts index c57994f075..b340b42772 100644 --- a/src/app/core/common-components/entity-form/entity-form/entity-form.component.spec.ts +++ b/src/app/core/common-components/entity-form/entity-form/entity-form.component.spec.ts @@ -7,6 +7,7 @@ import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper import { ConfirmationDialogService } from "../../confirmation-dialog/confirmation-dialog.service"; import { EntityFormService } from "../entity-form.service"; import { DateWithAge } from "../../../basic-datatypes/date-with-age/dateWithAge"; +import { EntityAbility } from "../../../permissions/ability/entity-ability"; describe("EntityFormComponent", () => { let component: EntityFormComponent; @@ -55,6 +56,25 @@ describe("EntityFormComponent", () => { expect(component).toBeTruthy(); }); + it("should remove fields without read permissions", async () => { + component.fieldGroups = [ + { fields: ["foo", "bar"] }, + { fields: ["name"] }, + { fields: ["birthday"] }, + ]; + + TestBed.inject(EntityAbility).update([ + { subject: "Child", action: "read", fields: ["foo", "name"] }, + ]); + + component.ngOnChanges({ entity: true, form: true } as any); + + expect(component.fieldGroups).toEqual([ + { fields: ["foo"] }, + { fields: ["name"] }, + ]); + }); + it("should not change anything if changed entity has same values as form", () => { return expectApplyChangesPopup( "not-shown", diff --git a/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts b/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts index 1d392e4447..81ab7eb210 100644 --- a/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts +++ b/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts @@ -16,6 +16,7 @@ import { Subscription } from "rxjs"; import moment from "moment"; import { EntityFieldEditComponent } from "../../entity-field-edit/entity-field-edit.component"; import { FieldGroup } from "../../../entity-details/form/field-group"; +import { EntityAbility } from "../../../permissions/ability/entity-ability"; /** * A general purpose form component for displaying and editing entities. @@ -60,9 +61,17 @@ export class EntityFormComponent constructor( private entityMapper: EntityMapperService, private confirmationDialog: ConfirmationDialogService, + private ability: EntityAbility, ) {} ngOnChanges(changes: SimpleChanges) { + if (this.fieldGroups) { + this.fieldGroups = this.filterFieldGroupsByPermissions( + this.fieldGroups, + this.entity, + ); + } + if (changes.entity && this.entity) { this.changesSubscription?.unsubscribe(); this.changesSubscription = this.entityMapper @@ -74,6 +83,7 @@ export class EntityFormComponent ) .subscribe(({ entity }) => this.applyChanges(entity)); } + if (changes.form && this.form) { this.initialFormValues = this.form.getRawValue(); this.disableForLockedEntity(); @@ -125,6 +135,24 @@ export class EntityFormComponent ); } + private filterFieldGroupsByPermissions( + fieldGroups: FieldGroup[], + entity: Entity, + ): FieldGroup[] { + return fieldGroups + .map((group) => { + group.fields = group.fields.filter((field) => + this.ability.can( + "read", + entity, + typeof field === "string" ? field : field.id, + ), + ); + return group; + }) + .filter((group) => group.fields.length > 0); + } + private entityEqualsFormValue(entityValue, formValue) { return ( (entityValue instanceof Date && diff --git a/src/app/core/entity-details/form/form.component.ts b/src/app/core/entity-details/form/form.component.ts index bc6e7d2da3..1071f27b70 100644 --- a/src/app/core/entity-details/form/form.component.ts +++ b/src/app/core/entity-details/form/form.component.ts @@ -52,6 +52,7 @@ export class FormComponent implements FormConfig, OnInit { [].concat(...this.fieldGroups.map((group) => group.fields)), this.entity, ); + if (!this.creatingNew) { this.form.disable(); } diff --git a/src/app/core/permissions/ability/testing-entity-ability-factory.ts b/src/app/core/permissions/ability/testing-entity-ability-factory.ts new file mode 100644 index 0000000000..5425812b3a --- /dev/null +++ b/src/app/core/permissions/ability/testing-entity-ability-factory.ts @@ -0,0 +1,10 @@ +import { EntitySchemaService } from "../../entity/schema/entity-schema.service"; +import { EntityAbility } from "./entity-ability"; + +export const entityAbilityFactory = ( + entitySchemaService: EntitySchemaService, +) => { + let ability = new EntityAbility(entitySchemaService); + ability.update([{ subject: "all", action: "manage" }]); + return ability; +}; diff --git a/src/app/core/permissions/permission-directive/disable-entity-operation.directive.ts b/src/app/core/permissions/permission-directive/disable-entity-operation.directive.ts index 2548287941..458831a0be 100644 --- a/src/app/core/permissions/permission-directive/disable-entity-operation.directive.ts +++ b/src/app/core/permissions/permission-directive/disable-entity-operation.directive.ts @@ -26,12 +26,13 @@ export class DisableEntityOperationDirective { /** * These arguments are required to check whether the user has permissions to perform the operation. - * The operation property defines to what kind of operation a element belongs, e.g. OperationType.CREATE + * The operation property defines to what kind of operation an element belongs, e.g. OperationType.CREATE * The entity property defines for which kind of entity the operation will be performed, e.g. Child */ @Input("appDisabledEntityOperation") arguments: { operation: EntityAction; entity: EntitySubject; + field?: string; }; private wrapperComponent: ComponentRef; @@ -76,6 +77,7 @@ export class DisableEntityOperationDirective this.wrapperComponent.instance.elementDisabled = this.ability.cannot( this.arguments.operation, this.arguments.entity, + this.arguments.field, ); this.wrapperComponent.instance.ngAfterViewInit(); } diff --git a/src/app/core/permissions/permission-directive/disabled-wrapper.component.ts b/src/app/core/permissions/permission-directive/disabled-wrapper.component.ts index 359cacf3dc..b4e2599ee3 100644 --- a/src/app/core/permissions/permission-directive/disabled-wrapper.component.ts +++ b/src/app/core/permissions/permission-directive/disabled-wrapper.component.ts @@ -51,16 +51,31 @@ export class DisabledWrapperComponent implements AfterViewInit { constructor(private renderer: Renderer2) {} ngAfterViewInit() { - if (this.wrapper) { - const buttonElement = - this.wrapper.nativeElement.getElementsByTagName("button")[0]; - if (this.elementDisabled) { - this.renderer.addClass(buttonElement, "mat-button-disabled"); - this.renderer.setAttribute(buttonElement, "disabled", "true"); - } else { - this.renderer.removeAttribute(buttonElement, "disabled"); - this.renderer.removeClass(buttonElement, "mat-button-disabled"); - } + if (!this.wrapper) { + return; } + + const buttonElement = + this.wrapper.nativeElement.getElementsByTagName("button")[0]; + + if (!buttonElement) { + return; + } + + if (this.elementDisabled) { + this.disable(buttonElement); + } else { + this.enable(buttonElement); + } + } + + private enable(buttonElement: HTMLButtonElement) { + this.renderer.removeAttribute(buttonElement, "disabled"); + this.renderer.removeClass(buttonElement, "mat-button-disabled"); + } + + private disable(buttonElement: HTMLButtonElement) { + this.renderer.addClass(buttonElement, "mat-button-disabled"); + this.renderer.setAttribute(buttonElement, "disabled", "true"); } } diff --git a/src/app/utils/mocked-testing.module.ts b/src/app/utils/mocked-testing.module.ts index 2f6c6e0a6f..c5414eef6a 100644 --- a/src/app/utils/mocked-testing.module.ts +++ b/src/app/utils/mocked-testing.module.ts @@ -23,6 +23,9 @@ import { BehaviorSubject } from "rxjs"; import { CurrentUserSubject } from "../core/session/current-user-subject"; import { SessionInfo, SessionSubject } from "../core/session/auth/session-info"; import { TEST_USER } from "../core/user/demo-user-generator.service"; +import { EntityAbility } from "../core/permissions/ability/entity-ability"; +import { EntitySchemaService } from "../core/entity/schema/entity-schema.service"; +import { entityAbilityFactory } from "app/core/permissions/ability/testing-entity-ability-factory"; /** * Utility module that can be imported in test files or stories to have mock implementations of the SessionService @@ -72,9 +75,15 @@ export class MockedTestingModule { ): ModuleWithProviders { environment.session_type = SessionType.mock; const mockedEntityMapper = mockEntityMapper([...data]); + return { ngModule: MockedTestingModule, providers: [ + { + provide: EntityAbility, + useFactory: entityAbilityFactory, + deps: [EntitySchemaService], + }, { provide: EntityMapperService, useValue: mockedEntityMapper }, { provide: ConfigService, useValue: createTestingConfigService() }, {