diff --git a/.dev.env b/.dev.env index 9c0fa1846..5db598c8d 100644 Binary files a/.dev.env and b/.dev.env differ diff --git a/.github/workflows/time-tracker-ui-cd-prod.yml b/.github/workflows/time-tracker-ui-cd-prod.yml index 0a7ba4679..26c54d0c1 100644 --- a/.github/workflows/time-tracker-ui-cd-prod.yml +++ b/.github/workflows/time-tracker-ui-cd-prod.yml @@ -15,7 +15,7 @@ jobs: ARM_CLIENT_SECRET: ${{secrets.TF_ARM_CLIENT_SECRET}} ARM_SUBSCRIPTION_ID: ${{secrets.TF_ARM_SUBSCRIPTION_ID}} ARM_TENANT_ID: ${{secrets.TF_ARM_TENANT_ID}} - + steps: - name: Checkout uses: actions/checkout@v3 @@ -30,8 +30,16 @@ jobs: with: creds: ${{ secrets.AZURE_CREDENTIALS }} + - name: Unlock PROD secrets + uses: sliteteam/github-action-git-crypt-unlock@1.2.0 + env: + GIT_CRYPT_KEY: ${{ secrets.GIT_CRYPT_KEY_PROD }} + - name: Build the docker image - run: make build_prod + run: |- + docker build \ + --target production -t timetracker_ui -f Dockerfile_prod \ + . - name: Publish docker image to prod azure container registry run: | diff --git a/.github/workflows/time-tracker-ui-cd-stage.yml b/.github/workflows/time-tracker-ui-cd-stage.yml index 3f73c471c..a57cfec09 100644 --- a/.github/workflows/time-tracker-ui-cd-stage.yml +++ b/.github/workflows/time-tracker-ui-cd-stage.yml @@ -38,7 +38,7 @@ jobs: - name: Build the docker image run: |- docker build \ - --target production -t timetracker_ui \ + --target production -t timetracker_ui -f Dockerfile_stage \ . - name: Publish docker image to stage azure container registry @@ -61,4 +61,4 @@ jobs: - name: Terraform Apply working-directory: ${{ env.WORKING_DIR }} - run: terraform apply -lock=false -var-file="${{ env.TF_WORKSPACE }}.tfvars" -var "image_tag=$RELEASE_VERSION" -auto-approve + run: terraform apply -var-file="${{ env.TF_WORKSPACE }}.tfvars" -var "image_tag=$RELEASE_VERSION" -auto-approve diff --git a/.github/workflows/time-tracker-ui-ci.yml b/.github/workflows/time-tracker-ui-ci.yml index 07e52253c..d7992606d 100644 --- a/.github/workflows/time-tracker-ui-ci.yml +++ b/.github/workflows/time-tracker-ui-ci.yml @@ -66,8 +66,7 @@ jobs: - name: Terraform Plan Prod id: plan-prod - # run: terraform plan -var-file=${{ env.TF_WORKSPACE }}.tfvars -var image_tag=latest -no-color - run: echo "Disabled for now up to restructure infra tiers" + run: terraform plan -var-file=${{ env.TF_WORKSPACE }}.tfvars -var image_tag=latest -no-color continue-on-error: true working-directory: ./${{ env.WORKING_DIR }} env: diff --git a/.prod.env b/.prod.env index 8b545657e..b245c8510 100644 Binary files a/.prod.env and b/.prod.env differ diff --git a/.stage.env b/.stage.env index 8cab2af02..7e9798577 100644 Binary files a/.stage.env and b/.stage.env differ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index babf9d405..000000000 --- a/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -FROM node:14-alpine AS building -WORKDIR /app -# ENV USERNAME timetracker -# ENV HOME /home/${USERNAME} -# RUN useradd -ms /bin/bash ${USERNAME} -# WORKDIR ${HOME}/time-tracker-ui -COPY . /app -# RUN chown ${USERNAME}:${USERNAME} -R ${HOME}/time-tracker-ui -# RUN chmod -R 777 ${HOME}/time-tracker-ui -# USER ${USERNAME} -RUN npm cache clean --force && npm install -EXPOSE 4200 9876 -RUN source .stage.env && npm run build -# >> scrt && -# - -FROM nginx:1.21 AS production -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=building /app/dist/time-tracker /usr/share/nginx/html -# FIXME: Actually if we can deploy to azure in port 80 we need a root user -# Maybe we can refactor this dockerfile to use root user directly this is not a good approach y -# security terms. It's a good practice to have rootless in containers so for this -# we can to refactor this dockerfile and the terraform module to deploy in other ports because -# Ports below 1024 needs root permisions. - -# USER ${USERNAME} - -EXPOSE 80 \ No newline at end of file diff --git a/Dockerfile_prod b/Dockerfile_prod new file mode 100644 index 000000000..3d51f5643 --- /dev/null +++ b/Dockerfile_prod @@ -0,0 +1,13 @@ +FROM node:14-alpine AS building +WORKDIR /app +COPY . /app +RUN npm cache clean --force && npm install +EXPOSE 4200 9876 +RUN source .prod.env && npm run build + + +FROM nginx:1.21 AS production +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=building /app/dist/time-tracker /usr/share/nginx/html +EXPOSE 80 + diff --git a/Dockerfile_stage b/Dockerfile_stage new file mode 100644 index 000000000..5922ed3d9 --- /dev/null +++ b/Dockerfile_stage @@ -0,0 +1,13 @@ +FROM node:14-alpine AS building +WORKDIR /app +COPY . /app +RUN npm cache clean --force && npm install +EXPOSE 4200 9876 +RUN source .stage.env && npm run build + + +FROM nginx:1.21 AS production +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=building /app/dist/time-tracker /usr/share/nginx/html +EXPOSE 80 + diff --git a/README.md b/README.md index 3bb8268cf..94e90975a 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ We strongly recommend that you install it using Node Version Management [https:/ Angular CLI is a Command Line Interface (CLI) to speed up your development with Angular. -Run `npm install -g @angular/cli` to install Angular CLI +Run `npm install -g @angular/cli` to install Angular CLI. ### Docker @@ -134,7 +134,7 @@ In any case, the app will automatically reload if you change anything in the sou - **test**: Adding missing tests or correcting existing tests. ### Example - fix: TT-48 implement semantic versioning + fix: TTA-48 implement semantic versioning Prefix to use in the space fix: `(fix: |feat: |perf: |build: |ci: |docs: |refactor: |style: |test: )` @@ -146,9 +146,9 @@ In any case, the app will automatically reload if you change anything in the sou | `perf(pencil): remove graphiteWidth option`

