diff --git a/package.json b/package.json index 357cd2d59..0ce4e1099 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "minimist": "^1.2.5", "moment": "^2.25.3", "msal": "^1.2.1", + "ngrx-store-localstorage": "^11.0.0", "ngx-cookie-service": "^11.0.2", "ngx-mask": "^9.1.2", "ngx-material-timepicker": "^5.5.3", @@ -88,7 +89,7 @@ "popper.js": "^1.16.0", "prettier": "^2.0.2", "protractor": "^7.0.0", - "semantic-release": "^17.3.0", + "semantic-release": "^17.4.2", "ts-node": "~8.3.0", "tslint": "~6.1.0", "typescript": "4.0.5" diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 9840d7461..9be2a978d 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,4 +1,4 @@ -import { AdminGuard } from './guards/admin-guard/admin-guard'; +import { AdminGuard } from './guards/admin-guard/admin.guard'; import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; @@ -26,7 +26,7 @@ const routes: Routes = [ { path: 'activities-management', component: ActivitiesManagementComponent }, { path: 'customers-management', canActivate: [AdminGuard], component: CustomerComponent }, { path: 'users', canActivate: [AdminGuard], component: UsersComponent }, - { path: 'technology-report', canActivate: [AdminGuard, TechnologiesReportGuard], component: TechnologyReportComponent}, + { path: 'technology-report', canActivate: [AdminGuard, TechnologiesReportGuard], component: TechnologyReportComponent }, { path: '', pathMatch: 'full', redirectTo: 'time-clock' }, ], }, @@ -37,4 +37,4 @@ const routes: Routes = [ imports: [RouterModule.forRoot(routes)], exports: [RouterModule], }) -export class AppRoutingModule {} +export class AppRoutingModule { } diff --git a/src/app/guards/admin-guard/admin-guard.ts b/src/app/guards/admin-guard/admin-guard.ts deleted file mode 100644 index 5c505e44f..000000000 --- a/src/app/guards/admin-guard/admin-guard.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Router, CanActivate } from '@angular/router'; -import { AzureAdB2CService } from '../../modules/login/services/azure.ad.b2c.service'; - -@Injectable({ - providedIn: 'root' -}) -export class AdminGuard implements CanActivate { - - constructor(private azureAdB2CService: AzureAdB2CService, private router: Router) { } - - canActivate() { - if (this.azureAdB2CService.isAdmin()) { - return true; - } else { - this.router.navigate(['login']); - return false; - } -} -} diff --git a/src/app/guards/admin-guard/admin.guard.spec.ts b/src/app/guards/admin-guard/admin.guard.spec.ts index 53d513e60..7398b97a7 100644 --- a/src/app/guards/admin-guard/admin.guard.spec.ts +++ b/src/app/guards/admin-guard/admin.guard.spec.ts @@ -1,57 +1,129 @@ import { inject, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; - +import { of } from 'rxjs'; +import { skip, take } from 'rxjs/operators'; +import { FeatureSwitchGroupService } from 'src/app/modules/shared/feature-toggles/switch-group/feature-switch-group.service'; +import { UserInfoService } from 'src/app/modules/user/services/user-info.service'; import { AzureAdB2CService } from '../../modules/login/services/azure.ad.b2c.service'; -import { AdminGuard } from './admin-guard'; +import { AdminGuard } from './admin.guard'; describe('AdminGuard', () => { - let adminGuard: AdminGuard; let azureAdB2CService: AzureAdB2CService; - + let userInfoService: UserInfoService; + let featureSwitchGroupService: FeatureSwitchGroupService; const azureAdB2CServiceStub = { isLogin() { return true; }, isAdmin() { return true; - } + }, + }; + + const userInfoServiceStub = { + isAdmin: () => of(false), + }; + + const featureSwitchGroupServiceStub = { + isActivated: () => of(false), }; beforeEach(() => { TestBed.configureTestingModule({ imports: [RouterTestingModule], providers: [ - { providers: AzureAdB2CService, useValue: azureAdB2CServiceStub }, - ] + { provide: AzureAdB2CService, useValue: azureAdB2CServiceStub }, + { provide: UserInfoService, useValue: userInfoServiceStub }, + { provide: FeatureSwitchGroupService, useValue: featureSwitchGroupServiceStub }, + ], }); adminGuard = TestBed.inject(AdminGuard); azureAdB2CService = TestBed.inject(AzureAdB2CService); + userInfoService = TestBed.inject(UserInfoService); + featureSwitchGroupService = TestBed.inject(FeatureSwitchGroupService); }); it('should be created', () => { expect(adminGuard).toBeTruthy(); }); - it('can activate the route when user is logged-in', () => { - spyOn(azureAdB2CService, 'isAdmin').and.returnValue(true); + const roleParams = [{ bool: false }, { bool: true }]; + roleParams.map((param) => { + it(`isAdminBasedInRole return ${param.bool}`, () => { + spyOn(azureAdB2CService, 'isAdmin').and.returnValue(param.bool); + + adminGuard.isAdminBasedInRole().subscribe((enabled) => { + expect(azureAdB2CService.isAdmin).toHaveBeenCalled(); + expect(enabled).toBe(param.bool); + }); + }); + }); + + const groupParams = [{ bool: false }, { bool: true }]; + groupParams.map((param) => { + it(`isAdminBasedInGroup return ${param.bool}`, () => { + spyOn(userInfoService, 'isAdmin').and.returnValue(of(param.bool)); + + adminGuard.isAdminBasedInGroup().subscribe((enabled) => { + expect(userInfoService.isAdmin).toHaveBeenCalled(); + expect(enabled).toBe(param.bool); + }); + }); + }); + + const switchToggleParams = [ + { switchGroup: false, chosen: 'isAdminBasedInRole', isAdmin: true }, + { switchGroup: true, chosen: 'isAdminBasedInGroup', isAdmin: false }, + ]; + switchToggleParams.map((param) => { + it(`on switchGroup ${param.switchGroup}, ${param.chosen} should be chosen`, () => { + const switchGroup$ = of(param.switchGroup); + + spyOn(featureSwitchGroupService, 'isActivated').and.returnValue(switchGroup$); + + const canActivate = adminGuard.canActivate(); - const canActivate = adminGuard.canActivate(); + featureSwitchGroupService.isActivated().pipe(take(1)); - expect(azureAdB2CService.isAdmin).toHaveBeenCalled(); - expect(canActivate).toEqual(true); + canActivate.subscribe((enabled) => { + expect(featureSwitchGroupService.isActivated).toHaveBeenCalled(); + expect(enabled).toBe(param.isAdmin); + }); + }); }); - it('can not active the route and is redirected to login if user is not logged-in', inject([Router], (router: Router) => { - spyOn(azureAdB2CService, 'isAdmin').and.returnValue(false); - spyOn(router, 'navigate').and.stub(); + const navigateParams = [ + { switchGroup: false, chosen: 'activate the route', isAdmin: true }, + { switchGroup: false, chosen: 'redirect to /login', isAdmin: false }, + { switchGroup: true, chosen: 'activate the route', isAdmin: true }, + { switchGroup: true, chosen: 'redirect to /login', isAdmin: false }, + ]; + navigateParams.map((param) => { + it(`on isAdmin: ${param.isAdmin} with toggleSwitch: ${param.switchGroup}, should ${param.chosen} `, inject( + [Router], + (router: Router) => { + const switchGroup$ = of(param.switchGroup); + const isAdmin$ = of(param.isAdmin); - const canActivate = adminGuard.canActivate(); + spyOn(featureSwitchGroupService, 'isActivated').and.returnValue(switchGroup$); + spyOn(adminGuard, 'isAdminBasedInRole').and.returnValue(isAdmin$); + spyOn(adminGuard, 'isAdminBasedInGroup').and.returnValue(isAdmin$); + spyOn(router, 'navigate').and.stub(); - expect(azureAdB2CService.isAdmin).toHaveBeenCalled(); - expect(canActivate).toEqual(false); - expect(router.navigate).toHaveBeenCalledWith(['login']); - })); + const canActivate = adminGuard.canActivate(); + canActivate.subscribe((enabled) => { + expect(featureSwitchGroupService.isActivated).toHaveBeenCalled(); + if (!enabled) { + expect(router.navigate).toHaveBeenCalledWith(['login']); + } else { + expect(router.navigate).not.toHaveBeenCalled(); + expect(enabled).toBeTrue(); + } + }); + } + )); + }); }); diff --git a/src/app/guards/admin-guard/admin.guard.ts b/src/app/guards/admin-guard/admin.guard.ts new file mode 100644 index 000000000..be0325b5b --- /dev/null +++ b/src/app/guards/admin-guard/admin.guard.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@angular/core'; +import { Router, CanActivate } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; +import { FeatureSwitchGroupService } from 'src/app/modules/shared/feature-toggles/switch-group/feature-switch-group.service'; +import { UserInfoService } from 'src/app/modules/user/services/user-info.service'; +import { AzureAdB2CService } from '../../modules/login/services/azure.ad.b2c.service'; + +@Injectable({ + providedIn: 'root', +}) +export class AdminGuard implements CanActivate { + constructor( + private azureAdB2CService: AzureAdB2CService, + private router: Router, + private userInfoService: UserInfoService, + private featureSwitchGroup: FeatureSwitchGroupService + ) {} + + canActivate(): Observable { + return this.featureSwitchGroup.isActivated().pipe( + mergeMap((enabled: boolean) => { + return enabled ? this.isAdminBasedInGroup() : this.isAdminBasedInRole(); + }), + map((isAdmin: boolean): boolean => { + if (!isAdmin) { + this.router.navigate(['login']); + } + return isAdmin; + }) + ); + } + + isAdminBasedInRole(): Observable { + return of(this.azureAdB2CService.isAdmin()); + } + + isAdminBasedInGroup(): Observable { + return this.userInfoService.isAdmin(); + } +} diff --git a/src/app/modules/home/home.component.spec.ts b/src/app/modules/home/home.component.spec.ts index a0ad3bf0b..234c818d7 100644 --- a/src/app/modules/home/home.component.spec.ts +++ b/src/app/modules/home/home.component.spec.ts @@ -1,25 +1,79 @@ import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; - +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { of } from 'rxjs'; +import { AzureAdB2CService } from '../login/services/azure.ad.b2c.service'; +import { FeatureSwitchGroupService } from '../shared/feature-toggles/switch-group/feature-switch-group.service'; +import { LoadUser } from '../user/store/user.actions'; import { HomeComponent } from './home.component'; describe('HomeComponent', () => { let component: HomeComponent; + let azureAdB2CService: AzureAdB2CService; + let featureSwitchGroupService: FeatureSwitchGroupService; + let store: MockStore; let fixture: ComponentFixture; + const initialState = {}; + const azureB2CServiceStub = { + getUserId: () => 'user_id', + }; + const featureSwitchGroupServiceStub = { + isActivated: () => of(false), + }; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ HomeComponent ] + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [HomeComponent], + providers: [ + provideMockStore({ initialState }), + { provide: AzureAdB2CService, useValue: azureB2CServiceStub }, + { provide: FeatureSwitchGroupService, useValue: featureSwitchGroupServiceStub }, + ], + }).compileComponents(); }) - .compileComponents(); - })); + ); beforeEach(() => { fixture = TestBed.createComponent(HomeComponent); + azureAdB2CService = TestBed.inject(AzureAdB2CService); + featureSwitchGroupService = TestBed.inject(FeatureSwitchGroupService); + store = TestBed.inject(MockStore); + component = fixture.componentInstance; fixture.detectChanges(); + store.setState(initialState); }); it('should be created', () => { expect(component).toBeTruthy(); }); + + it('onInit, if featureSwitchGroup is true LoadUser action is dispatched', () => { + const userId = 'user_id'; + spyOn(featureSwitchGroupService, 'isActivated').and.returnValue(of(true)); + spyOn(azureAdB2CService, 'getUserId').and.returnValue(userId); + spyOn(store, 'dispatch'); + + component.ngOnInit(); + + featureSwitchGroupService.isActivated().subscribe(() => { + expect(featureSwitchGroupService.isActivated).toHaveBeenCalled(); + expect(azureAdB2CService.getUserId).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new LoadUser(userId)); + }); + }); + + it('onInit, if featureSwitchGroup is false nothing happens', () => { + spyOn(featureSwitchGroupService, 'isActivated').and.returnValue(of(false)); + spyOn(azureAdB2CService, 'getUserId'); + spyOn(store, 'dispatch'); + + component.ngOnInit(); + + featureSwitchGroupService.isActivated().subscribe(() => { + expect(featureSwitchGroupService.isActivated).toHaveBeenCalled(); + expect(azureAdB2CService.getUserId).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/modules/home/home.component.ts b/src/app/modules/home/home.component.ts index 73acf06f0..cfabf7ced 100644 --- a/src/app/modules/home/home.component.ts +++ b/src/app/modules/home/home.component.ts @@ -1,15 +1,34 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Subscription } from 'rxjs'; +import { LoadUser } from 'src/app/modules/user/store/user.actions'; +import { AzureAdB2CService } from '../login/services/azure.ad.b2c.service'; +import { FeatureSwitchGroupService } from '../shared/feature-toggles/switch-group/feature-switch-group.service'; @Component({ selector: 'app-home', templateUrl: './home.component.html', - styleUrls: ['./home.component.scss'] + styleUrls: ['./home.component.scss'], }) -export class HomeComponent implements OnInit { +export class HomeComponent implements OnInit, OnDestroy { + FTSwitchGroup$: Subscription; - constructor() { } + constructor( + private featureSwitchGroup: FeatureSwitchGroupService, + private azureAdB2CService: AzureAdB2CService, + private store: Store + ) {} ngOnInit(): void { + this.FTSwitchGroup$ = this.featureSwitchGroup.isActivated().subscribe((enabled) => { + if (enabled) { + const userId = this.azureAdB2CService.getUserId(); + this.store.dispatch(new LoadUser(userId)); + } + }); } + ngOnDestroy() { + this.FTSwitchGroup$.unsubscribe(); + } } diff --git a/src/app/modules/login/services/azure.ad.b2c.service.spec.ts b/src/app/modules/login/services/azure.ad.b2c.service.spec.ts index 4cb71ef2c..bc55dcbbe 100644 --- a/src/app/modules/login/services/azure.ad.b2c.service.spec.ts +++ b/src/app/modules/login/services/azure.ad.b2c.service.spec.ts @@ -42,9 +42,12 @@ describe('AzureAdB2CService', () => { expect(UserAgentApplication.prototype.loginPopup).toHaveBeenCalled(); }); - it('on logout should call msal logout', () => { + it('on logout should call msal logout and verify if user localStorage is removed', () => { spyOn(UserAgentApplication.prototype, 'logout').and.returnValue(); + spyOn(localStorage, 'removeItem').withArgs('user'); service.logout(); + + expect(localStorage.removeItem).toHaveBeenCalledWith('user'); expect(UserAgentApplication.prototype.logout).toHaveBeenCalled(); }); @@ -66,7 +69,7 @@ describe('AzureAdB2CService', () => { }); it('isAdmin when extension_role === time-tracker-admin', async () => { - const adminAccount = {...account}; + const adminAccount = { ...account }; adminAccount.idToken.extension_role = 'time-tracker-admin'; spyOn(UserAgentApplication.prototype, 'getAccount').and.returnValue(adminAccount); diff --git a/src/app/modules/login/services/azure.ad.b2c.service.ts b/src/app/modules/login/services/azure.ad.b2c.service.ts index f4e486f60..29ba51980 100644 --- a/src/app/modules/login/services/azure.ad.b2c.service.ts +++ b/src/app/modules/login/services/azure.ad.b2c.service.ts @@ -4,12 +4,12 @@ import { from, Observable } from 'rxjs'; import { CookieService } from 'ngx-cookie-service'; import { AUTHORITY, CLIENT_ID, SCOPES } from '../../../../environments/environment'; +import { FeatureSwitchGroupService } from '../../shared/feature-toggles/switch-group/feature-switch-group.service'; @Injectable({ providedIn: 'root', }) export class AzureAdB2CService { - constructor(private cookieService?: CookieService) {} msalConfig: any = { @@ -37,6 +37,7 @@ export class AzureAdB2CService { this.cookieService.delete('msal.idtoken'); this.cookieService.delete('msal.client.info'); this.msal.logout(); + localStorage.removeItem('user'); } getName(): string { @@ -84,7 +85,7 @@ export class AzureAdB2CService { return this.msal.getAccount().idToken?.extension_role; } - getUserId(): string{ + getUserId(): string { return this.msal.getAccount().accountIdentifier; } } diff --git a/src/app/modules/shared/feature-toggles/switch-group/feature-switch-group.service.spec.ts b/src/app/modules/shared/feature-toggles/switch-group/feature-switch-group.service.spec.ts new file mode 100644 index 000000000..08817b020 --- /dev/null +++ b/src/app/modules/shared/feature-toggles/switch-group/feature-switch-group.service.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { FeatureManagerService } from '../feature-toggle-manager.service'; +import { FeatureSwitchGroupService } from './feature-switch-group.service'; + +describe('FeatureSwitchGroupService', () => { + let service: FeatureSwitchGroupService; + let featureManagerService: FeatureManagerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: FeatureManagerService }], + }); + service = TestBed.inject(FeatureSwitchGroupService); + featureManagerService = TestBed.inject(FeatureManagerService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + const params = [{ bool: false }, { bool: true }]; + params.map((param) => { + it(`isActivated should return a boolean ${param.bool}`, () => { + const toggleName = 'toggle_switch_test'; + // tslint:disable-next-line: no-shadowed-variable + featureManagerService.isToggleEnabledForUser = (toggleName) => of(param.bool); + + service.isActivated().subscribe((enabled) => { + expect(enabled).toBe(param.bool); + }); + }); + }); +}); diff --git a/src/app/modules/shared/feature-toggles/switch-group/feature-switch-group.service.ts b/src/app/modules/shared/feature-toggles/switch-group/feature-switch-group.service.ts new file mode 100644 index 000000000..987485aea --- /dev/null +++ b/src/app/modules/shared/feature-toggles/switch-group/feature-switch-group.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { TOGGLES } from 'src/environments/environment'; +import { FeatureManagerService } from '../feature-toggle-manager.service'; + +@Injectable({ + providedIn: 'root', +}) +export class FeatureSwitchGroupService { + constructor(private FTManager: FeatureManagerService) {} + isActivated(): Observable { + return this.FTManager.isToggleEnabledForUser(TOGGLES.SWITCH_GROUP); + } +} diff --git a/src/app/modules/user/services/user-info.service.ts b/src/app/modules/user/services/user-info.service.ts index a9e3e5161..d3f7ddba6 100644 --- a/src/app/modules/user/services/user-info.service.ts +++ b/src/app/modules/user/services/user-info.service.ts @@ -16,11 +16,7 @@ export class UserInfoService { } isMemberOf(groupName: string): Observable { - return this.groups().pipe( - map((groups: string[]) => { - return groups.includes(groupName); - }) - ); + return this.groups().pipe(map((groups: string[]) => groups.includes(groupName))); } isAdmin(): Observable { diff --git a/src/app/reducers/index.ts b/src/app/reducers/index.ts index 5e0f8585d..d394e85e9 100644 --- a/src/app/reducers/index.ts +++ b/src/app/reducers/index.ts @@ -1,13 +1,14 @@ -import { ActionReducerMap, MetaReducer } from '@ngrx/store'; -import { projectReducer } from '../modules/customer-management/components/projects/components/store/project.reducer'; -import { activityManagementReducer } from '../modules/activities-management/store/activity-management.reducers'; -import { technologyReducer } from '../modules/shared/store/technology.reducers'; -import { customerManagementReducer } from '../modules/customer-management/store/customer-management.reducers'; -import { projectTypeReducer } from '../modules/customer-management/components/projects-type/store/project-type.reducers'; -import { entryReducer } from '../modules/time-clock/store/entry.reducer'; +import { ActionReducer, ActionReducerMap, MetaReducer } from '@ngrx/store'; +import { localStorageSync } from 'ngrx-store-localstorage'; import { environment } from '../../environments/environment'; import { userReducer } from '../modules/user/store/user.reducer'; +import { entryReducer } from '../modules/time-clock/store/entry.reducer'; +import { technologyReducer } from '../modules/shared/store/technology.reducers'; import { userReducer as usersReducer } from '../modules/users/store/user.reducers'; +import { customerManagementReducer } from '../modules/customer-management/store/customer-management.reducers'; +import { activityManagementReducer } from '../modules/activities-management/store/activity-management.reducers'; +import { projectReducer } from '../modules/customer-management/components/projects/components/store/project.reducer'; +import { projectTypeReducer } from '../modules/customer-management/components/projects-type/store/project-type.reducers'; export interface State { projects; activities; @@ -30,4 +31,10 @@ export const reducers: ActionReducerMap = { user: userReducer, }; -export const metaReducers: MetaReducer[] = !environment.production ? [] : []; +export function localStorageSyncReducer(reducer: ActionReducer): ActionReducer { + return localStorageSync({ keys: [{ user: ['groups'] }], rehydrate: true })(reducer); +} + +export const metaReducers: MetaReducer[] = !environment.production + ? [localStorageSyncReducer] + : [localStorageSyncReducer]; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 5b04fe66e..b0c5aaf4a 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -22,6 +22,9 @@ export const GROUPS = { ADMIN: 'time-tracker-admin', TESTER: 'time-tracker-tester', }; +export const TOGGLES = { + SWITCH_GROUP: 'switch-group', +}; /* * For easier debugging in development mode, you can import the following file