diff --git a/src/app/core/directives/not-role-toggle.directive.spec.ts b/src/app/core/directives/not-role-toggle.directive.spec.ts new file mode 100644 index 0000000000..641c824c8b --- /dev/null +++ b/src/app/core/directives/not-role-toggle.directive.spec.ts @@ -0,0 +1,76 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RoleToggleModule } from 'ish-core/role-toggle.module'; + +@Component({ + template: ` +
unrelated
+
content1
+
content2
+
content3
+ `, + // Default change detection for dynamic role test + changeDetection: ChangeDetectionStrategy.Default, +}) +class TestComponent { + dynamicRole: string; +} + +describe('Not Role Toggle Directive', () => { + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TestComponent], + imports: [RoleToggleModule.forTesting('ROLE1')], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + element = fixture.nativeElement; + fixture.detectChanges(); + }); + + it('should always render unrelated content', () => { + expect(element.textContent).toContain('unrelated'); + }); + + it('should not render content if role is given', () => { + expect(element.textContent).not.toContain('content1'); + }); + + it('should render content if the role is not given', () => { + expect(element.textContent).toContain('content2'); + }); + + it('should react on changing roles in store', () => { + RoleToggleModule.switchTestingRoles('ROLE2'); + fixture.detectChanges(); + + expect(element.textContent).toContain('content1'); + expect(element.textContent).not.toContain('content2'); + + RoleToggleModule.switchTestingRoles('ROLE1'); + fixture.detectChanges(); + + expect(element.textContent).not.toContain('content1'); + expect(element.textContent).toContain('content2'); + }); + + it('should react on changing roles from input', () => { + expect(element.textContent).toContain('content3'); + + fixture.componentInstance.dynamicRole = 'ROLE1'; + fixture.detectChanges(); + + expect(element.textContent).not.toContain('content3'); + + fixture.componentInstance.dynamicRole = 'ROLE2'; + fixture.detectChanges(); + + expect(element.textContent).toContain('content3'); + }); +}); diff --git a/src/app/core/directives/not-role-toggle.directive.ts b/src/app/core/directives/not-role-toggle.directive.ts new file mode 100644 index 0000000000..cf4538f4ef --- /dev/null +++ b/src/app/core/directives/not-role-toggle.directive.ts @@ -0,0 +1,55 @@ +import { Directive, Input, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core'; +import { ReplaySubject, Subject, Subscription } from 'rxjs'; +import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; + +import { RoleToggleService } from 'ish-core/utils/role-toggle/role-toggle.service'; + +/** + * Structural directive. + * Used on an element, this element will only be rendered if the logged in user has NOT the specified role. + * + * @example + *
+ * Only visible when the current user is not an OCI punchout user. + *
+ */ +@Directive({ + selector: '[ishHasNotRole]', +}) +export class NotRoleToggleDirective implements OnDestroy { + private subscription: Subscription; + private enabled$ = new ReplaySubject(1); + + private destroy$ = new Subject(); + + constructor( + private templateRef: TemplateRef, + private viewContainer: ViewContainerRef, + private roleToggleService: RoleToggleService + ) { + this.enabled$.pipe(distinctUntilChanged(), takeUntil(this.destroy$)).subscribe(enabled => { + if (enabled) { + this.viewContainer.createEmbeddedView(this.templateRef); + } else { + this.viewContainer.clear(); + } + }); + } + + @Input() set ishHasNotRole(roleId: string) { + // end previous subscription and newly subscribe + if (this.subscription) { + // tslint:disable-next-line: ban + this.subscription.unsubscribe(); + } + this.subscription = this.roleToggleService + .hasRole(roleId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ next: val => this.enabled$.next(!val) }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/app/core/role-toggle.module.ts b/src/app/core/role-toggle.module.ts new file mode 100644 index 0000000000..7e6d03841a --- /dev/null +++ b/src/app/core/role-toggle.module.ts @@ -0,0 +1,40 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { ReplaySubject } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { NotRoleToggleDirective } from './directives/not-role-toggle.directive'; +import { whenTruthy } from './utils/operators'; +import { RoleToggleService, checkRole } from './utils/role-toggle/role-toggle.service'; + +@NgModule({ + declarations: [NotRoleToggleDirective], + exports: [NotRoleToggleDirective], +}) +export class RoleToggleModule { + private static roleIds = new ReplaySubject(1); + + static forTesting(...roleIds: string[]): ModuleWithProviders { + RoleToggleModule.switchTestingRoles(...roleIds); + return { + ngModule: RoleToggleModule, + providers: [ + { + provide: RoleToggleService, + useValue: { + hasRole: (roleId: string) => + RoleToggleModule.roleIds.pipe( + whenTruthy(), + map(roles => checkRole(roles, roleId)) + ), + }, + }, + ], + }; + } + + static switchTestingRoles(...roleIds: string[]) { + RoleToggleModule.roleIds.next(roleIds); + } +} + +export { RoleToggleService } from './utils/role-toggle/role-toggle.service'; diff --git a/src/app/core/utils/role-toggle/role-toggle.service.spec.ts b/src/app/core/utils/role-toggle/role-toggle.service.spec.ts new file mode 100644 index 0000000000..cae4368bd3 --- /dev/null +++ b/src/app/core/utils/role-toggle/role-toggle.service.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { cold } from 'jest-marbles'; + +import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; +import { loadRolesAndPermissionsSuccess } from 'ish-core/store/customer/authorization'; +import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module'; + +import { RoleToggleService } from './role-toggle.service'; + +describe('Role Toggle Service', () => { + let store$: Store; + let roleToggleService: RoleToggleService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreStoreModule.forTesting(), CustomerStoreModule.forTesting('authorization')], + }); + + store$ = TestBed.inject(Store); + store$.dispatch( + loadRolesAndPermissionsSuccess({ + authorization: { roles: [{ roleId: 'ROLE1', displayName: 'Role 1' }], permissionIDs: [] }, + }) + ); + + roleToggleService = TestBed.inject(RoleToggleService); + }); + + describe('hasRole', () => { + it('should return true if user has the role', () => { + expect(roleToggleService.hasRole('ROLE1')).toBeObservable(cold('a', { a: true })); + }); + + it("should return false if user doesn't have the role", () => { + expect(roleToggleService.hasRole('ROLE2')).toBeObservable(cold('a', { a: false })); + }); + }); +}); diff --git a/src/app/core/utils/role-toggle/role-toggle.service.ts b/src/app/core/utils/role-toggle/role-toggle.service.ts new file mode 100644 index 0000000000..ba2fa55e76 --- /dev/null +++ b/src/app/core/utils/role-toggle/role-toggle.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { getUserRoles } from 'ish-core/store/customer/authorization'; +import { whenTruthy } from 'ish-core/utils/operators'; + +export function checkRole(roleIds: string[], roleId: string): boolean { + return roleIds.includes(roleId); +} + +@Injectable({ providedIn: 'root' }) +export class RoleToggleService { + private roleIds$: Observable; + + constructor(store: Store) { + this.roleIds$ = store.pipe(select(getUserRoles)).pipe(map(roles => roles.map(role => role.roleId))); + } + + hasRole(roleId: string): Observable { + return this.roleIds$.pipe( + // wait for permissions to be loaded + whenTruthy(), + map(roleIds => checkRole(roleIds, roleId)) + ); + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 2e44bb85bc..8b9a6425a8 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -19,6 +19,7 @@ import { AuthorizationToggleModule } from 'ish-core/authorization-toggle.module' import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { IconModule } from 'ish-core/icon.module'; import { PipesModule } from 'ish-core/pipes.module'; +import { RoleToggleModule } from 'ish-core/role-toggle.module'; import { ShellModule } from 'ish-shell/shell.module'; import { AddressFormsSharedModule } from './address-forms/address-forms.module'; @@ -129,6 +130,7 @@ const importExportModules = [ NgbPopoverModule, PipesModule, ReactiveFormsModule, + RoleToggleModule, RouterModule, ShellModule, SwiperModule, diff --git a/src/app/shell/shell.module.ts b/src/app/shell/shell.module.ts index 1147bf0900..2391d7b5df 100644 --- a/src/app/shell/shell.module.ts +++ b/src/app/shell/shell.module.ts @@ -10,6 +10,7 @@ import { DirectivesModule } from 'ish-core/directives.module'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { IconModule } from 'ish-core/icon.module'; import { PipesModule } from 'ish-core/pipes.module'; +import { RoleToggleModule } from 'ish-core/role-toggle.module'; import { CaptchaExportsModule } from '../extensions/captcha/exports/captcha-exports.module'; import { OrderTemplatesExportsModule } from '../extensions/order-templates/exports/order-templates-exports.module'; @@ -40,6 +41,7 @@ const importExportModules = [ OrderTemplatesExportsModule, QuickorderExportsModule, QuotingExportsModule, + RoleToggleModule, TactonExportsModule, WishlistsExportsModule, ];