`BREAKING CHANGE: The graphiteWidth option has been removed.`
`The default graphite width of 10mm is always used for performance reasons.` | ~~Major~~ Breaking Release | ### Branch names format -For example if your task in Jira is **TT-48 implement semantic versioning** your branch name is: +For example if your task in Jira is **TTA-48 implement semantic versioning** your branch name is: ``` - TT-48-implement-semantic-versioning + TTA-48-implement-semantic-versioning ``` ## Code scaffolding diff --git a/infrastructure/main.tf b/infrastructure/main.tf index 23b9518d7..4907d9b7f 100644 --- a/infrastructure/main.tf +++ b/infrastructure/main.tf @@ -33,18 +33,18 @@ data "terraform_remote_state" "service" { } locals { - common_name = "time-tracker-ui" + common_name = "time-tracker-service" environment = terraform.workspace service_name = "${local.common_name}-${local.environment}" - create_app_service_plan = true + create_app_service_plan = false service_plan_kind = "Linux" image_name = "timetracker_ui" } module "ui" { #source = "../../infra-terraform-modules/azure-app-service" - source = "git@github.com:ioet/infra-terraform-modules.git//azure-app-service?ref=tags/v0.0.13" - app_service_name = local.service_name + source = "git@github.com:ioet/infra-terraform-modules.git//azure-app-service?ref=tags/v0.0.20" + app_service_name = "${local.service_name}-ui" create_app_service_plan = local.create_app_service_plan docker_image_name = "${local.image_name}:${var.image_tag}" docker_image_namespace = data.terraform_remote_state.service.outputs.container_registry_login_server diff --git a/package-lock.json b/package-lock.json index 2387dc8d1..b764360fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "time-tracker", - "version": "1.75.0", + "version": "1.75.24", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -6409,7 +6409,7 @@ "normalize-path": "^3.0.0", "p-limit": "^3.0.1", "schema-utils": "^2.7.0", - "serialize-javascript": "^4.0.0", + "serialize-javascript": "^3.1.0", "webpack-sources": "^1.4.3" }, "dependencies": { @@ -11954,9 +11954,9 @@ "dev": true }, "moment": { - "version": "2.25.3", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.25.3.tgz", - "integrity": "sha512-PuYv0PHxZvzc15Sp8ybUCoQ+xpyPWvjOuK72a5ovzp2LI32rJXOiIfyoFoYvG3s6EwwrdkMyWuRiEHSZRLJNdg==" + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" }, "move-concurrently": { "version": "1.0.1", @@ -23568,7 +23568,7 @@ "jest-worker": "^26.3.0", "p-limit": "^3.0.2", "schema-utils": "^2.6.6", - "serialize-javascript": "^4.0.0", + "serialize-javascript": "^3.1.0", "source-map": "^0.6.1", "terser": "^5.0.0", "webpack-sources": "^1.4.3" @@ -24869,7 +24869,7 @@ "find-cache-dir": "^2.1.0", "is-wsl": "^1.1.0", "schema-utils": "^1.0.0", - "serialize-javascript": "^4.0.0", + "serialize-javascript": "^3.1.0", "source-map": "^0.6.1", "terser": "^4.1.2", "webpack-sources": "^1.4.0", diff --git a/package.json b/package.json index 02494b8b3..8888cd30a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "time-tracker", - "version": "1.75.0", + "version": "1.75.24", "scripts": { "preinstall": "npx npm-force-resolutions", "ng": "ng", @@ -50,7 +50,7 @@ "jquery": "3.5.1", "jszip": "3.7.0", "minimist": "1.2.5", - "moment": "2.25.3", + "moment": "2.29.4", "msal": "1.2.1", "ngrx-store-localstorage": "11.0.0", "ngx-cookie-service": "11.0.2", @@ -111,13 +111,13 @@ "husky": { "hooks": { "commit-msg": "commit-message-validator", - "pre-commit": "ng lint && ng test --watch=false --browsers=ChromeHeadless" + "pre-commit": "ng lint" } }, "config": { "commit-message-validator": { - "pattern": "^(fix: TT-|feat: TT-|perf: TT-|build: TT-|ci: TT-|docs: TT-|refactor: TT-|style: TT-|test: TT-|code-smell: TT-)[0-9].*", - "errorMessage": "Your commit message needs to start with fix: , feat:, or perf: followed by any commit message, e.g. fix: TT-43 any commit message." + "pattern": "^(fix: TTA-|feat: TTA-|perf: TTA-|build: TTA-|ci: TTA-|docs: TTA-|refactor: TTA-|style: TTA-|test: TTA-|code-smell: TTA-)[0-9].*", + "errorMessage": "\nYour commit message must comply with the following pattern:\n ^(fix: TTA-|feat: TTA-|perf: TTA-|build: TTA-|ci: TTA-|docs: TTA-|refactor: TTA-|style: TTA-|test: TTA-|code-smell: TTA-)[0-9].*\n followed by any commit message.\n\n Example:\n fix: TTA-43 any commit message\n" } }, "resolutions": { diff --git a/src/app/app.component.html b/src/app/app.component.html index 0680b43f9..90c6b6463 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d0c44bfbf..a7115745c 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -3,7 +3,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { ToastrModule } from 'ngx-toastr'; import { CommonModule, DatePipe } from '@angular/common'; import { BrowserModule } from '@angular/platform-browser'; -import { NgModule, Component } from '@angular/core'; +import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { DataTablesModule } from 'angular-datatables'; @@ -14,6 +14,8 @@ import { DragDropModule } from '@angular/cdk/drag-drop'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatIconModule } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { MatMomentDateModule } from '@angular/material-moment-adapter'; @@ -49,7 +51,7 @@ import { ProjectEffects } from './modules/customer-management/components/project import { TechnologyEffects } from './modules/shared/store/technology.effects'; import { ProjectTypeEffects } from './modules/customer-management/components/projects-type/store/project-type.effects'; import { reducers } from './reducers'; -import { CLIENT_URL, environment } from '../environments/environment'; +import { environment } from '../environments/environment'; import { EnvironmentType } from '../environments/enum'; import { CustomerComponent } from './modules/customer-management/pages/customer.component'; // tslint:disable-next-line: max-line-length @@ -91,12 +93,12 @@ import { CalendarComponent } from './modules/time-entries/components/calendar/ca import { DropdownComponent } from './modules/shared/components/dropdown/dropdown.component'; import { NgSelectModule } from '@ng-select/ng-select'; import { DarkModeComponent } from './modules/shared/components/dark-mode/dark-mode.component'; -import { SocialLoginModule, SocialAuthServiceConfig } from 'angularx-social-login'; -import { GoogleLoginProvider } from 'angularx-social-login'; import { SearchUserComponent } from './modules/shared/components/search-user/search-user.component'; import { TimeRangeCustomComponent } from './modules/reports/components/time-range-custom/time-range-custom.component'; import { TimeRangeHeaderComponent } from './modules/reports/components/time-range-custom/time-range-header/time-range-header.component'; import { TimeRangeOptionsComponent } from './modules/reports/components/time-range-custom/time-range-options/time-range-options.component'; +import { SpinnerOverlayComponent } from './modules/shared/components/spinner-overlay/spinner-overlay.component'; +import { SpinnerInterceptor } from './modules/shared/interceptors/spinner.interceptor'; const maskConfig: Partial = { validation: false, @@ -156,6 +158,7 @@ const maskConfig: Partial = { TimeRangeCustomComponent, TimeRangeHeaderComponent, TimeRangeOptionsComponent, + SpinnerOverlayComponent, ], imports: [ NgxMaskModule.forRoot(maskConfig), @@ -174,6 +177,7 @@ const maskConfig: Partial = { DataTablesModule, AutocompleteLibModule, NgxMaterialTimepickerModule, + MatProgressSpinnerModule, UiSwitchModule, DragDropModule, MatIconModule, @@ -201,7 +205,6 @@ const maskConfig: Partial = { useFactory: adapterFactory, }), NgSelectModule, - SocialLoginModule ], providers: [ { @@ -209,20 +212,14 @@ const maskConfig: Partial = { useClass: InjectTokenInterceptor, multi: true, }, + { + provide: HTTP_INTERCEPTORS, + useClass: SpinnerInterceptor, + multi: true, + }, DatePipe, CookieService, - { - provide: 'SocialAuthServiceConfig', - useValue: { - autoLogin: false, - providers: [ - { - id: GoogleLoginProvider.PROVIDER_ID, - provider: new GoogleLoginProvider(CLIENT_URL) - } - ] - } as SocialAuthServiceConfig, - } + {provide: Window, useValue: window} ], bootstrap: [AppComponent], }) diff --git a/src/app/modules/login/login.component.html b/src/app/modules/login/login.component.html index 745fce8a8..9ad2d90d3 100644 --- a/src/app/modules/login/login.component.html +++ b/src/app/modules/login/login.component.html @@ -7,8 +7,19 @@

Please log in

