From bba66ea0d632f3ffc45cc4eaf1d5683aaa3c9220 Mon Sep 17 00:00:00 2001 From: Dan Percic Date: Mon, 19 Dec 2022 11:36:52 +0200 Subject: [PATCH] Add deny directive --- .../allow/allow.directive.spec.ts} | 8 +- .../directives/allow/allow.directive.ts | 47 ++ .../directives/deny/deny.directive.spec.ts | 788 ++++++++++++++++++ .../directives/deny/deny.directive.ts | 55 ++ .../directives/permissions.directive.ts | 133 +++ src/lib/permissions/index.ts | 2 +- src/lib/permissions/permissions.directive.ts | 156 ---- src/lib/permissions/permissions.module.ts | 7 +- .../services/permissions.service.ts | 11 + src/lib/permissions/services/roles.service.ts | 11 + 10 files changed, 1054 insertions(+), 164 deletions(-) rename src/lib/permissions/{permissions.directive.spec.ts => directives/allow/allow.directive.spec.ts} (98%) create mode 100644 src/lib/permissions/directives/allow/allow.directive.ts create mode 100644 src/lib/permissions/directives/deny/deny.directive.spec.ts create mode 100644 src/lib/permissions/directives/deny/deny.directive.ts create mode 100644 src/lib/permissions/directives/permissions.directive.ts delete mode 100644 src/lib/permissions/permissions.directive.ts diff --git a/src/lib/permissions/permissions.directive.spec.ts b/src/lib/permissions/directives/allow/allow.directive.spec.ts similarity index 98% rename from src/lib/permissions/permissions.directive.spec.ts rename to src/lib/permissions/directives/allow/allow.directive.spec.ts index 11ee281..34bc3d7 100644 --- a/src/lib/permissions/permissions.directive.spec.ts +++ b/src/lib/permissions/directives/allow/allow.directive.spec.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, Type } from '@angular/core'; -import { IqserPermissionsModule } from '.'; +import { IqserPermissionsModule } from '../../index'; import { ComponentFixture, ComponentFixtureAutoDetect, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { IqserPermissionsService } from './services/permissions.service'; -import { IqserRolesService } from './services/roles.service'; +import { IqserPermissionsService } from '../../services/permissions.service'; +import { IqserRolesService } from '../../services/roles.service'; import { BehaviorSubject } from 'rxjs'; class BaseTestComponent { @@ -41,7 +41,7 @@ function getFixtureContent(): HTMLElement { describe('Permission directive', () => { @Component({ - template: ` + template: `
123
`, }) diff --git a/src/lib/permissions/directives/allow/allow.directive.ts b/src/lib/permissions/directives/allow/allow.directive.ts new file mode 100644 index 0000000..74b69bf --- /dev/null +++ b/src/lib/permissions/directives/allow/allow.directive.ts @@ -0,0 +1,47 @@ +import { Directive, Input, OnDestroy, OnInit, TemplateRef } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { List } from '../../../utils'; +import { assertTemplate, IqserPermissionsDirective } from '../permissions.directive'; + +@Directive({ + selector: '[allow]', +}) +export class IqserAllowDirective extends IqserPermissionsDirective implements OnDestroy, OnInit { + /** + * Assert the correct type of the expression bound to the `allow` input within the template. + * + * The presence of this static field is a signal to the Ivy template type check compiler that + * when the `IqserPermissionsDirective` structural directive renders its template, the type of the expression bound + * to `allow` should be narrowed in some way. + * For `allow`, the binding expression itself is used to + * narrow its type, which allows the strictNullChecks feature of TypeScript to work with `IqserPermissionsDirective`. + */ + static ngTemplateGuard_allow: 'binding'; + + constructor(templateRef: TemplateRef) { + super(templateRef); + } + + @Input() + set allow(value: string | List) { + this.setPermissions(value); + } + + @Input() + set allowThen(template: TemplateRef) { + assertTemplate('allowThen', template); + this.setThenTemplateRef(template); + } + + @Input() + set allowElse(template: TemplateRef) { + assertTemplate('allowElse', template); + this.setElseTemplateRef(template); + } + + @Input() + set allowIf(value: boolean | Promise | Observable) { + this.setIf(value); + } +} diff --git a/src/lib/permissions/directives/deny/deny.directive.spec.ts b/src/lib/permissions/directives/deny/deny.directive.spec.ts new file mode 100644 index 0000000..0952f78 --- /dev/null +++ b/src/lib/permissions/directives/deny/deny.directive.spec.ts @@ -0,0 +1,788 @@ +import { ChangeDetectionStrategy, Component, Type } from '@angular/core'; +import { IqserPermissionsModule } from '../../index'; +import { ComponentFixture, ComponentFixtureAutoDetect, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { IqserPermissionsService } from '../../services/permissions.service'; +import { IqserRolesService } from '../../services/roles.service'; +import { BehaviorSubject } from 'rxjs'; + +class BaseTestComponent { + isAuthorized() {} + + isUnauthorized() {} +} + +const ADMIN = 'ADMIN' as const; +const GUEST = 'GUEST' as const; + +let fixture: ComponentFixture; +let permissionsService: IqserPermissionsService; +let rolesService: IqserRolesService; +let isAuthorizedSpy: jest.SpyInstance; +let isUnauthorizedSpy: jest.SpyInstance; + +function configureTestBed(component: Type) { + TestBed.configureTestingModule({ + declarations: [component], + imports: [IqserPermissionsModule.forRoot()], + providers: [{ provide: ComponentFixtureAutoDetect, useValue: true }], + }); + + fixture = TestBed.createComponent(component); + isAuthorizedSpy = jest.spyOn(fixture.componentInstance, 'isAuthorized'); + isUnauthorizedSpy = jest.spyOn(fixture.componentInstance, 'isUnauthorized'); + + permissionsService = TestBed.inject(IqserPermissionsService); + rolesService = TestBed.inject(IqserRolesService); +} + +function getFixtureContent(): HTMLElement { + return fixture.debugElement.nativeElement.querySelector('div'); +} + +describe('Permission directive', () => { + @Component({ + template: ` +
123
+
`, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should show the component', fakeAsync(() => { + permissionsService.load([GUEST]); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + tick(); + })); + + it('should not show the component', fakeAsync(() => { + permissionsService.load([ADMIN, GUEST]); + tick(); + + expect(getFixtureContent()).toEqual(null); + expect(isAuthorizedSpy).toHaveBeenCalledTimes(0); + })); + + it('should show component when permission removed', fakeAsync(() => { + permissionsService.load([ADMIN, GUEST]); + tick(); + + expect(isUnauthorizedSpy).toHaveBeenCalledTimes(1); + expect(getFixtureContent()).toEqual(null); + + permissionsService.remove(ADMIN); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + + expect(isAuthorizedSpy).toHaveBeenCalledTimes(1); + })); + + it('should hide component when permission added', fakeAsync(() => { + permissionsService.load([GUEST]); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + + permissionsService.add(ADMIN); + tick(); + + expect(getFixtureContent()).toEqual(null); + expect(isUnauthorizedSpy).toHaveBeenCalledTimes(1); + })); +}); + +describe('Permission directive with roles', () => { + @Component({ + template: ` +
123
+
`, + }) + class TestComponent extends BaseTestComponent {} + + const awesomePermissions = 'AWESOME'; + beforeEach(() => configureTestBed(TestComponent)); + + it('should hide the component when key of role is the same', fakeAsync(() => { + rolesService.add({ ADMIN: [awesomePermissions] }); + permissionsService.add(awesomePermissions); + tick(); + + expect(getFixtureContent()).toBe(null); + })); + + it('should show the component when user deletes all roles', fakeAsync(() => { + permissionsService.add(awesomePermissions); + rolesService.add({ ADMIN: [awesomePermissions] }); + tick(); + expect(getFixtureContent()).toEqual(null); + + rolesService.clear(); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + })); + + it('should show the component when user deletes one role', fakeAsync(() => { + permissionsService.add(awesomePermissions); + rolesService.add({ ADMIN: [awesomePermissions] }); + tick(); + expect(getFixtureContent()).toEqual(null); + + rolesService.remove(ADMIN); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + })); +}); + +describe('Permission directive with roles array', () => { + @Component({ + template: ` +
123
+
`, + }) + class TestComponent extends BaseTestComponent {} + + const awesomePermission = 'AWESOME'; + beforeEach(() => configureTestBed(TestComponent)); + + it('should hide the component when key of role is the same', fakeAsync(() => { + permissionsService.add(awesomePermission); + rolesService.add({ ADMIN: [awesomePermission] }); + rolesService.add({ GUEST: [awesomePermission] }); + tick(); + + expect(getFixtureContent()).toBe(null); + })); + + it('should show the component when user deletes all roles', fakeAsync(() => { + permissionsService.add(awesomePermission); + rolesService.add({ ADMIN: [awesomePermission] }); + rolesService.add({ GUEST: [awesomePermission] }); + tick(); + expect(getFixtureContent()).toEqual(null); + + rolesService.clear(); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + })); + + it('should hide the component when user deletes one role', fakeAsync(() => { + permissionsService.add(awesomePermission); + rolesService.add({ ADMIN: [awesomePermission] }); + rolesService.add({ GUEST: [awesomePermission] }); + tick(); + + expect(getFixtureContent()).toEqual(null); + + rolesService.remove(ADMIN); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); +}); + +describe('Permission directive testing different selectors *deny', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('Should hide the component when key of role is the same', fakeAsync(() => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + permissionsService.add('AWESOME'); + rolesService.add({ ADMIN: ['AWESOME'] }); + tick(); + expect(getFixtureContent()).toEqual(null); + })); + + it('should show the component when key of role is the same', fakeAsync(() => { + let content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + rolesService.add({ GG: ['Awsesome'] }); + tick(); + + content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + })); +}); + +describe('Permission directive angular testing different async functions in roles', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should hide the component when promise returns truthy value', fakeAsync(() => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + rolesService.add({ ADMIN: () => true }); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); + + it('should show the component when promise returns falsy value', fakeAsync(() => { + let content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + rolesService.add({ ADMIN: () => false }); + tick(); + + content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + })); +}); + +describe('Permission directive angular testing different async functions in roles via array', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should hide the component when returns truthy value', fakeAsync(() => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + rolesService.add({ ADMIN: () => true }); + rolesService.add({ GUEST: () => true }); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); + + it('should hide the component when one returns falsy value', fakeAsync(() => { + let content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + rolesService.add({ ADMIN: () => false }); + rolesService.add({ GUEST: () => true }); + tick(); + + expect(getFixtureContent()).toBe(null); + })); + + it('should show the component when all return falsy value', fakeAsync(() => { + let content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + rolesService.add({ ADMIN: () => false }); + rolesService.add({ GUEST: () => false }); + tick(); + + content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + })); + + it('should hide the component when 1 passes second fails', fakeAsync(() => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + rolesService.add({ ADMIN: () => false }); + rolesService.add({ GUEST: ['AWESOME'] }); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); +}); + +describe('Permission directive with different async functions in permissions via array', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should hide the component when returns truthy value', fakeAsync(() => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + permissionsService.add({ ADMIN: () => true }); + permissionsService.add({ GUEST: () => true }); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); + + it('should show the component when returns falsy value', fakeAsync(() => { + let content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + permissionsService.add({ ADMIN: () => false }); + tick(); + + content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + })); + + it('should hide the component when returns truthy value', fakeAsync(() => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + permissionsService.add({ + ADMIN: () => true, + GUEST: () => true, + }); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); + + it('should not show the component when functions with name and store fulfils', fakeAsync(() => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + permissionsService.add({ + ADMIN: (name, store) => { + expect(name).toBeTruthy(); + expect(store[name!].name).toBeTruthy(); + return name === ADMIN; + }, + }); + + permissionsService.add({ GUEST: () => false }); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); +}); + +describe('Permission directive testing different async functions in permissions via string', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should show the component when promise returns truthy value', fakeAsync(() => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + permissionsService.add({ ADMIN: () => true }); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); + + it('should show the component when promise returns false value', fakeAsync(() => { + let content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + permissionsService.add({ ADMIN: () => false }); + tick(); + + content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + })); + + it('should show the component when only one of the promises fulfills', fakeAsync(() => { + let content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + permissionsService.add({ ADMIN: () => false }); + permissionsService.add({ GUEST: () => true }); + tick(); + + content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + })); + + it('should show the component when all promises fails', fakeAsync(() => { + let content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + permissionsService.add({ ADMIN: () => false }); + permissionsService.add({ GUEST: () => false }); + tick(); + + content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + })); + + it('should not show the component when functions with name and store fulfils', fakeAsync(() => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('
123
'); + + permissionsService.add({ + ADMIN: (name, store) => { + expect(name).toBeTruthy(); + expect(store[name!].name).toBeTruthy(); + return name === ADMIN; + }, + }); + + permissionsService.add({ GUEST: () => false }); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); +}); + +describe('Permissions directive with else block', () => { + @Component({ + template: ` +
main
+ + +
elseBlockContent
+
+ + +
thenBlockContent
+
+ `, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should show else block when permissions fail', fakeAsync(() => { + let content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual(`main`); + + permissionsService.add(ADMIN); + tick(); + + content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual(`elseBlockContent`); + })); + + it('should show else block when permissions change', fakeAsync(() => { + rolesService.add({ FAILED_BLOCK: () => true }); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('elseBlockContent'); + + rolesService.remove('FAILED_BLOCK'); + tick(); + + const content2 = getFixtureContent(); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual(`main`); + })); +}); + +describe('Permissions directive with then block', () => { + @Component({ + template: ` +
main
+ + +
elseBlockContent
+
+ + +
thenBlockContent
+
+ `, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should pass and show then block', fakeAsync(() => { + rolesService.add({ THEN_BLOCK: () => false }); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual(`thenBlockContent`); + })); +}); + +describe('Permissions directive with empty argument', () => { + @Component({ + template: ` + +
123
+
+ `, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should succeed and show the component', () => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual(`123`); + }); +}); + +describe('Permissions directive with an empty array', () => { + @Component({ + template: ` + +
123
+
+ `, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should succeed and show the component', () => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual(`123`); + }); +}); + +describe('Permission directive with true if condition', () => { + @Component({ + template: ` +
123
+
`, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should show the component when permission is absent', () => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + }); + + it('should hide the component when permission is present', fakeAsync(() => { + permissionsService.add(ADMIN); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); +}); + +describe('Permission directive with true if condition', () => { + @Component({ + template: ` +
123
+
`, + }) + class TestComponent extends BaseTestComponent {} + + beforeEach(() => configureTestBed(TestComponent)); + + it('should hide the component when permission is present', fakeAsync(() => { + permissionsService.add(ADMIN); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); + + it('should hide the component when permission is absent', fakeAsync(() => { + expect(getFixtureContent()).toEqual(null); + })); +}); + +describe('Permission directive with true promise if condition', () => { + @Component({ + template: ` +
123
+
`, + }) + class TestComponent extends BaseTestComponent { + condition = Promise.resolve(true); + } + + beforeEach(() => configureTestBed(TestComponent)); + + it('should show the component when permission is absent', () => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + }); + + it('should hide the component when permission is present', fakeAsync(() => { + permissionsService.add(ADMIN); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); +}); + +describe('Permission directive with false promise if condition', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent extends BaseTestComponent { + condition = Promise.resolve(false); + } + + beforeEach(() => configureTestBed(TestComponent)); + + it('should hide the component when permission is present', fakeAsync(() => { + permissionsService.add(ADMIN); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); + + it('should hide the component when permission is absent', fakeAsync(() => { + expect(getFixtureContent()).toEqual(null); + })); +}); + +describe('Permissions directive with then block and if condition', () => { + @Component({ + template: ` +
main
+ + +
elseBlockContent
+
+ + +
thenBlockContent
+
+ `, + }) + class TestComponent extends BaseTestComponent { + condition = new BehaviorSubject(true); + } + + beforeEach(() => configureTestBed(TestComponent)); + + it('should show then block when permission missing', fakeAsync(() => { + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual(`thenBlockContent`); + })); + + it('should show else block when permission added', fakeAsync(() => { + permissionsService.add('THEN_BLOCK'); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual(`elseBlockContent`); + })); + + it('should show else block when permission added but condition is false', fakeAsync(() => { + permissionsService.add('THEN_BLOCK'); + (fixture.componentInstance as TestComponent).condition.next(false); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual(`elseBlockContent`); + })); + + it('should show else block when permission is missing and condition is false', fakeAsync(() => { + (fixture.componentInstance as TestComponent).condition.next(false); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual(`elseBlockContent`); + })); +}); + +describe('Permission directive with false promise, if condition and change detection on push', () => { + @Component({ + template: `
+
123
+
`, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent extends BaseTestComponent { + condition = new BehaviorSubject(false); + } + + beforeEach(() => configureTestBed(TestComponent)); + + it('should hide the component when permission is present', fakeAsync(() => { + permissionsService.add(ADMIN); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); + + it('should hide the component when permission is present and condition is true', fakeAsync(() => { + permissionsService.add(ADMIN); + (fixture.componentInstance as TestComponent).condition.next(true); + tick(); + + expect(getFixtureContent()).toEqual(null); + })); + + it('should hide the component when permission is absent', fakeAsync(() => { + expect(getFixtureContent()).toEqual(null); + })); + + it('should show the component when permission is missing and condition is true', fakeAsync(() => { + (fixture.componentInstance as TestComponent).condition.next(true); + tick(); + + const content = getFixtureContent(); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual(`
123
`); + })); +}); diff --git a/src/lib/permissions/directives/deny/deny.directive.ts b/src/lib/permissions/directives/deny/deny.directive.ts new file mode 100644 index 0000000..cfa85cc --- /dev/null +++ b/src/lib/permissions/directives/deny/deny.directive.ts @@ -0,0 +1,55 @@ +import { Directive, Input, OnDestroy, OnInit, TemplateRef } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { List } from '../../../utils'; +import { assertTemplate, IqserPermissionsDirective } from '../permissions.directive'; + +@Directive({ + selector: '[deny]', +}) +export class IqserDenyDirective extends IqserPermissionsDirective implements OnDestroy, OnInit { + /** + * Assert the correct type of the expression bound to the `allow` input within the template. + * + * The presence of this static field is a signal to the Ivy template type check compiler that + * when the `IqserPermissionsDirective` structural directive renders its template, the type of the expression bound + * to `allow` should be narrowed in some way. + * For `allow`, the binding expression itself is used to + * narrow its type, which allows the strictNullChecks feature of TypeScript to work with `IqserPermissionsDirective`. + */ + static ngTemplateGuard_deny: 'binding'; + + constructor(templateRef: TemplateRef) { + super(templateRef); + } + + @Input() + set deny(value: string | List) { + this.setPermissions(value); + } + + @Input() + set denyThen(template: TemplateRef) { + assertTemplate('denyThen', template); + this.setThenTemplateRef(template); + } + + @Input() + set denyElse(template: TemplateRef) { + assertTemplate('denyElse', template); + this.setElseTemplateRef(template); + } + + @Input() + set denyIf(value: boolean | Promise | Observable) { + this.setIf(value); + } + + protected override _validate() { + if (!this._permissions) { + return true; + } + + return !(this._permissionsService.hasAtLeastOne(this._permissions) || this._rolesService.hasAtLeastOne(this._permissions)); + } +} diff --git a/src/lib/permissions/directives/permissions.directive.ts b/src/lib/permissions/directives/permissions.directive.ts new file mode 100644 index 0000000..6fa8219 --- /dev/null +++ b/src/lib/permissions/directives/permissions.directive.ts @@ -0,0 +1,133 @@ +import { + ChangeDetectorRef, + Directive, + EmbeddedViewRef, + EventEmitter, + inject, + OnDestroy, + OnInit, + Output, + TemplateRef, + ViewContainerRef, + ɵstringify as stringify, +} from '@angular/core'; + +import { BehaviorSubject, combineLatest, merge, Observable, of, Subject, Subscription, switchMap } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; + +import { IqserRolesService } from '../services/roles.service'; +import { IqserPermissionsService } from '../services/permissions.service'; +import { List } from '../../utils'; +import { isBoolean } from '../utils'; + +@Directive() +export abstract class IqserPermissionsDirective implements OnDestroy, OnInit { + @Output() readonly authorized = new EventEmitter(); + @Output() readonly unauthorized = new EventEmitter(); + protected readonly _permissionsService = inject(IqserPermissionsService); + protected readonly _rolesService = inject(IqserRolesService); + protected readonly _viewContainer = inject(ViewContainerRef); + protected readonly _changeDetector = inject(ChangeDetectorRef); + protected _permissions?: string | List; + protected _thenTemplateRef: TemplateRef; + protected _elseTemplateRef?: TemplateRef; + protected _thenViewRef: EmbeddedViewRef | boolean = false; + protected _elseViewRef: EmbeddedViewRef | boolean = false; + protected readonly _updateView = new Subject(); + protected readonly _subscription = new Subscription(); + protected readonly _if = new BehaviorSubject | Observable>(of(true)); + + protected constructor(templateRef: TemplateRef) { + this._thenTemplateRef = templateRef; + + const ifCondition$ = this._if.pipe(switchMap(condition => condition)); + + this._subscription = combineLatest([ifCondition$, this._updateView]) + .pipe( + switchMap(([ifCondition]) => this._validateRolesAndPermissions().pipe(map(hasPermission => ifCondition && hasPermission))), + tap(isAllowed => (isAllowed ? this._showThenBlock() : this._showElseBlock())), + tap(() => this._changeDetector.markForCheck()), + ) + .subscribe(); + } + + /** + This assures that when the directive has an empty input (such as [allow]="") the view is updated + */ + ngOnInit() { + this._updateView.next(); + } + + ngOnDestroy(): void { + this._subscription.unsubscribe(); + } + + protected setPermissions(value: string | List) { + this._permissions = value; + this._updateView.next(); + } + + protected setThenTemplateRef(template: TemplateRef) { + this._thenTemplateRef = template; + this._thenViewRef = false; + this._updateView.next(); + } + + protected setElseTemplateRef(template: TemplateRef) { + this._elseTemplateRef = template; + this._elseViewRef = false; + this._updateView.next(); + } + + protected setIf(value: boolean | Promise | Observable) { + if (isBoolean(value)) { + this._if.next(of(value)); + } else { + this._if.next(value); + } + } + + protected _validateRolesAndPermissions() { + return merge(this._permissionsService.permissions$, this._rolesService.roles$).pipe(map(() => this._validate())); + } + + protected _validate() { + if (!this._permissions) { + return true; + } + + return this._permissionsService.has(this._permissions) || this._rolesService.has(this._permissions); + } + + protected _showElseBlock() { + if (this._elseViewRef) { + return; + } + + this.unauthorized.emit(); + this._thenViewRef = false; + this._elseViewRef = this._showTemplate(this._elseTemplateRef); + } + + protected _showThenBlock() { + if (this._thenViewRef) { + return; + } + + this.authorized.emit(); + this._elseViewRef = false; + this._thenViewRef = this._showTemplate(this._thenTemplateRef); + } + + protected _showTemplate(template?: TemplateRef) { + this._viewContainer.clear(); + return template ? this._viewContainer.createEmbeddedView(template) : true; + } +} + +export function assertTemplate(property: string, templateRef: TemplateRef): void { + const isTemplateRefOrNull = !!(!templateRef || templateRef.createEmbeddedView); + if (!isTemplateRefOrNull) { + throw new Error(`${property} must be a TemplateRef, but received '${stringify(templateRef)}'.`); + } +} diff --git a/src/lib/permissions/index.ts b/src/lib/permissions/index.ts index 67e7c44..b255d47 100644 --- a/src/lib/permissions/index.ts +++ b/src/lib/permissions/index.ts @@ -2,6 +2,6 @@ export * from './types'; export * from './services/permissions-guard.service'; export * from './services/permissions.service'; export * from './services/roles.service'; -export * from './permissions.directive'; +export * from './directives/permissions.directive'; export * from './permissions.module'; export * from './utils'; diff --git a/src/lib/permissions/permissions.directive.ts b/src/lib/permissions/permissions.directive.ts deleted file mode 100644 index 1617523..0000000 --- a/src/lib/permissions/permissions.directive.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - ChangeDetectorRef, - Directive, - EmbeddedViewRef, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, - TemplateRef, - ViewContainerRef, - ɵstringify as stringify, -} from '@angular/core'; - -import { BehaviorSubject, combineLatest, merge, Observable, of, Subject, Subscription, switchMap } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; - -import { IqserRolesService } from './services/roles.service'; -import { IqserPermissionsService } from './services/permissions.service'; -import { List } from '../utils'; -import { isBoolean } from './utils'; - -@Directive({ - selector: '[allow]', -}) -export class IqserPermissionsDirective implements OnDestroy, OnInit { - /** - * Assert the correct type of the expression bound to the `allow` input within the template. - * - * The presence of this static field is a signal to the Ivy template type check compiler that - * when the `IqserPermissionsDirective` structural directive renders its template, the type of the expression bound - * to `allow` should be narrowed in some way. - * For `allow`, the binding expression itself is used to - * narrow its type, which allows the strictNullChecks feature of TypeScript to work with `IqserPermissionsDirective`. - */ - static ngTemplateGuard_allow: 'binding'; - - @Output() readonly permissionsAuthorized = new EventEmitter(); - @Output() readonly permissionsUnauthorized = new EventEmitter(); - - #permissions?: string | List; - #thenTemplateRef: TemplateRef; - #elseTemplateRef?: TemplateRef; - #thenViewRef: EmbeddedViewRef | boolean = false; - #elseViewRef: EmbeddedViewRef | boolean = false; - - readonly #updateView = new Subject(); - readonly #subscription = new Subscription(); - readonly #if = new BehaviorSubject | Observable>(of(true)); - - constructor( - private readonly _permissionsService: IqserPermissionsService, - private readonly _rolesService: IqserRolesService, - private readonly _viewContainer: ViewContainerRef, - private readonly _changeDetector: ChangeDetectorRef, - templateRef: TemplateRef, - ) { - this.#thenTemplateRef = templateRef; - - const ifCondition$ = this.#if.pipe(switchMap(condition => condition)); - - this.#subscription = combineLatest([ifCondition$, this.#updateView]) - .pipe( - switchMap(([ifCondition]) => this.#validateRolesAndPermissions().pipe(map(hasPermission => ifCondition && hasPermission))), - tap(isAllowed => (isAllowed ? this.#showThenBlock() : this.#showElseBlock())), - tap(() => this._changeDetector.markForCheck()), - ) - .subscribe(); - } - - @Input() - set allow(value: string | List) { - this.#permissions = value; - this.#updateView.next(); - } - - @Input() - set allowThen(template: TemplateRef) { - assertTemplate('allowThen', template); - this.#thenTemplateRef = template; - this.#thenViewRef = false; - this.#updateView.next(); - } - - @Input() - set allowElse(template: TemplateRef) { - assertTemplate('allowElse', template); - this.#elseTemplateRef = template; - this.#elseViewRef = false; - this.#updateView.next(); - } - - @Input() - set allowIf(value: boolean | Promise | Observable) { - if (isBoolean(value)) { - this.#if.next(of(value)); - } else { - this.#if.next(value); - } - } - - /** - This assures that when the directive has an empty input (such as [allow]="") the view is updated - */ - ngOnInit() { - this.#updateView.next(); - } - - ngOnDestroy(): void { - this.#subscription.unsubscribe(); - } - - #validateRolesAndPermissions() { - return merge(this._permissionsService.permissions$, this._rolesService.roles$).pipe(map(() => this.#validate())); - } - - #validate() { - if (!this.#permissions) { - return true; - } - - return this._permissionsService.has(this.#permissions) || this._rolesService.has(this.#permissions); - } - - #showElseBlock() { - if (this.#elseViewRef) { - return; - } - - this.permissionsUnauthorized.emit(); - this.#thenViewRef = false; - this.#elseViewRef = this.#showTemplate(this.#elseTemplateRef); - } - - #showThenBlock() { - if (this.#thenViewRef) { - return; - } - - this.permissionsAuthorized.emit(); - this.#elseViewRef = false; - this.#thenViewRef = this.#showTemplate(this.#thenTemplateRef); - } - - #showTemplate(template?: TemplateRef) { - this._viewContainer.clear(); - return template ? this._viewContainer.createEmbeddedView(template) : true; - } -} - -function assertTemplate(property: string, templateRef: TemplateRef): void { - const isTemplateRefOrNull = !!(!templateRef || templateRef.createEmbeddedView); - if (!isTemplateRefOrNull) { - throw new Error(`${property} must be a TemplateRef, but received '${stringify(templateRef)}'.`); - } -} diff --git a/src/lib/permissions/permissions.module.ts b/src/lib/permissions/permissions.module.ts index e151458..c5dd2ec 100644 --- a/src/lib/permissions/permissions.module.ts +++ b/src/lib/permissions/permissions.module.ts @@ -1,12 +1,13 @@ import { NgModule, Optional } from '@angular/core'; -import { IqserPermissionsDirective } from './permissions.directive'; +import { IqserAllowDirective } from './directives/allow/allow.directive'; import { IqserPermissionsService } from './services/permissions.service'; import { IqserPermissionsGuard } from './services/permissions-guard.service'; import { IqserRolesService } from './services/roles.service'; +import { IqserDenyDirective } from './directives/deny/deny.directive'; @NgModule({ - declarations: [IqserPermissionsDirective], - exports: [IqserPermissionsDirective], + declarations: [IqserAllowDirective, IqserDenyDirective], + exports: [IqserAllowDirective, IqserDenyDirective], }) export class IqserPermissionsModule { constructor(@Optional() permissionsService: IqserPermissionsService) { diff --git a/src/lib/permissions/services/permissions.service.ts b/src/lib/permissions/services/permissions.service.ts index 1ac4946..fa7555c 100644 --- a/src/lib/permissions/services/permissions.service.ts +++ b/src/lib/permissions/services/permissions.service.ts @@ -34,6 +34,17 @@ export class IqserPermissionsService { return results.every(result => result); } + hasAtLeastOne(permissions: List | string): boolean { + const isEmpty = !permissions || permissions.length === 0; + if (isEmpty) { + return true; + } + + const all = this.#permissions$.value; + const results = toArray(permissions).map(permission => all[permission]?.(permission, all) ?? false); + return results.some(result => result); + } + load(permissions: IqserPermissions | List) { return this.#reduce(permissions); } diff --git a/src/lib/permissions/services/roles.service.ts b/src/lib/permissions/services/roles.service.ts index 5527c3e..c45863f 100644 --- a/src/lib/permissions/services/roles.service.ts +++ b/src/lib/permissions/services/roles.service.ts @@ -51,6 +51,17 @@ export class IqserRolesService { return this.#checkPermissionsIfNeeded(validations).every(result => result); } + hasAtLeastOne(roles: string | List): boolean { + const isEmpty = !roles || roles.length === 0; + + if (isEmpty) { + return true; + } + + const validations = toArray(roles).map(role => this.#runValidation(role)); + return this.#checkPermissionsIfNeeded(validations).some(result => result); + } + #checkPermissionsIfNeeded(results: List) { return results.map(result => (isBoolean(result) ? result : this._permissionsService.has(result))); }