diff --git a/ui/src/app/account/account.module.ts b/ui/src/app/account/account.module.ts index 59bdfd816..13f05b381 100644 --- a/ui/src/app/account/account.module.ts +++ b/ui/src/app/account/account.module.ts @@ -3,11 +3,13 @@ import { CommonModule } from '@angular/common' import { LoginComponent } from './login/login.component' import { RouterModule } from '@angular/router' import { ReactiveFormsModule } from '@angular/forms' -import { routes } from './account.route'; +import { routes } from './account.route' import { PasswordResetInitComponent } from './password/password-reset-init.component' +import { SettingsComponent } from './settings/settings.component' +import { SharedModule } from '../shared/shared.module' @NgModule({ - declarations: [LoginComponent, PasswordResetInitComponent], - imports: [CommonModule, ReactiveFormsModule, RouterModule.forChild(routes)], + declarations: [LoginComponent, PasswordResetInitComponent, SettingsComponent], + imports: [SharedModule, CommonModule, ReactiveFormsModule, RouterModule.forChild(routes)], }) export class AccountModule {} diff --git a/ui/src/app/account/account.route.ts b/ui/src/app/account/account.route.ts index 48f4f60fd..8794f2b25 100644 --- a/ui/src/app/account/account.route.ts +++ b/ui/src/app/account/account.route.ts @@ -1,6 +1,8 @@ import { Routes } from '@angular/router' import { LoginComponent } from './login/login.component' import { PasswordResetInitComponent } from './password/password-reset-init.component' +import { SettingsComponent } from './settings/settings.component' +import { AuthGuard } from './auth.guard' export const routes: Routes = [ { @@ -15,4 +17,13 @@ export const routes: Routes = [ pageTitle: 'global.menu.account.password.string', }, }, + { + path: 'settings', + component: SettingsComponent, + data: { + authorities: ['ROLE_USER'], + pageTitle: 'global.menu.account.settings.string', + }, + canActivate: [AuthGuard], + }, ] diff --git a/ui/src/app/account/auth.guard.ts b/ui/src/app/account/auth.guard.ts index 4f49e9871..67718ff98 100644 --- a/ui/src/app/account/auth.guard.ts +++ b/ui/src/app/account/auth.guard.ts @@ -15,8 +15,6 @@ export const AuthGuard = (route: ActivatedRouteSnapshot, state: RouterStateSnaps return accountService.getAccountData().pipe( filter((account) => account !== undefined), map((account) => { - console.log(authorities, account) - if (account) { const hasAnyAuthority = accountService.hasAnyAuthority(authorities) if (hasAnyAuthority) { diff --git a/ui/src/app/account/login/login.component.ts b/ui/src/app/account/login/login.component.ts index edca1cc51..83bbc9c86 100644 --- a/ui/src/app/account/login/login.component.ts +++ b/ui/src/app/account/login/login.component.ts @@ -43,7 +43,6 @@ export class LoginComponent implements AfterViewInit, OnDestroy { ngOnDestroy(): void { this.sub?.unsubscribe() - console.log('test') } ngAfterViewInit() { diff --git a/ui/src/app/account/service/account.service.ts b/ui/src/app/account/service/account.service.ts index 7dc4be9ed..9d49fb025 100644 --- a/ui/src/app/account/service/account.service.ts +++ b/ui/src/app/account/service/account.service.ts @@ -67,8 +67,8 @@ export class AccountService { ) } - getMfaSetup(): Observable> { - return this.http.get('/services/userservice/api/account/mfa', { observe: 'response' }) + getMfaSetup(): Observable<{ secret: string; otp: string; qrCode: any }> { + return this.http.get('/services/userservice/api/account/mfa') } save(account: any): Observable> { @@ -90,8 +90,6 @@ export class AccountService { } hasAnyAuthority(authorities: string[]): boolean { - console.log(authorities, this.accountData.value?.authorities) - if (!this.authenticated || !this.accountData || !this.accountData.value?.authorities) { return false } diff --git a/ui/src/app/account/settings/settings.component.html b/ui/src/app/account/settings/settings.component.html new file mode 100644 index 000000000..de18a8683 --- /dev/null +++ b/ui/src/app/account/settings/settings.component.html @@ -0,0 +1,138 @@ +
+
+
+

Personal details

+
+ +
+ Settings saved! +
+ +
+
+ + + +
+ + Your first name is required. + + + Your first name is required to be at least 1 character. + + + Your first name cannot be longer than 50 characters. + +
+
+
+ + + + +
+ + Your last name is required. + + + Your last name is required to be at least 1 character. + + + Your last name cannot be longer than 50 characters. + +
+
+
+ + + + +
+
+ + +
+ +
+
+ +
+

Security

+
+
+

Add extra security to your ORCID member portal account by enabling two-factor authentication. Each time you sign in, you'll be prompted to enter a six-digit code we send to your preferred authentication application.

+ + +
+
2FA settings updated
+
+
+
    +
  • Install a two-factor authentication app
    A 2FA app is required to create the six-digit code you need to access your account each time you sign in. Most apps are for mobile devices; some are also available as desktop or web-based apps. Download and install your preferred 2FA app, such as Google Authenticator, FreeOTP, or Authy.
  • +
  • Scan this QR code with your device
    Open your 2FA app and scan the image below.
  • +
+
+
+
+
+ QR Code +
+
+
+
+

{{ mfaSetup.secret }}

+
+
+
+
+
    +
  • Can't scan the QR code?
    Get a text code and enter it into your 2FA app instead
  • +
  • Enter the six-digit code from the app
    After scanning the QR code or entering in the text code, your 2FA app will display a six-digit code. Enter this code in the box below and click Save.
  • +
+
+
+
+
+
+ Incorrect verification code +
+ + + +
+
+
+
+

Make a note of the following backup codes, this is the only time they will be shown.

+ + + + +
{{ backupCode }}
+
+
+ +
+
+
+
+ +
diff --git a/ui/src/app/account/settings/settings.component.scss b/ui/src/app/account/settings/settings.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/app/account/settings/settings.component.spec.ts b/ui/src/app/account/settings/settings.component.spec.ts new file mode 100644 index 000000000..3e04d7e35 --- /dev/null +++ b/ui/src/app/account/settings/settings.component.spec.ts @@ -0,0 +1,229 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { SettingsComponent } from './settings.component' +import { ReactiveFormsModule } from '@angular/forms' +import { HttpClientModule, HttpResponse } from '@angular/common/http' +import { LanguageService } from 'src/app/shared/service/language.service' +import { AccountService } from '../service/account.service' +import { of, throwError } from 'rxjs' + +describe('SettingsComponent', () => { + let component: SettingsComponent + let fixture: ComponentFixture + let accountServiceSpy: jasmine.SpyObj + let languageServiceSpy: jasmine.SpyObj + + beforeEach(() => { + accountServiceSpy = jasmine.createSpyObj('AccountService', [ + 'getAccountData', + 'getUserName', + 'save', + 'getMfaSetup', + 'enableMfa', + 'disableMfa', + ]) + languageServiceSpy = jasmine.createSpyObj('LanguageService', [ + 'getAllLanguages', + 'getCurrentLanguage', + 'changeLanguage', + ]) + + TestBed.configureTestingModule({ + declarations: [SettingsComponent], + imports: [ReactiveFormsModule, HttpClientModule], + providers: [ + { provide: LanguageService, useValue: languageServiceSpy }, + { provide: AccountService, useValue: accountServiceSpy }, + ], + }).compileComponents() + + fixture = TestBed.createComponent(SettingsComponent) + component = fixture.componentInstance + accountServiceSpy = TestBed.inject(AccountService) as jasmine.SpyObj + }) + + it('should create', () => { + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: true, + }) + ) + + expect(component).toBeTruthy() + }) + + it('should flip mfa fields when mfa state changed', () => { + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: false, + }) + ) + accountServiceSpy.getMfaSetup.and.returnValue(of({ secret: 'test', otp: 'test', qrCode: 'test' })) + accountServiceSpy.getUserName.and.returnValue('test') + fixture.detectChanges() + + expect(component.showMfaSetup).toBeFalsy() + expect(component.showMfaBackupCodes).toBeFalsy() + + component.mfaForm.patchValue({ mfaEnabled: true }) + component.mfaEnabledStateChange() + + expect(component.showMfaSetup).toBeTruthy() + expect(component.showMfaBackupCodes).toBeFalsy() + }) + + it('should flip mfa fields when mfa state changed', () => { + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: true, + }) + ) + accountServiceSpy.getMfaSetup.and.returnValue(of({ secret: 'test', otp: 'test', qrCode: 'test' })) + + expect(component.showMfaTextCode).toBeFalsy() + + component.toggleMfaTextCode() + + expect(component.showMfaTextCode).toBeTruthy() + }) + + it('save mfa enabled should call account service enable', () => { + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: false, + }) + ) + accountServiceSpy.getMfaSetup.and.returnValue(of({ secret: 'test', otp: 'test', qrCode: ['test'] })) + accountServiceSpy.enableMfa.and.returnValue(of(new HttpResponse())) + fixture.detectChanges() + + component.mfaForm.patchValue({ mfaEnabled: true, verificationCode: 'test' }) + component.saveMfa() + + expect(accountServiceSpy.enableMfa).toHaveBeenCalled() + expect(accountServiceSpy.disableMfa).toHaveBeenCalledTimes(0) + }) + + it('save mfa enabled should call account service disable', () => { + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: true, + }) + ) + accountServiceSpy.getMfaSetup.and.returnValue(of({ secret: 'test', otp: 'test', qrCode: ['test'] })) + accountServiceSpy.disableMfa.and.returnValue(of(new HttpResponse())) + + component.mfaForm.patchValue({ mfaEnabled: false, verificationCode: 'test' }) + component.saveMfa() + + expect(accountServiceSpy.disableMfa).toHaveBeenCalled() + expect(accountServiceSpy.enableMfa).toHaveBeenCalledTimes(0) + }) + + it('save form should call accountService.save and then account data requested when save is successful', () => { + accountServiceSpy.save.and.returnValue(of(new HttpResponse())) + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: true, + }) + ) + languageServiceSpy.getCurrentLanguage.and.returnValue(of('en')) + fixture.detectChanges() + expect(component.success).toBeFalsy() + component.save() + expect(component.success).toBeTruthy() + expect(accountServiceSpy.save).toHaveBeenCalled() + }) + + it('save form should call accountService.save and then account data requested when save is successful', () => { + accountServiceSpy.save.and.returnValue(throwError(() => new Error('error'))) + accountServiceSpy.getAccountData.and.returnValue( + of({ + activated: true, + authorities: ['test', 'test'], + email: 'email@email.com', + firstName: 'name', + langKey: 'en', + lastName: 'surname', + imageUrl: 'url', + salesforceId: 'sfid', + loggedAs: false, + loginAs: 'sfid', + mainContact: false, + mfaEnabled: true, + }) + ) + fixture.detectChanges() + expect(component.success).toBeFalsy() + component.save() + expect(component.success).toBeFalsy() + expect(accountServiceSpy.save).toHaveBeenCalled() + expect(languageServiceSpy.getCurrentLanguage).toHaveBeenCalledTimes(0) + }) +}) diff --git a/ui/src/app/account/settings/settings.component.ts b/ui/src/app/account/settings/settings.component.ts new file mode 100644 index 000000000..bbab08e42 --- /dev/null +++ b/ui/src/app/account/settings/settings.component.ts @@ -0,0 +1,177 @@ +import { Component, OnInit } from '@angular/core' +import { FormBuilder, Validators } from '@angular/forms' +import { AccountService } from '../service/account.service' +import { DomSanitizer } from '@angular/platform-browser' +import { LanguageService } from 'src/app/shared/service/language.service' +import { IAccount } from '../model/account.model' + +@Component({ + selector: 'app-settings', + templateUrl: './settings.component.html', + styleUrls: ['./settings.component.scss'], +}) +export class SettingsComponent implements OnInit { + error: string | undefined + success: string | undefined + languages: any[] | undefined + userName: string | null = null + mfaSetup: any + showMfaSetup: boolean | undefined + showMfaTextCode: boolean | undefined + mfaSetupFailure: boolean | undefined + mfaBackupCodes: string[] | undefined + showMfaBackupCodes: boolean | undefined + showMfaUpdated: boolean | undefined + settingsForm = this.fb.group({ + firstName: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(50)]], + lastName: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(50)]], + email: ['', [Validators.required, Validators.minLength(5), Validators.maxLength(254), Validators.email]], + activated: [false], + authorities: [['']], + langKey: ['en'], + imageUrl: [''], + }) + mfaForm = this.fb.group({ + mfaEnabled: false, + verificationCode: [''], + securitySave: [], + }) + + constructor( + private accountService: AccountService, + private fb: FormBuilder, + private languageService: LanguageService, + private sanitizer: DomSanitizer + ) {} + + ngOnInit() { + this.showMfaSetup = false + this.showMfaTextCode = false + this.showMfaBackupCodes = false + console.log('calling get account data') + this.accountService.getAccountData().subscribe((account) => { + console.log('got account data', account) + if (account) { + this.updateForm(account) + this.updateMfaForm(account) + this.userName = this.accountService.getUserName() + console.log('acocunt and account mfa enabled are ', account, ' and ', account.mfaEnabled) + if (account && !account.mfaEnabled) { + this.accountService.getMfaSetup().subscribe((res) => { + console.log('setting mfa setup to ' + res) + this.mfaSetup = res + }) + } + } + }) + this.languages = this.languageService.getAllLanguages() + } + + mfaEnabledStateChange(): void { + console.log('mfa state change called') + this.showMfaUpdated = false + const mfaEnabled = this.mfaForm.get('mfaEnabled')!.value + console.log('setup is ' + this.mfaSetup) + if (mfaEnabled && this.mfaSetup) { + this.showMfaSetup = true + this.showMfaBackupCodes = false + } else { + this.showMfaSetup = false + this.showMfaBackupCodes = false + } + } + + toggleMfaTextCode(): void { + this.showMfaTextCode = true + } + + save() { + const settingsAccount = this.accountFromForm() + this.accountService.save(settingsAccount).subscribe({ + next: () => { + this.error = undefined + this.success = 'OK' + this.accountService.getAccountData().subscribe((account) => { + if (account) { + this.updateForm(account) + this.updateMfaForm(account) + } + }) + this.languageService.getCurrentLanguage().subscribe((current) => { + if (settingsAccount.langKey !== current) { + this.languageService.changeLanguage(settingsAccount.langKey) + } + }) + }, + error: () => { + this.success = undefined + this.error = 'ERROR' + }, + }) + } + + saveMfa() { + const enabled = this.mfaForm.get('mfaEnabled')!.value + if (enabled) { + const otp = this.mfaForm.get('verificationCode')!.value + console.log('about to set otp on ' + this.mfaSetup) + this.mfaSetup.otp = otp + this.accountService.enableMfa(this.mfaSetup).subscribe({ + next: (res) => { + this.mfaBackupCodes = res.body + this.showMfaBackupCodes = true + this.showMfaUpdated = true + }, + error: (err) => { + this.mfaSetupFailure = true + }, + }) + } else { + this.accountService.disableMfa().subscribe({ + next: () => { + this.showMfaUpdated = true + this.accountService.getMfaSetup().subscribe((res) => { + this.mfaSetup = res + }) + }, + error: (err) => console.log('error disabling mfa'), + }) + } + } + + safeQrCode() { + return this.sanitizer.bypassSecurityTrustResourceUrl('data:image/png;base64, ' + this.mfaSetup.qrCode) + } + + private accountFromForm(): any { + const account = {} + return { + ...account, + firstName: this.settingsForm.get('firstName')!.value, + lastName: this.settingsForm.get('lastName')!.value, + email: this.settingsForm.get('email')!.value, + activated: this.settingsForm.get('activated')!.value, + authorities: this.settingsForm.get('authorities')!.value, + langKey: this.settingsForm.get('langKey')!.value, + imageUrl: this.settingsForm.get('imageUrl')!.value, + } + } + + updateForm(account: IAccount): void { + this.settingsForm.patchValue({ + firstName: account.firstName, + lastName: account.lastName, + email: account.email, + activated: account.activated, + authorities: account.authorities, + langKey: account.langKey, + imageUrl: account.imageUrl, + }) + } + + updateMfaForm(account: IAccount): void { + this.mfaForm.patchValue({ + mfaEnabled: account.mfaEnabled, + }) + } +} diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index 140ef1aef..4b906ce98 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -11,8 +11,9 @@ import { NavbarComponent } from './layout/navbar/navbar.component' import { CommonModule } from '@angular/common' import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { HasAnyAuthorityDirective } from './shared/directive/has-any-authority.directive' -import { HomeModule } from './home/home.module'; +import { HomeModule } from './home/home.module' import { FooterComponent } from './layout/footer/footer.component' +import { SharedModule } from './shared/shared.module' @NgModule({ declarations: [AppComponent, NavbarComponent, HasAnyAuthorityDirective, FooterComponent], @@ -26,6 +27,7 @@ import { FooterComponent } from './layout/footer/footer.component' NgxWebstorageModule.forRoot(), CommonModule, NgbModule, + SharedModule.forRoot(), ], providers: [], bootstrap: [AppComponent], diff --git a/ui/src/app/layout/navbar/navbar.component.html b/ui/src/app/layout/navbar/navbar.component.html index c4f9422f0..f41071d7a 100644 --- a/ui/src/app/layout/navbar/navbar.component.html +++ b/ui/src/app/layout/navbar/navbar.component.html @@ -252,7 +252,7 @@