- diff --git a/src/app/modules/login/login.component.spec.ts b/src/app/modules/login/login.component.spec.ts index bb44058d9..9de359249 100644 --- a/src/app/modules/login/login.component.spec.ts +++ b/src/app/modules/login/login.component.spec.ts @@ -8,12 +8,15 @@ import { FeatureToggleCookiesService } from '../shared/feature-toggles/feature-t import { LoginService } from './services/login.service'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { SocialAuthService } from 'angularx-social-login'; +import { UserService } from '../user/services/user.service'; + describe('LoginComponent', () => { let component: LoginComponent; let fixture: ComponentFixture; let azureAdB2CService: AzureAdB2CService; let loginService: LoginService; + let userService: UserService; let featureToggleCookiesService: FeatureToggleCookiesService; const azureAdB2CServiceStub = { @@ -38,6 +41,16 @@ describe('LoginComponent', () => { } }; + const userTest = { + name: 'user', + email: 'test@test.com', + roles: ['admin'], + groups: ['admin'], + id: 'user_id', + tenant_id: 'tenant_test', + deleted: 'no', + }; + const featureToggleCookiesServiceStub = { setCookies() { return null; @@ -66,6 +79,7 @@ describe('LoginComponent', () => { fixture.detectChanges(); azureAdB2CService = TestBed.inject(AzureAdB2CService); loginService = TestBed.inject(LoginService); + userService = TestBed.inject(UserService); featureToggleCookiesService = TestBed.inject(FeatureToggleCookiesService); }); @@ -89,29 +103,17 @@ describe('LoginComponent', () => { spyOn(azureAdB2CService, 'isLogin').and.returnValue(false); spyOn(azureAdB2CService, 'setCookies').and.returnValue(); spyOn(azureAdB2CService, 'signIn').and.returnValue(of(() => {})); + spyOn(azureAdB2CService, 'getUserId').and.returnValue('userId_123'); + spyOn(userService, 'loadUser').withArgs('userId_123').and.returnValue(of(userTest)); spyOn(featureToggleCookiesService, 'setCookies').and.returnValue(featureToggleCookiesService.setCookies()); component.login(); expect(azureAdB2CService.signIn).toHaveBeenCalled(); expect(azureAdB2CService.setCookies).toHaveBeenCalled(); + expect(azureAdB2CService.getUserId).toHaveBeenCalled(); expect(featureToggleCookiesService.setCookies).toHaveBeenCalled(); - })); - - it('should sign up or login with google if is not logged-in into the app Locally', inject([Router], (router: Router) => { - spyOn(loginService, 'isLogin').and.returnValue(of(false)); - spyOn(loginService, 'setLocalStorage').and.returnValue(); - spyOn(loginService, 'getUser').and.returnValue(of(() => {})); - spyOn(loginService, 'setCookies').and.returnValue(); - spyOn(loginService, 'signIn').and.returnValue(); - spyOn(featureToggleCookiesService, 'setCookies').and.returnValue(featureToggleCookiesService.setCookies()); - - component.ngOnInit(); - component.loginWithGoogle(); - - expect(loginService.signIn).toHaveBeenCalled(); - expect(loginService.setCookies).toHaveBeenCalled(); - expect(featureToggleCookiesService.setCookies).toHaveBeenCalled(); + expect(userService.loadUser).toHaveBeenCalledWith('userId_123'); })); it('should not sign-up or login with google if is already logged-in into the app on Production', inject([Router], (router: Router) => { @@ -122,11 +124,4 @@ describe('LoginComponent', () => { expect(router.navigate).toHaveBeenCalledWith(['']); })); - it('should not sign-up or login with google if is already logged-in into the app Locally', inject([Router], (router: Router) => { - spyOn(loginService, 'isLogin').and.returnValue(of(true)); - spyOn(router, 'navigate').and.stub(); - component.loginWithGoogle(); - expect(loginService.isLogin).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['']); - })); }); diff --git a/src/app/modules/login/login.component.ts b/src/app/modules/login/login.component.ts index a2945a080..792fc56e9 100644 --- a/src/app/modules/login/login.component.ts +++ b/src/app/modules/login/login.component.ts @@ -1,12 +1,18 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, NgZone } from '@angular/core'; import { AzureAdB2CService } from './services/azure.ad.b2c.service'; import { Router } from '@angular/router'; import { FeatureToggleCookiesService } from '../shared/feature-toggles/feature-toggle-cookies/feature-toggle-cookies.service'; -import { SocialAuthService, SocialUser } from 'angularx-social-login'; -import { environment } from 'src/environments/environment'; +import { environment, CLIENT_URL } from 'src/environments/environment'; import { EnvironmentType } from 'src/environments/enum'; import { LoginService } from './services/login.service'; +import { UserService } from '../user/services/user.service'; + +declare global { + interface Window { + handleCredentialResponse: (response: any) => void; + } +} @Component({ selector: 'app-login', @@ -14,31 +20,71 @@ import { LoginService } from './services/login.service'; styleUrls: ['./login.component.scss'], }) export class LoginComponent implements OnInit { - socialUser: SocialUser; isProduction = environment.production === EnvironmentType.TT_PROD_LEGACY; + cliendId = CLIENT_URL; + auth2: any; + constructor( private azureAdB2CService: AzureAdB2CService, private router: Router, private featureToggleCookiesService: FeatureToggleCookiesService, - private socialAuthService: SocialAuthService, - private loginService?: LoginService + private loginService?: LoginService, + private userService?: UserService, + private ngZone?: NgZone ) {} + + googleAuthSDK() { + const sdkLoaded = 'googleSDKLoaded'; + const gapi = 'gapi'; + + (window as any)[sdkLoaded] = () => { + (window as any)[gapi].load('auth2', () => { + this.auth2 = ( window as any)[gapi].auth2.init({ + client_id: this.cliendId, + plugin_name: 'login', + cookiepolicy: 'single_host_origin', + scope: 'profile email' + }); + }); + }; + + (async (d, s, id) => { + const keyGoogle = 'src'; + const gjs = d.getElementsByTagName(s)[1]; + let js = gjs; + if (d.getElementById(id)) { return; } + js = d.createElement(s); js.id = id; + js[keyGoogle] = 'https://accounts.google.com/gsi/client'; + gjs.parentNode.insertBefore(js, gjs); + })(document, 'script', 'async defer'); + } + ngOnInit() { - this.socialAuthService.authState.subscribe((user) => { - if (user != null) { - this.featureToggleCookiesService.setCookies(); - this.loginService.setLocalStorage('idToken', user.idToken); - this.loginService.getUser(user.idToken).subscribe((response) => { + + this.googleAuthSDK(); + if (this.isProduction && this.azureAdB2CService.isLogin()) { + this.router.navigate(['']); + } else { + this.loginService.isLogin().subscribe(isLogin => { + if (isLogin) { + this.router.navigate(['']); + } + }); + } + window.handleCredentialResponse = (response) => { + const {credential = ''} = response; + this.featureToggleCookiesService.setCookies(); + this.loginService.setLocalStorage('idToken', credential); + this.loginService.getUser(credential).subscribe((resp) => { this.loginService.setCookies(); - const tokenObject = JSON.stringify(response); + const tokenObject = JSON.stringify(resp); const tokenJson = JSON.parse(tokenObject); this.loginService.setLocalStorage('user', tokenJson.token); - this.router.navigate(['']); - }); - } - }); + this.ngZone.run(() => this.router.navigate([''])); + }); + }; } login(): void { @@ -48,18 +94,16 @@ export class LoginComponent implements OnInit { this.azureAdB2CService.signIn().subscribe(() => { this.featureToggleCookiesService.setCookies(); this.azureAdB2CService.setCookies(); - this.router.navigate(['']); + const userId = this.azureAdB2CService.getUserId(); + this.userService.loadUser(userId).subscribe((user) => { + const userGroups = { + groups: user.groups + }; + this.loginService.setLocalStorage('user', JSON.stringify(userGroups)); + this.router.navigate(['']); + }); }); } } - loginWithGoogle() { - this.loginService.isLogin().subscribe(isLogin => { - if (isLogin) { - this.router.navigate(['']); - } else { - this.loginService.signIn(); - } - }); - } } diff --git a/src/app/modules/login/services/login.service.spec.ts b/src/app/modules/login/services/login.service.spec.ts index fa558e35e..94e246cc6 100644 --- a/src/app/modules/login/services/login.service.spec.ts +++ b/src/app/modules/login/services/login.service.spec.ts @@ -1,4 +1,5 @@ -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { JwtHelperService } from '@auth0/angular-jwt'; import { SocialAuthService } from 'angularx-social-login'; @@ -9,11 +10,11 @@ import { LoginService } from './login.service'; describe('LoginService', () => { let service: LoginService; - let httpMock: HttpTestingController; let cookieService: CookieService; let socialAuthService: SocialAuthService; let account; const socialAuthServiceStub = jasmine.createSpyObj('SocialAuthService', ['signOut', 'signIn']); + const httpClientSpy = jasmine.createSpyObj('HttpClient', ['post', 'get']); const cookieStoreStub = {}; const helper = new JwtHelperService(); const getAccountInfo = () => { @@ -26,11 +27,11 @@ describe('LoginService', () => { providers: [ { providers: CookieService, useValue: cookieStoreStub }, { provide: SocialAuthService, useValue: socialAuthServiceStub }, + { provide: HttpClient, useValue: httpClientSpy } ], }); service = TestBed.inject(LoginService); cookieService = TestBed.inject(CookieService); - httpMock = TestBed.inject(HttpTestingController); socialAuthService = TestBed.inject(SocialAuthService); account = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImFiYyIsIm5hbWUiOiJhYmMiLCJlbWFpbCI6ImFiYyIsImdyb3VwcyI6WyJhYmMiXX0.UNxyDT8XzXJhI1F3LySBU7TJlpENPUPHj8my7Obw2ZM'; let store = {}; @@ -49,6 +50,7 @@ describe('LoginService', () => { spyOn(localStorage, 'setItem').and.callFake(mockLocalStorage.setItem); spyOn(localStorage, 'clear').and.callFake(mockLocalStorage.clear); localStorage.setItem('user', account); + localStorage.setItem('user2', '"test_token_123"'); }); it('should be created', () => { @@ -90,12 +92,16 @@ describe('LoginService', () => { }); it('load a user by sending a token using POST', () => { + const token = 'test_123'; service.baseUrl = '/users'; - service.getUser('token').subscribe(); - - const loadUserRequest = httpMock.expectOne(`${service.baseUrl}/login`); - expect(loadUserRequest.request.method).toBe('POST'); - }); + const mockSuccessDataPost = { + SUCCESS: true, + data: {} + }; + httpClientSpy.post.and.returnValue(of(mockSuccessDataPost)); + service.getUser(token).subscribe(); + expect(httpClientSpy.post).toHaveBeenCalled(); + }); it('should return true when user is Login', () => { spyOn(cookieService, 'check').and.returnValue(true); @@ -114,18 +120,48 @@ describe('LoginService', () => { }); }); - it('should login with social angularx-social-login', () => { - service.signIn(); - expect(socialAuthService.signIn).toHaveBeenCalled(); - }); - it('should logout with social angularx-social-login', () => { spyOn(cookieService, 'deleteAll').and.returnValue(); service.logout(); - expect(socialAuthService.signOut).toHaveBeenCalled(); expect(localStorage.clear).toHaveBeenCalled(); expect(cookieService.deleteAll).toHaveBeenCalled(); }); + + it('should call cookieService when app is isLegacyProd', () => { + service.isLegacyProd = true; + service.localStorageKey = 'user2'; + spyOn(cookieService, 'check').and.returnValue(true); + spyOn(service, 'isValidToken').and.returnValue(of(true)); + service.isLogin().subscribe(isLogin => { + expect(cookieService.check).toHaveBeenCalled(); + }); + }); + + it('should call JSON parse when app is isLegacyProd', () => { + spyOn(JSON, 'parse').and.returnValue('test_user_123'); + service.isLegacyProd = true; + service.localStorageKey = 'user2'; + service.getUserId(); + service.getName(); + service.getUserEmail(); + service.getUserGroup(); + expect(JSON.parse).toHaveBeenCalled(); + }); + + it('should call setLocalStorage when there is a new_token ', () => { + spyOn(cookieService, 'check').and.returnValue(true); + spyOn(service, 'setLocalStorage'); + const token = 'test123'; + service.baseUrl = '/users'; + const mockSuccessDataPost = { + SUCCESS: true, + new_token: 'test_token' + }; + httpClientSpy.post.and.returnValue(of(mockSuccessDataPost)); + service.isValidToken(token).subscribe(); + expect(service.setLocalStorage).toHaveBeenCalled(); + expect(cookieService.check).toHaveBeenCalled(); + }); }); diff --git a/src/app/modules/login/services/login.service.ts b/src/app/modules/login/services/login.service.ts index 212aa4b88..8a0869829 100644 --- a/src/app/modules/login/services/login.service.ts +++ b/src/app/modules/login/services/login.service.ts @@ -1,6 +1,5 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { GoogleLoginProvider, SocialAuthService } from 'angularx-social-login'; import { CookieService } from 'ngx-cookie-service'; import { EnvironmentType, UserEnum } from 'src/environments/enum'; import { environment } from 'src/environments/environment'; @@ -20,18 +19,12 @@ export class LoginService { constructor( private http?: HttpClient, private cookieService?: CookieService, - private socialAuthService?: SocialAuthService ) { this.baseUrl = `${environment.timeTrackerApiUrl}/users`; this.helper = new JwtHelperService(); } - signIn() { - this.socialAuthService.signIn(GoogleLoginProvider.PROVIDER_ID); - } - logout() { - this.socialAuthService.signOut(); this.cookieService.deleteAll(); localStorage.clear(); } diff --git a/src/app/modules/reports/components/time-entries-table/time-entries-table.component.spec.ts b/src/app/modules/reports/components/time-entries-table/time-entries-table.component.spec.ts index 6e84b0fc8..30c2d7882 100644 --- a/src/app/modules/reports/components/time-entries-table/time-entries-table.component.spec.ts +++ b/src/app/modules/reports/components/time-entries-table/time-entries-table.component.spec.ts @@ -17,10 +17,8 @@ describe('Reports Page', () => { let fixture: ComponentFixture; let store: MockStore; let getReportDataSourceSelectorMock; - let durationTime: number; let row: number; let node: number; - let decimalValidator: RegExp; const timeEntry: Entry = { id: '123', start_date: new Date(), @@ -102,10 +100,8 @@ describe('Reports Page', () => { ); beforeEach(() => { - durationTime = new Date().setHours(5, 30); row = 0; node = 0; - decimalValidator = /^\d+\.\d{0,2}$/; }); it('component should be created', async () => { @@ -155,16 +151,6 @@ describe('Reports Page', () => { }); }); - it('The data should be displayed as a multiple of hour when column is equal to 4', () => { - const column = 4; - expect(component.bodyExportOptions(durationTime, row, column, node)).toMatch(decimalValidator); - }); - - it('The data should not be displayed as a multiple of hour when column is different of 4', () => { - const column = 5; - expect(component.bodyExportOptions(durationTime, row, column, node)).toBe(durationTime.toString()); - }); - it('The link Ticket must not contain the ticket URL enclosed with < > when export a file csv, excel or PDF', () => { const entry = 'https://TT-392-uri'; const column = 0; @@ -212,10 +198,10 @@ describe('Reports Page', () => { it('the sume of hours of entries selected is equal to {hours:0, minutes:0, seconds:0}', () => { let checked = true; - let {hours, minutes, seconds}:TotalHours = component.sumHoursEntriesSelected(timeEntryList[0], checked); + let {hours, minutes, seconds}: TotalHours = component.sumHoursEntriesSelected(timeEntryList[0], checked); checked = false; - ({hours, minutes,seconds} = component.sumHoursEntriesSelected(timeEntryList[0], checked)); - expect({hours, minutes, seconds}).toEqual({hours:0, minutes:0, seconds:0}); + ({hours, minutes, seconds} = component.sumHoursEntriesSelected(timeEntryList[0], checked)); + expect({hours, minutes, seconds}).toEqual({hours: 0, minutes: 0, seconds: 0}); }); it('should export data with the correct format', () => { @@ -247,7 +233,7 @@ describe('Reports Page', () => { '19', 'user@ioet.com', '07/01/2022', - '9.00', + '09:00', '09:00', '18:00', 'Project_Name', diff --git a/src/app/modules/reports/components/time-entries-table/time-entries-table.component.ts b/src/app/modules/reports/components/time-entries-table/time-entries-table.component.ts index 7709e94a7..502d6a7cc 100644 --- a/src/app/modules/reports/components/time-entries-table/time-entries-table.component.ts +++ b/src/app/modules/reports/components/time-entries-table/time-entries-table.component.ts @@ -69,7 +69,7 @@ export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewIn }, ], columnDefs: [{ type: 'date', targets: 2}, {orderable: false, targets: [0]}], - order: [[1,'asc'],[2,'desc'],[4,'desc']] + order: [[1, 'asc'], [2, 'desc'], [4, 'desc']] }; dtTrigger: Subject = new Subject(); @ViewChild(DataTableDirective, { static: false }) @@ -79,7 +79,7 @@ export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewIn rerenderTableSubscription: Subscription; resultSum: TotalHours; resultSumEntriesSelected: TotalHours; - resultSumEntriesSelected$:Observable; + resultSumEntriesSelected$: Observable; totalHoursSubscription: Subscription; dateTimeOffset: ParseDateTimeOffset; @@ -96,14 +96,16 @@ export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewIn this.actionsSubject$ .pipe(filter((action: any) => action.type === UserActionTypes.LOAD_USERS_SUCCESS)) .subscribe((action) => { - this.users = action.payload; + const sortUsers = [...action.payload]; + sortUsers.sort((a, b) => a.name.localeCompare(b.name)); + this.users = sortUsers; }); } ngOnInit(): void { this.rerenderTableSubscription = this.reportDataSource$.subscribe((ds) => { this.totalHoursSubscription = this.resultSumEntriesSelected$.subscribe((actTotalHours) => { - this.resultSumEntriesSelected = actTotalHours ; + this.resultSumEntriesSelected = actTotalHours; this.totalTimeSelected = moment.duration(0); }); this.sumDates(ds.data); @@ -142,9 +144,7 @@ export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewIn } bodyExportOptions(data, row, column, node) { - const dataFormated = data.toString().replace(/<((.|\n){0,200}?)>/gi, ''); - const durationColumnIndex = 4; - return column === durationColumnIndex ? moment.duration(dataFormated).asHours().toFixed(2) : dataFormated; + return data.toString().replace(/<((.|\n){0,200}?)>/gi, '') || ''; } diff --git a/src/app/modules/reports/components/time-range-custom/time-range-custom.component.html b/src/app/modules/reports/components/time-range-custom/time-range-custom.component.html index b680520de..a7f5bd2fe 100644 --- a/src/app/modules/reports/components/time-range-custom/time-range-custom.component.html +++ b/src/app/modules/reports/components/time-range-custom/time-range-custom.component.html @@ -2,9 +2,9 @@ Enter a date range - - - + + + MM/DD/YYYY – MM/DD/YYYY diff --git a/src/app/modules/reports/components/time-range-custom/time-range-custom.component.spec.ts b/src/app/modules/reports/components/time-range-custom/time-range-custom.component.spec.ts index 3fad6305f..fa2e8e9d5 100644 --- a/src/app/modules/reports/components/time-range-custom/time-range-custom.component.spec.ts +++ b/src/app/modules/reports/components/time-range-custom/time-range-custom.component.spec.ts @@ -136,4 +136,15 @@ describe('TimeRangeCustomComponent', () => { expect(component.onSubmit).not.toHaveBeenCalled(); }); + + it('should call range form and delete variable local storage ', () => { + spyOn(localStorage, 'removeItem').withArgs('rangeDatePicker'); + component.range.setValue({start: null, end: null}); + jasmine.clock().install(); + component.dateRangeChange(); + jasmine.clock().tick(200); + expect(localStorage.removeItem).toHaveBeenCalledWith('rangeDatePicker'); + jasmine.clock().uninstall(); + }); + }); diff --git a/src/app/modules/reports/components/time-range-custom/time-range-custom.component.ts b/src/app/modules/reports/components/time-range-custom/time-range-custom.component.ts index 8bb76cdb8..dded53994 100644 --- a/src/app/modules/reports/components/time-range-custom/time-range-custom.component.ts +++ b/src/app/modules/reports/components/time-range-custom/time-range-custom.component.ts @@ -49,6 +49,7 @@ export class TimeRangeCustomComponent implements OnInit, OnChanges { start: formatDate(moment().startOf('isoWeek').format('l'), DATE_FORMAT, 'en'), end: formatDate(moment().format('l'), DATE_FORMAT, 'en') }); + localStorage.setItem('rangeDatePicker', 'custom'); this.onSubmit(); } @@ -65,4 +66,11 @@ export class TimeRangeCustomComponent implements OnInit, OnChanges { } } + dateRangeChange() { + setTimeout(() => { + if (this.range.get('start').value === null || this.range.get('end').value === null) { + localStorage.removeItem('rangeDatePicker'); + } + }, 200); + } } diff --git a/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.html b/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.html index b3082762c..6cce60c1e 100644 --- a/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.html +++ b/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.html @@ -1,12 +1,7 @@
- - - custom - - - - - {{item}} - - + + + {{item}} + +
diff --git a/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.spec.ts b/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.spec.ts index 33a442cf2..b05ee5590 100644 --- a/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.spec.ts +++ b/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.spec.ts @@ -40,11 +40,6 @@ describe('TimeRangeOptionsComponent', () => { expect(component).toBeTruthy(); }); - it('should call resetTimeRange method and clean time range input ', () => { - component.resetTimeRange(); - expect(component.picker.startAt).toEqual(undefined); - }); - it('should click selectRange button and call calculateDateRange method', () => { spyOn(component, 'calculateDateRange').and.returnValues(['', '']); component.selectRange('today'); @@ -65,16 +60,18 @@ describe('TimeRangeOptionsComponent', () => { expect(new Date(end).toDateString()).toEqual(new Date().toDateString()); }); - it('should call calculateMonth and calculateWeek method when is called calculateDateRange method', () => { + it('should call getMondayCurrent, calculateMonth and calculateWeek method when is called calculateDateRange method', () => { const dataAll = [ - {method: 'calculateWeek', options: ['this week', 'last week']}, - {method: 'calculateMonth', options: ['this month', 'last month']}]; + {method: 'getMondayCurrent', ranges: ['custom']}, + {method: 'calculateWeek', ranges: ['this week', 'last week']}, + {method: 'calculateMonth', ranges: ['this month', 'last month']} + ]; dataAll.forEach((val: any) => { spyOn(component, val.method); - val.options.forEach((option: any) => { - component.calculateDateRange(option); + val.ranges.forEach((range: any) => { + component.calculateDateRange(range); expect(component[val.method]).toHaveBeenCalled(); }); }); @@ -130,4 +127,12 @@ describe('TimeRangeOptionsComponent', () => { expect(toastrServiceStub.error).toHaveBeenCalled(); }); + it('should call to method an error when the date created is null from date adapter', () => { + spyOn(component.dateAdapter, 'getYear').and.returnValues(2022); + spyOn(component.dateAdapter, 'getMonth').and.returnValues(7); + component.getMondayCurrent(); + expect(component.dateAdapter.getYear).toHaveBeenCalled(); + expect(component.dateAdapter.getMonth).toHaveBeenCalled(); + }); + }); diff --git a/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.ts b/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.ts index 01dfb39a7..3103b8507 100644 --- a/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.ts +++ b/src/app/modules/reports/components/time-range-custom/time-range-options/time-range-options.component.ts @@ -1,10 +1,11 @@ -import { Component, HostBinding, ChangeDetectionStrategy } from '@angular/core'; +import { Component, HostBinding, ChangeDetectionStrategy, OnInit } from '@angular/core'; import { DateAdapter } from '@angular/material/core'; import { MatDateRangePicker } from '@angular/material/datepicker'; import { ToastrService } from 'ngx-toastr'; const customPresets = [ + 'custom', 'today', 'last 7 days', 'this week', @@ -22,9 +23,10 @@ type CustomPreset = typeof customPresets[number]; styleUrls: ['./time-range-options.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TimeRangeOptionsComponent { +export class TimeRangeOptionsComponent implements OnInit{ customPresets = customPresets; + rangeDateSelected = ''; @HostBinding('class.touch-ui') readonly isTouchUi = this.picker.touchUi; constructor( @@ -35,8 +37,22 @@ export class TimeRangeOptionsComponent { this.dateAdapter.getFirstDayOfWeek = () => 1; } + ngOnInit() { + this.rangeDateSelected = this.getLocalStorageRange(); + } + + getLocalStorageRange(): string { + return localStorage.getItem('rangeDatePicker'); + } + + setLocalStorageRange(range: string): void { + localStorage.setItem('rangeDatePicker', range); + } + selectRange(rangeName: CustomPreset): void { const [start, end] = this.calculateDateRange(rangeName); + this.setLocalStorageRange(rangeName); + this.picker.select(start); this.picker.select(end); this.picker.close(); @@ -47,6 +63,9 @@ export class TimeRangeOptionsComponent { const year = this.dateAdapter.getYear(today); switch (rangeName) { + case 'custom': + const mondayWeek = this.getMondayCurrent(); + return [mondayWeek, today]; case 'today': return [today, today]; case 'last 7 days': { @@ -108,9 +127,14 @@ export class TimeRangeOptionsComponent { return today; } - resetTimeRange() { - this.picker.select(undefined); - this.picker.select(undefined); + getMondayCurrent(): Date { + const yearCurrent = this.dateAdapter.getYear(this.dateAdapter.today()); + const monthCurrent = this.dateAdapter.getMonth(this.dateAdapter.today()); + const today = new Date(); + const first = today.getDate() - today.getDay() + 1; + const monday = new Date(today.setDate(first)); + const mondayDayCurrent = monday.getDate(); + return this.dateAdapter.createDate(yearCurrent, monthCurrent, mondayDayCurrent); } } diff --git a/src/app/modules/shared/components/details-fields/details-fields.component.html b/src/app/modules/shared/components/details-fields/details-fields.component.html index 6954cc808..d0c4ea2dc 100644 --- a/src/app/modules/shared/components/details-fields/details-fields.component.html +++ b/src/app/modules/shared/components/details-fields/details-fields.component.html @@ -64,7 +64,7 @@ formControlName="uri" id="uri" type="text" - placeholder="Enter Jira ticket URL" + placeholder="Enter your ticket number" class="url-ticket-input form-control" aria-label="Small" aria-describedby="inputGroup-sizing-sm" diff --git a/src/app/modules/shared/components/details-fields/details-fields.component.spec.ts b/src/app/modules/shared/components/details-fields/details-fields.component.spec.ts index 4f6686951..320423770 100644 --- a/src/app/modules/shared/components/details-fields/details-fields.component.spec.ts +++ b/src/app/modules/shared/components/details-fields/details-fields.component.spec.ts @@ -208,7 +208,8 @@ describe('DetailsFieldsComponent', () => { it('on cleanFieldsForm the project_id and project_name should be kept', () => { const entryFormValueExpected = { - ...formValues, + project_id: '', + project_name: '', activity_id: '', uri: '', start_date: formatDate(new Date(), DATE_FORMAT, 'en'), diff --git a/src/app/modules/shared/components/details-fields/details-fields.component.ts b/src/app/modules/shared/components/details-fields/details-fields.component.ts index 580f0794d..84b07a576 100644 --- a/src/app/modules/shared/components/details-fields/details-fields.component.ts +++ b/src/app/modules/shared/components/details-fields/details-fields.component.ts @@ -235,7 +235,7 @@ export class DetailsFieldsComponent implements OnChanges, OnInit { } cleanFieldsForm(): void { - this.cleanForm(true); + this.cleanForm(false); } selectActiveActivities() { diff --git a/src/app/modules/shared/components/spinner-overlay/spinner-overlay.component.scss b/src/app/modules/shared/components/spinner-overlay/spinner-overlay.component.scss new file mode 100644 index 000000000..af4311aba --- /dev/null +++ b/src/app/modules/shared/components/spinner-overlay/spinner-overlay.component.scss @@ -0,0 +1,5 @@ +@use "../../../../../styles/colors.scss" as colors; + +:host ::ng-deep .mat-progress-spinner circle, .mat-spinner circle { + stroke: colors.$warning; +} diff --git a/src/app/modules/shared/components/spinner-overlay/spinner-overlay.component.spec.ts b/src/app/modules/shared/components/spinner-overlay/spinner-overlay.component.spec.ts new file mode 100644 index 000000000..4fe84d202 --- /dev/null +++ b/src/app/modules/shared/components/spinner-overlay/spinner-overlay.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SpinnerOverlayComponent } from './spinner-overlay.component'; + +describe('SpinnerOverlayComponent', () => { + let component: SpinnerOverlayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ SpinnerOverlayComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SpinnerOverlayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/modules/shared/components/spinner-overlay/spinner-overlay.component.ts b/src/app/modules/shared/components/spinner-overlay/spinner-overlay.component.ts new file mode 100644 index 000000000..00bca4517 --- /dev/null +++ b/src/app/modules/shared/components/spinner-overlay/spinner-overlay.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-spinner-overlay', + template: '', + styleUrls: ['./spinner-overlay.component.scss'], +}) + +export class SpinnerOverlayComponent { + constructor() {} +} diff --git a/src/app/modules/shared/interceptors/spinner.interceptor.spec.ts b/src/app/modules/shared/interceptors/spinner.interceptor.spec.ts new file mode 100644 index 000000000..aa7d28bb1 --- /dev/null +++ b/src/app/modules/shared/interceptors/spinner.interceptor.spec.ts @@ -0,0 +1,49 @@ + + +import { Overlay } from '@angular/cdk/overlay'; +import { TestBed } from '@angular/core/testing'; +import { SpinnerInterceptor } from './spinner.interceptor'; +import { HttpHandler, HttpRequest, HttpResponse, HttpEvent } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; +import { SpinnerOverlayService } from '../services/spinner-overlay.service'; + + +describe('SpinnerInterceptorService test', () => { + TestBed.configureTestingModule({ + providers: [ + SpinnerInterceptor, + Overlay + ], + }); + + class MockHttpHandler implements HttpHandler { + handle(req: HttpRequest): Observable> { + return of(new HttpResponse()); + } + } + + let overlay: Overlay; + let httpHandler: HttpHandler; + let spinnerInterceptor: SpinnerInterceptor; + + beforeEach(() => { + overlay = jasmine.createSpyObj('Overlay', ['create']); + httpHandler = new MockHttpHandler(); + spinnerInterceptor = new SpinnerInterceptor(new SpinnerOverlayService(overlay)); + }); + + it('should be created', () => { + expect(spinnerInterceptor).toBeTruthy(); + }); + + it('if request is made then spinnerInterceptor is called', () => { + const request = new HttpRequest('GET', '/recent'); + spyOn(spinnerInterceptor, 'intercept'); + + spinnerInterceptor.intercept(request, httpHandler); + + expect(spinnerInterceptor.intercept).toHaveBeenCalledWith(request, httpHandler); + + }); + +}); diff --git a/src/app/modules/shared/interceptors/spinner.interceptor.ts b/src/app/modules/shared/interceptors/spinner.interceptor.ts new file mode 100644 index 000000000..200628271 --- /dev/null +++ b/src/app/modules/shared/interceptors/spinner.interceptor.ts @@ -0,0 +1,30 @@ +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, +} from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, Subscription } from 'rxjs'; +import { finalize } from 'rxjs/operators'; +import { SpinnerOverlayService } from '../services/spinner-overlay.service'; + +@Injectable() +export class SpinnerInterceptor implements HttpInterceptor { + constructor(private readonly spinnerOverlayService: SpinnerOverlayService) {} + + intercept( + req: HttpRequest, + next: HttpHandler + ): Observable> { + if(req.url.endsWith('recent')){ + const spinnerSubscription: Subscription = this.spinnerOverlayService.spinner$.subscribe(); + return next + .handle(req) + .pipe(finalize(() => spinnerSubscription.unsubscribe())); + }else{ + return next.handle(req); + } + + } +} diff --git a/src/app/modules/shared/services/spinner-overlay.service.spec.ts b/src/app/modules/shared/services/spinner-overlay.service.spec.ts new file mode 100644 index 000000000..cde9934cc --- /dev/null +++ b/src/app/modules/shared/services/spinner-overlay.service.spec.ts @@ -0,0 +1,66 @@ +import { TestBed } from '@angular/core/testing'; +import { Overlay } from '@angular/cdk/overlay'; + +import { SpinnerOverlayService } from './spinner-overlay.service'; +import { SpinnerInterceptor } from '../interceptors/spinner.interceptor'; +import { HttpEvent, HttpHandler, HttpRequest, HttpResponse, HTTP_INTERCEPTORS } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; +import { ComponentPortal } from 'ngx-toastr'; + + +describe('SpinnerOverlayService test', () => { + + class MockHttpHandler extends HttpHandler { + handle(req: HttpRequest): Observable> { + return of(new HttpResponse()); + } + } + + let spinnerService: SpinnerOverlayService; + let spinnerInterceptor: SpinnerInterceptor; + let mockHttpHandler: HttpHandler; + let overlayRef: any; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + Overlay, + SpinnerInterceptor, + { + provide: HTTP_INTERCEPTORS, + useClass: SpinnerInterceptor, + multi: true, + }, + ], + }); + spinnerService = TestBed.inject(SpinnerOverlayService); + spinnerInterceptor = TestBed.inject(SpinnerInterceptor); + mockHttpHandler = new MockHttpHandler(); + overlayRef = spinnerService.overlayRef; + }); + + it('should be created', () => { + expect(spinnerService).toBeTruthy(); + }); + + it('if request is made then spinnerService is show', () => { + const request = new HttpRequest('GET', '/recent'); + spyOn(spinnerService, 'show'); + + spinnerInterceptor.intercept(request, mockHttpHandler); + + expect(spinnerService.show).toHaveBeenCalled(); + expect(ComponentPortal).toBeTruthy(); + }); + + + it('if hide calls detach method of overlayRef and then sets it to undefined', () => { + spyOn(spinnerService, 'hide'); + + spinnerService.hide(); + + expect(spinnerService.hide).toHaveBeenCalled(); + expect(overlayRef).toBeUndefined(); + }); + +}); diff --git a/src/app/modules/shared/services/spinner-overlay.service.ts b/src/app/modules/shared/services/spinner-overlay.service.ts new file mode 100644 index 000000000..1b74a6f27 --- /dev/null +++ b/src/app/modules/shared/services/spinner-overlay.service.ts @@ -0,0 +1,44 @@ +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { Injectable } from '@angular/core'; +import { defer, NEVER } from 'rxjs'; +import { finalize, share } from 'rxjs/operators'; +import { SpinnerOverlayComponent } from './../components/spinner-overlay/spinner-overlay.component'; + +@Injectable({ + providedIn: 'root', +}) +export class SpinnerOverlayService { + public overlayRef: OverlayRef = undefined; + static spinner$: any; + + constructor(private readonly overlay: Overlay) {} + + public readonly spinner$ = defer(() => { + this.show(); + return NEVER.pipe( + finalize(() => { + this.hide(); + }) + ); + }).pipe(share()); + + public show(): void { + Promise.resolve(null).then(() => { + this.overlayRef = this.overlay.create({ + positionStrategy: this.overlay + .position() + .global() + .centerHorizontally() + .centerVertically(), + hasBackdrop: true, + }); + this.overlayRef.attach(new ComponentPortal(SpinnerOverlayComponent)); + }); + } + + public hide(): void { + this.overlayRef.detach(); + this.overlayRef = undefined; + } +} diff --git a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.html b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.html index 1066c881f..ad3d545a7 100644 --- a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.html +++ b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.html @@ -13,11 +13,11 @@
- + { this.username = isLogin ? this.loginService.getName() : ''; - }) + }); } this.storeSubscription = this.store.pipe(select(getActiveTimeEntry)).subscribe((activeTimeEntry) => { this.activeTimeEntry = activeTimeEntry; diff --git a/src/app/modules/time-clock/services/entry.service.ts b/src/app/modules/time-clock/services/entry.service.ts index 125041202..e8b9a1b63 100644 --- a/src/app/modules/time-clock/services/entry.service.ts +++ b/src/app/modules/time-clock/services/entry.service.ts @@ -46,7 +46,8 @@ export class EntryService { } stopEntryRunning(idEntry: string): Observable { - return (this.urlInProductionLegacy ? this.http.post(`${this.baseUrl}/${idEntry}/stop`, null) : this.http.put(`${this.baseUrl}/stop`, null) ); + return (this.urlInProductionLegacy ? + this.http.post(`${this.baseUrl}/${idEntry}/stop`, null) : this.http.put(`${this.baseUrl}/stop`, null) ); } restartEntry(idEntry: string): Observable { diff --git a/src/app/modules/time-clock/store/entry.effects.ts b/src/app/modules/time-clock/store/entry.effects.ts index 40c1cb1c9..f9fc9d769 100644 --- a/src/app/modules/time-clock/store/entry.effects.ts +++ b/src/app/modules/time-clock/store/entry.effects.ts @@ -45,6 +45,9 @@ export class EntryEffects { mergeMap(() => this.entryService.summary().pipe( map((response) => { + if (!response){ + this.toastrService.warning('Your summary information could not be loaded'); + } return new actions.LoadEntriesSummarySuccess(response); }), catchError((error) => { diff --git a/src/app/modules/time-clock/store/entry.reducer.ts b/src/app/modules/time-clock/store/entry.reducer.ts index d3af715f8..023328efb 100644 --- a/src/app/modules/time-clock/store/entry.reducer.ts +++ b/src/app/modules/time-clock/store/entry.reducer.ts @@ -266,7 +266,7 @@ export const entryReducer = (state: EntryState = initialState, action: EntryActi return { ...state, isLoading: true, - resultSumEntriesSelected:{hours:0, minutes:0, seconds:0}, + resultSumEntriesSelected: {hours: 0, minutes: 0, seconds: 0}, reportDataSource: { data: [], isLoading: true diff --git a/src/app/modules/time-clock/store/entry.selectors.spec.ts b/src/app/modules/time-clock/store/entry.selectors.spec.ts index 06c3c02e2..1d5c088c0 100644 --- a/src/app/modules/time-clock/store/entry.selectors.spec.ts +++ b/src/app/modules/time-clock/store/entry.selectors.spec.ts @@ -55,7 +55,7 @@ describe('Entry selectors', () => { }); it('should select resultSumEntriesSelected', () => { - const resultSumEntriesSelected:TotalHours = { hours:0, minutes:0, seconds:0 }; + const resultSumEntriesSelected: TotalHours = { hours: 0, minutes: 0, seconds: 0 }; const entryState = { resultSumEntriesSelected }; expect(selectors.getResultSumEntriesSelected.projector(entryState)).toEqual(resultSumEntriesSelected); diff --git a/src/app/modules/time-entries/pages/time-entries.component.html b/src/app/modules/time-entries/pages/time-entries.component.html index 87f2da3c6..ce4b96f74 100644 --- a/src/app/modules/time-entries/pages/time-entries.component.html +++ b/src/app/modules/time-entries/pages/time-entries.component.html @@ -33,7 +33,7 @@ Customer Project Activity - + Actions @@ -45,13 +45,13 @@ {{ entry.customer_name }} {{ entry.project_name }} {{ entry.activity_name }} - + + + + + diff --git a/src/app/modules/time-entries/pages/time-entries.component.scss b/src/app/modules/time-entries/pages/time-entries.component.scss index f8d73e904..5cbc63b85 100644 --- a/src/app/modules/time-entries/pages/time-entries.component.scss +++ b/src/app/modules/time-entries/pages/time-entries.component.scss @@ -26,3 +26,7 @@ table.dataTable thead .sorting_asc, table.dataTable thead .sorting_desc { background-image: none; } + +.actions-buttons { + text-align: center; +} diff --git a/src/app/modules/user/services/user-info.service.spec.ts b/src/app/modules/user/services/user-info.service.spec.ts index 79d2ef782..04f17c586 100644 --- a/src/app/modules/user/services/user-info.service.spec.ts +++ b/src/app/modules/user/services/user-info.service.spec.ts @@ -75,4 +75,13 @@ describe('UserInfoService', () => { }); }); + it('should return true if is Admin and isLegacyProduction', () => { + const groupsTT = {groups: ['fake-admin', 'fake-admin-tt']}; + spyOn(mockLoginService, 'getLocalStorage').and.returnValue(JSON.stringify(groupsTT)); + service.isLegacyProduction = true; + service.isMemberOf('fake-admin').subscribe((value) => { + expect(value).toEqual(true); + }); + }); + }); diff --git a/src/app/modules/users/components/users-list/users-list.component.html b/src/app/modules/users/components/users-list/users-list.component.html index ecc05e543..ff18560c2 100644 --- a/src/app/modules/users/components/users-list/users-list.component.html +++ b/src/app/modules/users/components/users-list/users-list.component.html @@ -20,6 +20,7 @@ admin diff --git a/src/app/modules/users/components/users-list/users-list.component.spec.ts b/src/app/modules/users/components/users-list/users-list.component.spec.ts index 025d5b1d9..ada0b01ef 100644 --- a/src/app/modules/users/components/users-list/users-list.component.spec.ts +++ b/src/app/modules/users/components/users-list/users-list.component.spec.ts @@ -1,4 +1,5 @@ import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { NgxPaginationModule } from 'ngx-pagination'; import { UsersListComponent } from './users-list.component'; @@ -7,12 +8,22 @@ import { ActionsSubject } from '@ngrx/store'; import { DataTablesModule } from 'angular-datatables'; import { GrantUserRole, RevokeUserRole } from '../../store/user.actions'; import { ROLES } from '../../../../../environments/environment'; +import { LoginService } from '../../../login/services/login.service'; +import { of } from 'rxjs'; +import { UserInfoService } from 'src/app/modules/user/services/user-info.service'; + describe('UsersListComponent', () => { let component: UsersListComponent; let fixture: ComponentFixture; let store: MockStore; + let httpMock: HttpTestingController; const actionSub: ActionsSubject = new ActionsSubject(); + let loginService: LoginService; + let userInfoService: UserInfoService; + const userInfoServiceStub = { + isAdmin: () => of(false), + }; const state: UserState = { data: [ @@ -33,9 +44,11 @@ describe('UsersListComponent', () => { beforeEach( waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NgxPaginationModule, DataTablesModule], + imports: [NgxPaginationModule, DataTablesModule, HttpClientTestingModule], declarations: [UsersListComponent], - providers: [provideMockStore({ initialState: state }), { provide: ActionsSubject, useValue: actionSub }], + providers: [provideMockStore({ initialState: state }), + { provide: ActionsSubject, useValue: actionSub }, + { providers: LoginService, useValue: {} }, ], }).compileComponents(); }) ); @@ -44,6 +57,9 @@ describe('UsersListComponent', () => { fixture = TestBed.createComponent(UsersListComponent); component = fixture.componentInstance; store = TestBed.inject(MockStore); + httpMock = TestBed.inject(HttpTestingController); + loginService = TestBed.inject(LoginService); + userInfoService = TestBed.inject(UserInfoService); store.setState(state); fixture.detectChanges(); }); @@ -229,6 +245,16 @@ describe('UsersListComponent', () => { expect(component.ROLES).toEqual(ROLES); }); + it('Should call to localstorage and helper decode for get information about user when checkRoleCurrentUser method is called', () => { + const account = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFiYyIsIm5hbWUiOiJhYmMiLCJlbWFpbCI6ImVtYWlsIiwiZ3JvdXBzIjpbImFkbWluIl19.gy1GljkoiuOjP8DzkoLRYE9SldBn5ljRc4kp8rwq7UI'; + spyOn(loginService, 'getLocalStorage').and.returnValue(account); + spyOn(userInfoService, 'isAdmin').and.returnValue(of(true)); + const response = component.checkRoleCurrentUser('email'); + expect(response).toBeTrue(); + expect(userInfoService.isAdmin).toHaveBeenCalled(); + expect(loginService.getLocalStorage).toHaveBeenCalled(); + }); + afterEach(() => { component.dtTrigger.unsubscribe(); component.loadUsersSubscription.unsubscribe(); diff --git a/src/app/modules/users/components/users-list/users-list.component.ts b/src/app/modules/users/components/users-list/users-list.component.ts index 6145e5d5a..d973a25f3 100644 --- a/src/app/modules/users/components/users-list/users-list.component.ts +++ b/src/app/modules/users/components/users-list/users-list.component.ts @@ -9,6 +9,9 @@ import { EnvironmentType } from 'src/environments/enum'; import { User } from '../../models/users'; import { LoadUsers, UserActionTypes, AddUserToGroup, RemoveUserFromGroup } from '../../store/user.actions'; import { getIsLoading } from '../../store/user.selectors'; +import { UserInfoService } from 'src/app/modules/user/services/user-info.service'; +import { LoginService } from '../../../login/services/login.service'; +import { JwtHelperService } from '@auth0/angular-jwt'; @Component({ selector: 'app-users-list', @@ -28,13 +31,16 @@ export class UsersListComponent implements OnInit, OnDestroy, AfterViewInit { }; switchGroupsSubscription: Subscription; isDevelopmentOrProd = true; + helper: JwtHelperService; public get ROLES() { return ROLES; } - constructor(private store: Store, private actionsSubject$: ActionsSubject) { + constructor(private store: Store, private actionsSubject$: ActionsSubject, + private userInfoService: UserInfoService, private loginService: LoginService) { this.isLoading$ = store.pipe(delay(0), select(getIsLoading)); + this.helper = new JwtHelperService(); } ngOnInit(): void { @@ -94,4 +100,11 @@ export class UsersListComponent implements OnInit, OnDestroy, AfterViewInit { ) ); } + + checkRoleCurrentUser(userEmail: string){ + const token = this.loginService.getLocalStorage('user'); + const user = this.helper.decodeToken(token); + return this.userInfoService.isAdmin() && (userEmail === user.email); + } + } diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 7e836342b..f5e34a552 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -3,22 +3,23 @@ // The list of file replacements can be found in 'angular.json'. import { EnvironmentType } from './enum'; +/* tslint:disable:no-string-literal */ export const environment = { production: EnvironmentType.TT_DEV, timeTrackerApiUrl: process.env['API_URL'], stackexchangeApiUrl: 'https://api.stackexchange.com', }; - - export const AUTHORITY = process.env['AUTHORITY']; export const CLIENT_ID = process.env['CLIENT_ID']; export const CLIENT_URL = process.env['CLIENT_URL']; export const SCOPES = process.env['SCOPES'].split(','); -export const ITEMS_PER_PAGE = 5; export const STACK_EXCHANGE_ID = process.env['STACK_EXCHANGE_ID']; export const STACK_EXCHANGE_ACCESS_TOKEN = process.env['STACK_EXCHANGE_ACCESS_TOKEN']; export const AZURE_APP_CONFIGURATION_CONNECTION_STRING = process.env['AZURE_APP_CONFIGURATION_CONNECTION_STRING']; +/* tslint:enable:no-string-literal */ +export const ITEMS_PER_PAGE = 5; + export const DATE_FORMAT = 'yyyy-MM-dd'; export const DATE_FORMAT_YEAR = 'YYYY-MM-DD'; export const GROUPS = {