diff --git a/.gitignore b/.gitignore index 71b3b824f..22cd5e848 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ testem.log .keys.json keys.ts src/environments/keys.ts +debug.log # System Files .DS_Store diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 755dcd5da..f818512c2 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -57,7 +57,8 @@ import { ProjectTypeListComponent } from './modules/customer-management/componen // tslint:disable-next-line: max-line-length import { CreateProjectTypeComponent } from './modules/customer-management/components/projects-type/components/create-project-type/create-project-type.component'; import { CustomerEffects } from './modules/customer-management/store/customer-management.effects'; -import { UserEffects } from './modules/users/store/user.effects'; +import { UserEffects as UsersEffects } from './modules/users/store/user.effects'; +import { UserEffects } from './modules/user/store/user.effects'; import { EntryEffects } from './modules/time-clock/store/entry.effects'; import { InjectTokenInterceptor } from './modules/shared/interceptors/inject.token.interceptor'; import { SubstractDatePipe } from './modules/shared/pipes/substract-date/substract-date.pipe'; @@ -74,7 +75,7 @@ import { LoadingBarComponent } from './modules/shared/components/loading-bar/loa import { UsersComponent } from './modules/users/pages/users.component'; import { UsersListComponent } from './modules/users/components/users-list/users-list.component'; import { UiSwitchModule } from 'ngx-ui-switch'; -import {NgxMaterialTimepickerModule} from 'ngx-material-timepicker'; +import { NgxMaterialTimepickerModule } from 'ngx-material-timepicker'; // tslint:disable-next-line: max-line-length import { TechnologyReportTableComponent } from './modules/technology-report/components/technology-report-table/technology-report-table.component'; import { TechnologyReportComponent } from './modules/technology-report/pages/technology-report.component'; @@ -151,8 +152,8 @@ const maskConfig: Partial = { }), !environment.production ? StoreDevtoolsModule.instrument({ - maxAge: 15, // Retains last 15 states - }) + maxAge: 15, // Retains last 15 states + }) : [], EffectsModule.forRoot([ ProjectEffects, @@ -161,9 +162,10 @@ const maskConfig: Partial = { TechnologyEffects, ProjectTypeEffects, EntryEffects, + UsersEffects, UserEffects, ]), - ToastrModule.forRoot() + ToastrModule.forRoot(), ], providers: [ { @@ -176,4 +178,4 @@ const maskConfig: Partial = { ], bootstrap: [AppComponent], }) -export class AppModule { } +export class AppModule {} 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 7f8ed66b4..f4e486f60 100644 --- a/src/app/modules/login/services/azure.ad.b2c.service.ts +++ b/src/app/modules/login/services/azure.ad.b2c.service.ts @@ -83,4 +83,8 @@ export class AzureAdB2CService { getUserGroup(): string { return this.msal.getAccount().idToken?.extension_role; } + + getUserId(): string{ + return this.msal.getAccount().accountIdentifier; + } } diff --git a/src/app/modules/user/models/user.ts b/src/app/modules/user/models/user.ts new file mode 100644 index 000000000..604b84970 --- /dev/null +++ b/src/app/modules/user/models/user.ts @@ -0,0 +1,9 @@ +export interface User { + name: string; + email: string; + roles?: string[]; + groups?: string[]; + id: string; + tenant_id?: string; + deleted?: string; +} diff --git a/src/app/modules/user/services/user-info.service.spec.ts b/src/app/modules/user/services/user-info.service.spec.ts new file mode 100644 index 000000000..33fddfd45 --- /dev/null +++ b/src/app/modules/user/services/user-info.service.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { of } from 'rxjs'; +import { getUserGroups } from '../store/user.selectors'; +import { UserInfoService } from './user-info.service'; + +describe('UserInfoService', () => { + let service: UserInfoService; + let store: MockStore; + let mockGetUserGroupsSelector: any; + const initialState = { + name: 'Unknown Name', + email: 'example@mail.com', + roles: [], + groups: ['fake-admin', 'fake-tester'], + id: 'dummy_id_load', + tenant_id: 'dummy_tenant_id_load', + deleted: '', + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideMockStore({ initialState })], + }); + service = TestBed.inject(UserInfoService); + store = TestBed.inject(MockStore); + mockGetUserGroupsSelector = store.overrideSelector(getUserGroups, initialState.groups); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call groups selector', () => { + const expectedGroups = ['fake-admin', 'fake-tester']; + + service.groups().subscribe((value) => { + expect(value).toEqual(expectedGroups); + }); + }); + + const params = [ + { groupName: 'fake-admin', expectedValue: true, groups: ['fake-admin', 'fake-tester'] }, + { groupName: 'fake-owner', expectedValue: false, groups: ['fake-admin', 'fake-tester'] }, + ]; + + params.map((param) => { + it(`given group ${param.groupName} and groups [${param.groups.toString()}], isMemberOf() should return ${ + param.expectedValue + }`, () => { + const groups$ = of(param.groups); + + spyOn(service, 'groups').and.returnValue(groups$); + + service.isMemberOf(param.groupName).subscribe((value) => { + expect(value).toEqual(param.expectedValue); + }); + }); + }); +}); diff --git a/src/app/modules/user/services/user-info.service.ts b/src/app/modules/user/services/user-info.service.ts new file mode 100644 index 000000000..a9e3e5161 --- /dev/null +++ b/src/app/modules/user/services/user-info.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { getUserGroups } from '../store/user.selectors'; +import { GROUPS } from '../../../../environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class UserInfoService { + constructor(private store: Store) {} + + groups(): Observable { + return this.store.pipe(select(getUserGroups)); + } + + isMemberOf(groupName: string): Observable { + return this.groups().pipe( + map((groups: string[]) => { + return groups.includes(groupName); + }) + ); + } + + isAdmin(): Observable { + return this.isMemberOf(GROUPS.ADMIN); + } + + isTester(): Observable { + return this.isMemberOf(GROUPS.TESTER); + } +} diff --git a/src/app/modules/user/services/user.service.ts b/src/app/modules/user/services/user.service.ts new file mode 100644 index 000000000..ac711ad4d --- /dev/null +++ b/src/app/modules/user/services/user.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from 'src/environments/environment'; +import { User } from '../models/user'; + +@Injectable({ + providedIn: 'root', +}) +export class UserService { + + constructor(private http: HttpClient) { + } + + baseUrl = `${environment.timeTrackerApiUrl}/users`; + + loadUser(userId: string): Observable { + return this.http.get(`${this.baseUrl}/${userId}`); + } +} diff --git a/src/app/modules/user/store/user.actions.spec.ts b/src/app/modules/user/store/user.actions.spec.ts new file mode 100644 index 000000000..99c2746ad --- /dev/null +++ b/src/app/modules/user/store/user.actions.spec.ts @@ -0,0 +1,28 @@ +import { LoadUserFail, LoadUserSuccess, UserActionTypes } from './user.actions'; + +import { User } from '../models/user'; + +describe('Actions for User', () => { + it('LoadUserSuccess type is UserActionTypes.LOAD_USER_SUCCESS', () => { + const user: User = { + name: 'Unknown Name', + email: 'example@mail.com', + roles: [], + groups: [], + id: 'dummy_id_load', + tenant_id: 'dummy_tenant_id_load', + deleted: '' + }; + + const loadUserSuccess = new LoadUserSuccess(user); + + expect(loadUserSuccess.type).toEqual(UserActionTypes.LOAD_USER_SUCCESS); + }); + + it('LoadUserFail type is UserActionTypes.LOAD_USER_FAIL', () => { + const loadUserFail = new LoadUserFail('error'); + + expect(loadUserFail.type).toEqual(UserActionTypes.LOAD_USER_FAIL); + }); + +}); diff --git a/src/app/modules/user/store/user.actions.ts b/src/app/modules/user/store/user.actions.ts new file mode 100644 index 000000000..b5b5a664b --- /dev/null +++ b/src/app/modules/user/store/user.actions.ts @@ -0,0 +1,27 @@ +import { Action } from '@ngrx/store'; +import { User } from '../models/user'; + +export enum UserActionTypes { + LOAD_USER = '[User] LOAD_USER', + LOAD_USER_SUCCESS = '[User] LOAD_USER_SUCCESS', + LOAD_USER_FAIL = '[User] LOAD_USER_FAIL', +} + +export class LoadUser implements Action { + public readonly type = UserActionTypes.LOAD_USER; + constructor(readonly userId: string) {} +} + +export class LoadUserSuccess implements Action { + public readonly type = UserActionTypes.LOAD_USER_SUCCESS; + + constructor(readonly payload: User) {} +} + +export class LoadUserFail implements Action { + public readonly type = UserActionTypes.LOAD_USER_FAIL; + + constructor(public error: string) {} +} + +export type UserActions = LoadUser | LoadUserSuccess | LoadUserFail; diff --git a/src/app/modules/user/store/user.effects.spec.ts b/src/app/modules/user/store/user.effects.spec.ts new file mode 100644 index 000000000..c2ec5c2a3 --- /dev/null +++ b/src/app/modules/user/store/user.effects.spec.ts @@ -0,0 +1,62 @@ +import { Observable, of, throwError } from 'rxjs'; +import { Action } from '@ngrx/store'; +import { User } from '../models/user'; +import { UserEffects } from './user.effects'; +import { TestBed } from '@angular/core/testing'; +import { UserService } from '../services/user.service'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { LoadUser, UserActionTypes } from './user.actions'; + +describe('UserEffects', () => { + let actions$: Observable; + let effects: UserEffects; + let service: UserService; + const userInfo: User = { + name: 'Unknown Name', + email: 'example@mail.com', + roles: [], + groups: [], + id: 'dummy_tenant_id_load', + tenant_id: null, + deleted: null + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [UserEffects, provideMockActions(() => actions$)], + imports: [HttpClientTestingModule], + }); + + effects = TestBed.inject(UserEffects); + service = TestBed.inject(UserService); + }); + + it('should be created', async () => { + expect(effects).toBeTruthy(); + }); + + it('action type is LOAD_USER_SUCCESS when service is executed successfully', async () => { + const userId = 'dummy_id_load'; + const serviceSpy = spyOn(service, 'loadUser'); + + actions$ = of(new LoadUser(userId)); + serviceSpy.and.returnValue(of(userInfo)); + + effects.loadUserInfo$.subscribe((action) => { + expect(action.type).toEqual(UserActionTypes.LOAD_USER_SUCCESS); + }); + }); + + it('action type is LOAD_USER_FAIL when service fail in execution', async () => { + const userId = 'dummy_id_load'; + const serviceSpy = spyOn(service, 'loadUser'); + + actions$ = of(new LoadUser(userId)); + serviceSpy.and.returnValue(throwError({ error: { message: 'fail!' } })); + + effects.loadUserInfo$.subscribe((action) => { + expect(action.type).toEqual(UserActionTypes.LOAD_USER_FAIL); + }); + }); +}); diff --git a/src/app/modules/user/store/user.effects.ts b/src/app/modules/user/store/user.effects.ts new file mode 100644 index 000000000..4533a5cc0 --- /dev/null +++ b/src/app/modules/user/store/user.effects.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { Actions, ofType, Effect } from '@ngrx/effects'; +import { Action } from '@ngrx/store'; +import { Observable, of } from 'rxjs'; +import { catchError, map, mergeMap } from 'rxjs/operators'; +import { UserService } from '../services/user.service'; +import * as actions from './user.actions'; + +@Injectable() +export class UserEffects { + constructor(private actions$: Actions, private userService: UserService) {} + + @Effect() + loadUserInfo$: Observable = this.actions$.pipe( + ofType(actions.UserActionTypes.LOAD_USER), + map((action: actions.LoadUser) => action.userId), + mergeMap((userId) => + this.userService.loadUser(userId).pipe( + map((response) => new actions.LoadUserSuccess(response)), + catchError((error) => of(new actions.LoadUserFail(error))) + ) + ) + ); +} diff --git a/src/app/modules/user/store/user.reducer.spec.ts b/src/app/modules/user/store/user.reducer.spec.ts new file mode 100644 index 000000000..a73313362 --- /dev/null +++ b/src/app/modules/user/store/user.reducer.spec.ts @@ -0,0 +1,44 @@ +import { userReducer } from './user.reducer'; +import { LoadUser, LoadUserFail, LoadUserSuccess } from './user.actions'; +import { User } from '../models/user'; + +describe('userReducer', () => { + const initialState = { + name: '', + email: '', + roles: [], + groups: [], + }; + + it('on LoadUser, state equal to initialState', () => { + const userId = 'dummy_id_load'; + const action = new LoadUser(userId); + const state = userReducer(initialState, action); + + expect(state).toEqual(initialState); + }); + + it('on LoadUserSuccess, userFound is saved in store', () => { + const userFound: User = { + name: 'Unknown Name', + email: 'example@mail.com', + roles: [], + groups: [], + id: 'dummy_id_load', + tenant_id: null, + deleted: null + }; + + const action = new LoadUserSuccess(userFound); + const state = userReducer(initialState, action); + + expect(state).toEqual(userFound); + }); + + it('on LoadUserFail, state equal to initialState', () => { + const action = new LoadUserFail('error'); + const state = userReducer(initialState, action); + + expect(state).toEqual(initialState); + }); +}); diff --git a/src/app/modules/user/store/user.reducer.ts b/src/app/modules/user/store/user.reducer.ts new file mode 100644 index 000000000..dd10eae49 --- /dev/null +++ b/src/app/modules/user/store/user.reducer.ts @@ -0,0 +1,22 @@ +import { UserActions, UserActionTypes } from './user.actions'; + +export const initialState = { + name: '', + email: '', + roles: [], + groups: [], +}; + +export const userReducer = (state: any = initialState, action: UserActions): any => { + switch (action.type) { + case UserActionTypes.LOAD_USER: + return state; + case UserActionTypes.LOAD_USER_SUCCESS: + return action.payload; + case UserActionTypes.LOAD_USER_FAIL: + return state; + default: { + return state; + } + } +}; diff --git a/src/app/modules/user/store/user.selectors.spec.ts b/src/app/modules/user/store/user.selectors.spec.ts new file mode 100644 index 000000000..d5fc7e103 --- /dev/null +++ b/src/app/modules/user/store/user.selectors.spec.ts @@ -0,0 +1,22 @@ +import { getUserGroups, getUserInfo } from './user.selectors'; +import { User } from '../models/user'; + +describe('UserSelectors', () => { + const userState: User = { + name: 'Unknown Name', + email: 'example@mail.com', + roles: [], + groups: [], + id: 'dummy_tenant_id_load', + tenant_id: null, + deleted: null, + }; + + it('should select user from store', () => { + expect(getUserInfo.projector(userState)).toEqual(userState); + }); + + it('should select user groups from store', () => { + expect(getUserGroups.projector(userState)).toEqual(userState.groups); + }); +}); diff --git a/src/app/modules/user/store/user.selectors.ts b/src/app/modules/user/store/user.selectors.ts new file mode 100644 index 000000000..525d288fe --- /dev/null +++ b/src/app/modules/user/store/user.selectors.ts @@ -0,0 +1,7 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { User } from '../models/user'; + +const getUserState = createFeatureSelector('user'); + +export const getUserInfo = createSelector(getUserState, (state: User) => state); +export const getUserGroups = createSelector(getUserState, (state: User) => state.groups); diff --git a/src/app/reducers/index.ts b/src/app/reducers/index.ts index 6e1316651..5e0f8585d 100644 --- a/src/app/reducers/index.ts +++ b/src/app/reducers/index.ts @@ -6,7 +6,8 @@ import { customerManagementReducer } from '../modules/customer-management/store/ import { projectTypeReducer } from '../modules/customer-management/components/projects-type/store/project-type.reducers'; import { entryReducer } from '../modules/time-clock/store/entry.reducer'; import { environment } from '../../environments/environment'; -import { userReducer } from '../modules/users/store/user.reducers'; +import { userReducer } from '../modules/user/store/user.reducer'; +import { userReducer as usersReducer } from '../modules/users/store/user.reducers'; export interface State { projects; activities; @@ -15,6 +16,7 @@ export interface State { projectType; entries; users; + user; } export const reducers: ActionReducerMap = { @@ -24,7 +26,8 @@ export const reducers: ActionReducerMap = { technologies: technologyReducer, projectType: projectTypeReducer, entries: entryReducer, - users: userReducer, + users: usersReducer, + user: userReducer, }; export const metaReducers: MetaReducer[] = !environment.production ? [] : []; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 208e0a880..5b04fe66e 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -18,6 +18,10 @@ export const STACK_EXCHANGE_ACCESS_TOKEN = keys.STACK_EXCHANGE_ACCESS_TOKEN; export const AZURE_APP_CONFIGURATION_CONNECTION_STRING = keys.AZURE_APP_CONFIGURATION_CONNECTION_STRING; export const DATE_FORMAT = 'yyyy-MM-dd'; export const DATE_FORMAT_YEAR = 'YYYY-MM-DD'; +export const GROUPS = { + ADMIN: 'time-tracker-admin', + TESTER: 'time-tracker-tester', +}; /* * For easier debugging in development mode, you can import the following file