diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index de67975194..a124e65118 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,5 +1,5 @@ import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' +import { LoadChildrenCallback, RouterModule, Routes } from '@angular/router' import { ApplicationRoutes, @@ -17,6 +17,7 @@ import { LanguageGuard } from './guards/language.guard' import { ThirdPartySigninCompletedGuard } from './guards/third-party-signin-completed.guard' import { TwoFactorSigninGuard } from './guards/two-factor-signin.guard' import { AuthenticatedNoDelegatorGuard } from './guards/authenticated-no-delagator.guard' +import { RegisterTogglGuard } from './guards/register-toggl.guard' const routes: Routes = [ { @@ -89,9 +90,13 @@ const routes: Routes = [ }, { path: ApplicationRoutes.register, + canMatch: [RegisterTogglGuard], canActivateChild: [LanguageGuard, RegisterGuard], - loadChildren: () => - import('./register/register.module').then((m) => m.RegisterModule), + loadChildren: () => { + return localStorage.getItem('REGISTRATION_2_0') !== 'enable' + ? import('./register/register.module').then((m) => m.RegisterModuleLegacy) + : import('./register2/register.module').then((m) => m.Register2Module) + }, }, { path: ApplicationRoutes.search, @@ -151,7 +156,7 @@ const routes: Routes = [ matcher: routerReactivation, canActivateChild: [LanguageGuard, RegisterGuard], loadChildren: () => - import('./register/register.module').then((m) => m.RegisterModule), + import('./register/register.module').then((m) => m.RegisterModuleLegacy), }, { path: ApplicationRoutes.selfService, diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 0e02e598b8..6044e1eac7 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,21 +2,20 @@ import { Component, HostBinding, HostListener, Inject } from '@angular/core' import { NavigationEnd, NavigationStart, Router } from '@angular/router' import { catchError, tap } from 'rxjs/operators' +import { + finishPerformanceMeasurement, + reportNavigationStart, +} from './analytics-utils' import { PlatformInfo } from './cdk/platform-info' import { PlatformInfoService } from './cdk/platform-info/platform-info.service' import { WINDOW } from './cdk/window' import { HeadlessOnOauthRoutes } from './constants' import { UserService } from './core' -import { ZendeskService } from './core/zendesk/zendesk.service' -import { GoogleTagManagerService } from './core/google-tag-manager/google-tag-manager.service' -import { - finishPerformanceMeasurement, - reportNavigationStart, -} from './analytics-utils' -import { ERROR_REPORT } from './errors' import { ErrorHandlerService } from './core/error-handler/error-handler.service' -import { environment } from 'src/environments/environment' +import { GoogleTagManagerService } from './core/google-tag-manager/google-tag-manager.service' import { TitleService } from './core/title-service/title.service' +import { ZendeskService } from './core/zendesk/zendesk.service' +import { ERROR_REPORT } from './errors' @Component({ selector: 'app-root', diff --git a/src/app/core/oauth/oauth.service.ts b/src/app/core/oauth/oauth.service.ts index 0d01ef65ad..d374d904e5 100644 --- a/src/app/core/oauth/oauth.service.ts +++ b/src/app/core/oauth/oauth.service.ts @@ -96,7 +96,7 @@ export class OauthService { .post( environment.BASE_URL + 'oauth/custom/authorize.json', value, - { headers: this.headers } + { headers: this.headers, withCredentials: true } ) .pipe( retry(3), diff --git a/src/app/core/register2/register2.backend-validators.ts b/src/app/core/register2/register2.backend-validators.ts new file mode 100644 index 0000000000..92abbe4f71 --- /dev/null +++ b/src/app/core/register2/register2.backend-validators.ts @@ -0,0 +1,253 @@ +import { HttpClient } from '@angular/common/http' +import { + AbstractControl, + AsyncValidatorFn, + UntypedFormGroup, + ValidationErrors, +} from '@angular/forms' +import { Observable, of } from 'rxjs' +import { catchError, map, retry } from 'rxjs/operators' +import { Constructor } from 'src/app/types' +import { RegisterForm } from 'src/app/types/register.endpoint' +import { environment } from 'src/environments/environment' +import { ErrorHandlerService } from '../error-handler/error-handler.service' + +interface HasHttpClientAndErrorHandler { + _http: HttpClient + _errorHandler: ErrorHandlerService +} + +interface HasFormAdapters { + formGroupToEmailRegisterForm(formGroup: UntypedFormGroup): RegisterForm + formGroupToPasswordRegisterForm(formGroup: UntypedFormGroup): RegisterForm + formGroupToFullRegistrationForm( + StepA: UntypedFormGroup, + StepB: UntypedFormGroup, + StepC: UntypedFormGroup, + StepD: UntypedFormGroup + ): RegisterForm +} + +export function Register2BackendValidatorMixin< + T extends Constructor +>(base: T) { + return class RegisterBackendValidator extends base { + constructor(...args: any[]) { + super(...args) + } + formInputs = { + givenNames: { + validationEndpoint: 'validateGivenNames', + }, + familyNames: { + validationEndpoint: 'validateFamilyNames', + }, + email: { + validationEndpoint: 'validateEmail', + }, + emailsAdditional: { + validationEndpoint: 'validateEmailsAdditional', + }, + passwordConfirm: { + validationEndpoint: 'validatePasswordConfirm', + }, + password: { + validationEndpoint: 'validatePassword', + }, + } + + validateRegisterValue( + controlName: string, + value: RegisterForm + ): Observable { + return this._http + .post( + environment.API_WEB + + `oauth/custom/register/${this.formInputs[controlName].validationEndpoint}.json`, + value + ) + .pipe( + retry(3), + catchError((error) => this._errorHandler.handleError(error)) + ) + } + + validateAdditionalEmailsReactivation( + value: RegisterForm + ): Observable { + return this._http + .post( + `${environment.API_WEB}reactivateAdditionalEmailsValidate.json`, + value + ) + .pipe( + retry(3), + catchError((error) => this._errorHandler.handleError(error)) + ) + } + + backendValueValidate( + controlName: 'givenNames' | 'familyNames' | 'email' | 'password' + ): AsyncValidatorFn { + return ( + control: AbstractControl + ): Observable => { + if (control.value === '') { + return of(null) + } + const value = {} + value[controlName] = { value: control.value } + + return this.validateRegisterValue(controlName, value).pipe( + map((res) => { + if (res[controlName].errors && res[controlName].errors.length > 0) { + const error = { + backendError: res[controlName].errors, + } + return error + } + return null + }) + ) + } + } + + backendAdditionalEmailsValidate(reactivate: boolean): AsyncValidatorFn { + return ( + formGroup: UntypedFormGroup + ): Observable => { + const value: RegisterForm = this.formGroupToEmailRegisterForm(formGroup) + if (!value.emailsAdditional || value.emailsAdditional.length === 0) { + return of(null) + } + + if (reactivate) { + return this.validateAdditionalEmailsReactivation(value).pipe( + map((response) => { + // Add errors to additional emails controls + return this.setFormGroupEmailErrors(response, 'backendErrors') + }) + ) + } + + return this.validateRegisterValue('emailsAdditional', value).pipe( + map((response) => { + // Add errors to additional emails controls + return this.setFormGroupEmailErrors(response, 'backendErrors') + }) + ) + } + } + + backendPasswordValidate(): AsyncValidatorFn { + return ( + formGroup: UntypedFormGroup + ): Observable => { + const value: RegisterForm = + this.formGroupToPasswordRegisterForm(formGroup) + if (value.password.value === '' || value.passwordConfirm.value === '') { + return of(null) + } + return this.validateRegisterValue('password', value).pipe( + map((response) => { + // Add errors to additional emails controls + return this.setFormGroupPasswordErrors(response, 'backendErrors') + }) + ) + } + } + + backendRegisterFormValidate( + StepA: UntypedFormGroup, + StepB: UntypedFormGroup, + StepC: UntypedFormGroup, + StepD: UntypedFormGroup, + + type?: 'shibboleth' + ): Observable { + const registerForm = this.formGroupToFullRegistrationForm( + StepA, + StepB, + StepC, + StepD + ) + return this._http + .post(`${environment.API_WEB}register.json`, registerForm) + .pipe( + retry(3), + catchError((error) => this._errorHandler.handleError(error)) + ) + } + + public setFormGroupEmailErrors( + registerForm: RegisterForm, + errorGroup: string + ) { + let hasErrors = false + const error = {} + error[errorGroup] = { + additionalEmails: {}, + email: [], + } + + registerForm.emailsAdditional.forEach((responseControl) => { + if (responseControl.errors && responseControl.errors.length > 0) { + hasErrors = true + error[errorGroup]['additionalEmails'][responseControl.value] = + responseControl.errors + } + }) + + if ( + registerForm.email && + registerForm.email.errors && + registerForm.email.errors.length > 0 + ) { + hasErrors = true + error[errorGroup]['email'].push({ + value: registerForm.email.value, + errors: registerForm.email.errors, + }) + } + + return hasErrors ? error : null + } + + public setFormGroupPasswordErrors( + registerForm: RegisterForm, + errorGroup: string + ) { + let hasErrors = false + const error = {} + error[errorGroup] = { + password: [], + passwordConfirm: [], + } + + if ( + registerForm.password && + registerForm.password.errors && + registerForm.password.errors.length > 0 + ) { + hasErrors = true + error[errorGroup]['password'].push({ + value: registerForm.email.value, + errors: registerForm.email.errors, + }) + } + if ( + registerForm.passwordConfirm && + registerForm.passwordConfirm.errors && + registerForm.passwordConfirm.errors.length > 0 + ) { + hasErrors = true + error[errorGroup]['passwordConfirm'].push({ + value: registerForm.passwordConfirm.value, + errors: registerForm.passwordConfirm.errors, + }) + } + + return hasErrors ? error : null + } + } +} diff --git a/src/app/core/register2/register2.form-adapter.ts b/src/app/core/register2/register2.form-adapter.ts new file mode 100644 index 0000000000..f4d52ad296 --- /dev/null +++ b/src/app/core/register2/register2.form-adapter.ts @@ -0,0 +1,135 @@ +import { UntypedFormGroup } from '@angular/forms' +import { Constructor } from 'src/app/types' +import { Value, Visibility } from 'src/app/types/common.endpoint' +import { RegisterForm } from 'src/app/types/register.endpoint' + +export function Register2FormAdapterMixin>(base: T) { + return class RegisterFormAdapter extends base { + formGroupToEmailRegisterForm(formGroup: UntypedFormGroup): RegisterForm { + let additionalEmailsValue: Value[] + if (formGroup.controls['additionalEmails']) { + const additionalEmailsControls = ( + formGroup.controls['additionalEmails'] as UntypedFormGroup + ).controls + additionalEmailsValue = Object.keys(additionalEmailsControls) + .filter((name) => additionalEmailsControls[name].value !== '') + .map((name) => { + if (additionalEmailsControls[name].value) { + return { value: additionalEmailsControls[name].value } + } + }) + } + let emailValue + if (formGroup.controls['email']) { + emailValue = formGroup.controls['email'].value + } + + const value: RegisterForm = {} + + if (emailValue) { + value['email'] = { value: emailValue } + } + if (additionalEmailsValue) { + value['emailsAdditional'] = additionalEmailsValue + } + return value + } + + formGroupToNamesRegisterForm(formGroup: UntypedFormGroup): RegisterForm { + return { + givenNames: { value: formGroup.controls['givenNames'].value }, + familyNames: { value: formGroup.controls['familyNames'].value }, + } + } + + formGroupToActivitiesVisibilityForm( + formGroup: UntypedFormGroup + ): RegisterForm { + let activitiesVisibilityDefault: Visibility + if ( + formGroup && + formGroup.controls && + formGroup.controls['activitiesVisibilityDefault'] + ) { + activitiesVisibilityDefault = { + visibility: formGroup.controls['activitiesVisibilityDefault'].value, + } + } + return { activitiesVisibilityDefault } + } + + formGroupToPasswordRegisterForm(formGroup: UntypedFormGroup): RegisterForm { + let password: Value + if (formGroup && formGroup.controls && formGroup.controls['password']) { + password = { value: formGroup.controls['password'].value } + } + let passwordConfirm: Value + if ( + formGroup && + formGroup.controls && + formGroup.controls['passwordConfirm'] + ) { + passwordConfirm = { value: formGroup.controls['passwordConfirm'].value } + } + return { password, passwordConfirm } + } + + formGroupTermsOfUseAndDataProcessedRegisterForm( + formGroup: UntypedFormGroup + ): RegisterForm { + let termsOfUse: Value + let dataProcessed: Value + if (formGroup && formGroup.controls) { + if (formGroup.controls['termsOfUse']) { + termsOfUse = { value: formGroup.controls['termsOfUse'].value } + } + if (formGroup.controls['dataProcessed']) { + dataProcessed = { value: formGroup.controls['dataProcessed'].value } + } + } + return { termsOfUse, dataProcessed } + } + + formGroupToSendOrcidNewsForm(formGroup: UntypedFormGroup) { + let sendOrcidNews: Value + if ( + formGroup && + formGroup.controls && + formGroup.controls['sendOrcidNews'] + ) { + sendOrcidNews = { value: formGroup.controls['sendOrcidNews'].value } + } + return { sendOrcidNews } + } + + formGroupToRecaptchaForm( + formGroup: UntypedFormGroup, + widgetId: number + ): RegisterForm { + const value: RegisterForm = {} + value.grecaptchaWidgetId = { + value: widgetId != null ? widgetId.toString() : null, + } + if (formGroup && formGroup.controls && formGroup.controls['captcha']) { + value.grecaptcha = { value: formGroup.controls['captcha'].value } + } + return value + } + + formGroupToFullRegistrationForm( + StepA: UntypedFormGroup, + StepB: UntypedFormGroup, + StepC: UntypedFormGroup, + StepD: UntypedFormGroup + ): RegisterForm { + return { + ...StepA.value.personal, + ...StepB.value.password, + ...StepB.value.sendOrcidNews, + ...StepD.value.activitiesVisibilityDefault, + ...StepD.value.termsOfUse, + ...StepD.value.captcha, + } + } + } +} diff --git a/src/app/core/register2/register2.service.ts b/src/app/core/register2/register2.service.ts new file mode 100644 index 0000000000..a362de9d4c --- /dev/null +++ b/src/app/core/register2/register2.service.ts @@ -0,0 +1,190 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { UntypedFormGroup } from '@angular/forms' +import { Observable } from 'rxjs' +import { catchError, first, map, retry, switchMap } from 'rxjs/operators' +import { PlatformInfo, PlatformInfoService } from 'src/app/cdk/platform-info' +import { RequestInfoForm } from 'src/app/types' +import { + DuplicatedName, + RegisterConfirmResponse, + RegisterForm, +} from 'src/app/types/register.endpoint' +import { environment } from 'src/environments/environment' + +import { ERROR_REPORT } from 'src/app/errors' +import { objectToUrlParameters } from '../../constants' +import { ReactivationLocal } from '../../types/reactivation.local' +import { ErrorHandlerService } from '../error-handler/error-handler.service' +import { UserService } from '../user/user.service' +import { Register2BackendValidatorMixin } from './register2.backend-validators' +import { Register2FormAdapterMixin } from './register2.form-adapter' +import { EmailCategoryEndpoint } from 'src/app/types/register.email-category' + +// Mixing boiler plate + +class Register2ServiceBase { + constructor( + public _http: HttpClient, + public _errorHandler: ErrorHandlerService + ) {} +} +const _RegisterServiceMixingBase = Register2BackendValidatorMixin( + Register2FormAdapterMixin(Register2ServiceBase) +) + +@Injectable({ + providedIn: 'root', +}) +export class Register2Service extends _RegisterServiceMixingBase { + backendRegistrationForm: RegisterForm + + constructor( + _http: HttpClient, + _errorHandler: ErrorHandlerService, + private _userService: UserService, + private _platform: PlatformInfoService + ) { + super(_http, _errorHandler) + } + + public checkDuplicatedResearcher(names: { + familyNames: string + givenNames: string + }) { + return this._http + .get(environment.API_WEB + `dupicateResearcher.json`, { + params: names, + withCredentials: true, + }) + .pipe( + retry(3), + catchError((error) => this._errorHandler.handleError(error)) + ) + } + + getRegisterForm(): Observable { + return this._http + .get(`${environment.API_WEB}register.json`, { + withCredentials: true, + }) + .pipe( + retry(3), + catchError((error) => this._errorHandler.handleError(error)) + ) + .pipe(map((form) => (this.backendRegistrationForm = form))) + } + + getEmailCategory(email: string): Observable { + return this._http.get( + `${environment.API_WEB}email-domain/find-category?domain=${email}` + ) + } + + register( + StepA: UntypedFormGroup, + StepB: UntypedFormGroup, + StepC: UntypedFormGroup, + StepD: UntypedFormGroup, + reactivation: ReactivationLocal, + requestInfoForm?: RequestInfoForm, + updateUserService = true + ): Observable { + this.backendRegistrationForm.valNumClient = + this.backendRegistrationForm.valNumServer / 2 + const registerForm = this.formGroupToFullRegistrationForm( + StepA, + StepB, + StepC, + StepD + ) + this.addOauthContext(registerForm, requestInfoForm) + return this._platform.get().pipe( + first(), + switchMap((platform) => { + let url = `${environment.API_WEB}` + if ( + platform.institutional || + platform.queryParameters.linkType === 'shibboleth' + ) { + url += `shibboleth/` + } + if (reactivation.isReactivation) { + url += `reactivationConfirm.json?${objectToUrlParameters( + platform.queryParameters + )}` + registerForm.resetParams = reactivation.reactivationCode + } else { + url += `registerConfirm.json?${objectToUrlParameters( + platform.queryParameters + )}` + } + + const registerFormWithTypeContext = this.addCreationTypeContext( + platform, + registerForm + ) + + return this._http + .post( + url, + Object.assign( + this.backendRegistrationForm, + registerFormWithTypeContext + ) + ) + .pipe( + retry(3), + catchError((error) => + this._errorHandler.handleError(error, ERROR_REPORT.REGISTER) + ), + switchMap((value) => { + return this._userService.refreshUserSession(true, true).pipe( + first(), + map((userStatus) => { + if (!userStatus.loggedIn && !value.errors) { + // sanity check the user should be logged + // sanity check the user should be logged + this._errorHandler.handleError( + new Error('registerSanityIssue'), + ERROR_REPORT.REGISTER + ) + } + return value + }) + ) + }) + ) + }) + ) + } + + addOauthContext( + registerForm: RegisterForm, + requestInfoForm?: RequestInfoForm + ): void { + if (requestInfoForm) { + registerForm.referredBy = { value: requestInfoForm.clientId } + } + } + addCreationTypeContext( + platform: PlatformInfo, + registerForm: RegisterForm + ): RegisterForm { + /// TODO @leomendoza123 depend only on the user session thirty party login data + /// avoid taking data from the the parameters. + if ( + platform.social || + platform.queryParameters.providerId === 'facebook' || + platform.queryParameters.providerId === 'google' + ) { + registerForm.linkType = 'social' + return registerForm + } else if (platform.institutional || platform.queryParameters.providerId) { + registerForm.linkType = 'shibboleth' + return registerForm + } else { + return registerForm + } + } +} diff --git a/src/app/guards/register-toggl.guard.ts b/src/app/guards/register-toggl.guard.ts new file mode 100644 index 0000000000..c18a827dd9 --- /dev/null +++ b/src/app/guards/register-toggl.guard.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@angular/core' +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router' +import { Observable } from 'rxjs' +import { delay, map } from 'rxjs/operators' + +import { UserService } from '../core' +import { TogglzService } from '../core/togglz/togglz.service' + +@Injectable({ + providedIn: 'root', +}) +export class RegisterTogglGuard { + constructor( + private _user: UserService, + private _router: Router, + private _togglz: TogglzService + ) {} + + canMatch( + next: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable | UrlTree | boolean { + return this._togglz.getStateOf('REGISTRATION_2_0').pipe( + map((session) => { + if (session) { + localStorage.setItem('REGISTRATION_2_0', 'enabled') + } else { + localStorage.removeItem('REGISTRATION_2_0') + } + + // PLEASE NOTE THE PURPOSE OF THIS GUARD IS TO SET THE 'REGISTRATION_2_0' togglz + // before the registration module is loaded. + // This is done by setting the localStorage item 'REGISTRATION_2_0'. + + // DESPITE OF THIS BEEN A GUARD, IT DOES NOT PREVENT THE USER FROM ACCESSING THE REGISTRATION MODULE. + // IT JUST HELPS SETTING THE LOGIC TWO WHICH VERSION OF THE REGISTRATION MODULE SHOULD BE LOADED. + + // PLEASE NOTE THAT HAVING TO WAIT FOR THE FLAG TO LOAD BEFORE LOADING THE REGISTRATION MODULE + // IS A TEMPORAL SOLUTION AND MAKES THE LOADING OF THE REGISTRATION MODULE SLOWER. + return true + }) + ) + } +} diff --git a/src/app/institutional/pages/institutional/institutional.component.scss-theme.scss b/src/app/institutional/pages/institutional/institutional.component.scss-theme.scss index 0639da0481..663cb13ef6 100644 --- a/src/app/institutional/pages/institutional/institutional.component.scss-theme.scss +++ b/src/app/institutional/pages/institutional/institutional.component.scss-theme.scss @@ -1,4 +1,4 @@ -@import '../../../../../node_modules/@angular/material/theming'; +@import '~@angular/material/theming'; @import '../../../../assets/scss/material.orcid-theme'; @mixin institutional-theme($theme) { diff --git a/src/app/layout/header/header.component.ts b/src/app/layout/header/header.component.ts index f7afd092af..eb039c74ec 100644 --- a/src/app/layout/header/header.component.ts +++ b/src/app/layout/header/header.component.ts @@ -5,7 +5,6 @@ import { filter } from 'rxjs/operators' import { PlatformInfo, PlatformInfoService } from 'src/app/cdk/platform-info' import { WINDOW } from 'src/app/cdk/window' import { UserService } from 'src/app/core' -import { SignInService } from 'src/app/core/sign-in/sign-in.service' import { TogglzService } from 'src/app/core/togglz/togglz.service' import { ApplicationMenuItem, UserInfo } from 'src/app/types' import { @@ -77,7 +76,8 @@ export class HeaderComponent implements OnInit { path === `/${ApplicationRoutes.trustedParties}` || path === `/${ApplicationRoutes.selfService}` || path === `/${ApplicationRoutes.inbox}` || - path === `/${ApplicationRoutes.developerTools}` + path === `/${ApplicationRoutes.developerTools}` || + path.indexOf(`/${ApplicationRoutes.register}`) !== -1 }) } diff --git a/src/app/register/components/form-password/form-password.component.html b/src/app/register/components/form-password/form-password.component.html index 7c8077b9be..2e033006b1 100644 --- a/src/app/register/components/form-password/form-password.component.html +++ b/src/app/register/components/form-password/form-password.component.html @@ -11,9 +11,9 @@ /> - A password is required + Please enter a password NEXT diff --git a/src/app/register/components/step-a/step-a.component.ts b/src/app/register/components/step-a/step-a.component.ts index 303369d6ae..cc3c652e92 100644 --- a/src/app/register/components/step-a/step-a.component.ts +++ b/src/app/register/components/step-a/step-a.component.ts @@ -22,6 +22,10 @@ export class StepAComponent extends BaseStepDirective { } infoSiteBaseUrl = environment.INFO_SITE + goForward() { + this.formGroup.markAllAsTouched() + } + goBack() { this._platform .get() diff --git a/src/app/register/pages/register/register.component.ts b/src/app/register/pages/register/register.component.ts index b64834de10..62aa6abb2f 100644 --- a/src/app/register/pages/register/register.component.ts +++ b/src/app/register/pages/register/register.component.ts @@ -12,27 +12,26 @@ import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms' import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog' import { MatStep } from '@angular/material/stepper' import { Router } from '@angular/router' -import { combineLatest, forkJoin, Observable } from 'rxjs' +import { Observable, combineLatest, forkJoin } from 'rxjs' import { catchError, first, map, switchMap } from 'rxjs/operators' import { IsThisYouComponent } from 'src/app/cdk/is-this-you' import { PlatformInfo, PlatformInfoService } from 'src/app/cdk/platform-info' import { WINDOW } from 'src/app/cdk/window' import { isRedirectToTheAuthorizationPage } from 'src/app/constants' import { UserService } from 'src/app/core' +import { ErrorHandlerService } from 'src/app/core/error-handler/error-handler.service' import { RegisterService } from 'src/app/core/register/register.service' -import { RequestInfoForm } from 'src/app/types' +import { ERROR_REPORT } from 'src/app/errors' +import { RequestInfoForm, SearchParameters, SearchResults } from 'src/app/types' import { RegisterConfirmResponse, RegisterForm, } from 'src/app/types/register.endpoint' -import { ErrorHandlerService } from 'src/app/core/error-handler/error-handler.service' -import { ERROR_REPORT } from 'src/app/errors' import { UserSession } from 'src/app/types/session.local' import { ThirdPartyAuthData } from 'src/app/types/sign-in-data.endpoint' -import { ReactivationLocal } from '../../../types/reactivation.local' -import { SearchService } from '../../../core/search/search.service' -import { SearchParameters, SearchResults } from 'src/app/types' import { GoogleTagManagerService } from '../../../core/google-tag-manager/google-tag-manager.service' +import { SearchService } from '../../../core/search/search.service' +import { ReactivationLocal } from '../../../types/reactivation.local' @Component({ selector: 'app-register', diff --git a/src/app/register/register.module.ts b/src/app/register/register.module.ts index 4757a9c74a..f7519921a0 100644 --- a/src/app/register/register.module.ts +++ b/src/app/register/register.module.ts @@ -71,4 +71,4 @@ import { MdePopoverModule } from '../cdk/popover' WarningMessageModule, ], }) -export class RegisterModule {} +export class RegisterModuleLegacy {} diff --git a/src/app/register2/ErrorStateMatcherForFormLevelErrors.ts b/src/app/register2/ErrorStateMatcherForFormLevelErrors.ts new file mode 100644 index 0000000000..3877e5c3db --- /dev/null +++ b/src/app/register2/ErrorStateMatcherForFormLevelErrors.ts @@ -0,0 +1,35 @@ +import { UntypedFormControl, FormGroupDirective, NgForm } from '@angular/forms' +import { ErrorStateMatcher } from '@angular/material/core' + +export class ErrorStateMatcherForFormLevelErrors implements ErrorStateMatcher { + getControlErrorAtForm: ( + control: UntypedFormControl, + errorGroup: string + ) => string[] + errorGroup: string + constructor( + getControlErrorAtForm: ( + control: UntypedFormControl, + errorGroup: string + ) => string[], + errorGroup: string + ) { + this.getControlErrorAtForm = getControlErrorAtForm + this.errorGroup = errorGroup + } + isErrorState( + control: UntypedFormControl | null, + form: FormGroupDirective | NgForm | null + ): boolean { + const errorsAtFormLevel = this.getControlErrorAtForm( + control, + this.errorGroup + ) + const controlInteracted = control.touched || (form && form.submitted) + const validControlAtFormLevel = !( + errorsAtFormLevel && errorsAtFormLevel.length > 0 + ) + const validControl = control && !control.invalid + return !(validControlAtFormLevel && validControl) && controlInteracted + } +} diff --git a/src/app/register2/components/BaseForm.ts b/src/app/register2/components/BaseForm.ts new file mode 100644 index 0000000000..317ced3fb2 --- /dev/null +++ b/src/app/register2/components/BaseForm.ts @@ -0,0 +1,66 @@ +import { + AbstractControl, + AsyncValidator, + ControlValueAccessor, + UntypedFormGroup, + ValidationErrors, +} from '@angular/forms' +import { merge, Observable, timer } from 'rxjs' +import { filter, map, startWith, take } from 'rxjs/operators' + +export abstract class BaseForm implements ControlValueAccessor, AsyncValidator { + public form: UntypedFormGroup + public onTouchedFunction + constructor() {} + writeValue(val: any): void { + console.log('writeValue', val) + if (val != null && val !== undefined && val !== '') { + this.form.setValue(val, { emitEvent: true }) + // Trigger registerOnChange custom function by calling form.updateValueAndValidity + // require since most form controls extending this class + // need to call the xxxxRegisterForm functions to adapt the original angular form value for the backend format + setTimeout(() => { + this.form.updateValueAndValidity() + }) + } + } + registerOnChange(fn: any): void { + this.form.valueChanges.subscribe((value) => { + fn(value) + }) + } + registerOnTouched(fn: any): void { + this.onTouchedFunction = fn + } + setDisabledState?(isDisabled: boolean): void { + isDisabled ? this.form.disable() : this.form.enable() + } + validate(c: AbstractControl): Observable { + // temporal fix + // see related issue + // https://github.com/angular/angular/issues/14542 + // depending of fix + // https://github.com/angular/angular/pull/20806 + // + // using form.statusChanges observable only would be a better solution for this scenario (see the code before this fix) + // but if the form status starts as `pending` Angular wont report the status change because of #14542 + // and the status might now start as `pending` with the introduction of Oauth registration + + return merge(this.form.statusChanges, timer(0, 1000)).pipe( + map(() => this.form.status), + startWith(this.form.status), + filter((value) => value !== 'PENDING'), + take(1), + map(() => { + return this.form.valid + ? null + : { + invalidForm: { + valid: false, + message: 'internal form is not valid', + }, + } + }) + ) + } +} diff --git a/src/app/register2/components/BaseStep.ts b/src/app/register2/components/BaseStep.ts new file mode 100644 index 0000000000..fdb03d461a --- /dev/null +++ b/src/app/register2/components/BaseStep.ts @@ -0,0 +1,16 @@ +import { EventEmitter, Input, Output, Directive } from '@angular/core' +import { UntypedFormGroup } from '@angular/forms' + +@Directive() +export abstract class BaseStepDirective { + public _formGroup: UntypedFormGroup + @Input() + set formGroup(formGroup: UntypedFormGroup) { + this._formGroup = formGroup + this.formGroupChange.emit(this._formGroup) + } + get formGroup() { + return this._formGroup + } + @Output() formGroupChange = new EventEmitter() +} diff --git a/src/app/register2/components/backend-error/backend-error.component.html b/src/app/register2/components/backend-error/backend-error.component.html new file mode 100644 index 0000000000..d08320c6bb --- /dev/null +++ b/src/app/register2/components/backend-error/backend-error.component.html @@ -0,0 +1,45 @@ + + This email already exists in our system. Would you like to + + sign in? + + + + Additional email cannot match primary email + + + + Additional emails cannot be duplicated + + + + The ORCID record exists but has not been claimed. Would you like + to + + resend the claim email? + + + + + A deactivated ORCID record is associated with this email address. + + click here to reactivate + + + {{ errorCode }} diff --git a/src/app/register2/components/backend-error/backend-error.component.scss b/src/app/register2/components/backend-error/backend-error.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/register2/components/backend-error/backend-error.component.spec.ts b/src/app/register2/components/backend-error/backend-error.component.spec.ts new file mode 100644 index 0000000000..444341d5e4 --- /dev/null +++ b/src/app/register2/components/backend-error/backend-error.component.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { BackendErrorComponent } from './backend-error.component' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { RouterTestingModule } from '@angular/router/testing' +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog' +import { WINDOW_PROVIDERS } from '../../../cdk/window' +import { PlatformInfoService } from '../../../cdk/platform-info' +import { ErrorHandlerService } from '../../../core/error-handler/error-handler.service' +import { SnackbarService } from '../../../cdk/snackbar/snackbar.service' +import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar' +import { Overlay } from '@angular/cdk/overlay' +import { SignInService } from '../../../core/sign-in/sign-in.service' + +describe('BackendErrorComponent', () => { + let component: BackendErrorComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule], + declarations: [BackendErrorComponent], + providers: [ + WINDOW_PROVIDERS, + SignInService, + PlatformInfoService, + ErrorHandlerService, + SnackbarService, + MatSnackBar, + MatDialog, + Overlay, + ], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(BackendErrorComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/backend-error/backend-error.component.ts b/src/app/register2/components/backend-error/backend-error.component.ts new file mode 100644 index 0000000000..6e36e49331 --- /dev/null +++ b/src/app/register2/components/backend-error/backend-error.component.ts @@ -0,0 +1,100 @@ +import { Component, Input, OnInit, Inject } from '@angular/core' +import { Router } from '@angular/router' +import { take } from 'rxjs/operators' +import { PlatformInfoService } from 'src/app/cdk/platform-info' +import { SnackbarService } from 'src/app/cdk/snackbar/snackbar.service' +import { ApplicationRoutes } from 'src/app/constants' +import { ErrorHandlerService } from 'src/app/core/error-handler/error-handler.service' +import { SignInService } from 'src/app/core/sign-in/sign-in.service' +import { ERROR_REPORT } from 'src/app/errors' +import { WINDOW } from 'src/app/cdk/window' + +// When the error text is not listed on the RegisterBackendErrors enum +// the error message will be displayed as it comes from the backend +// This is because the backend might return code or a text ready for the UI +enum RegisterBackendErrors { + 'orcid.frontend.verify.duplicate_email', + 'additionalEmailCantBePrimaryEmail', + 'duplicatedAdditionalEmail', + 'orcid.frontend.verify.unclaimed_email', + 'orcid.frontend.verify.deactivated_email', +} + +@Component({ + selector: 'app-backend-error', + templateUrl: './backend-error.component.html', + styleUrls: ['./backend-error.component.scss'], + preserveWhitespaces: true, +}) +export class BackendErrorComponent implements OnInit { + recognizedError = RegisterBackendErrors + _errorCode: string + @Input() + set errorCode(errorCode: string) { + // This will change the string send by the backend into a code, to handle the error trough a code + if (errorCode.indexOf('resend-claim') >= 0) { + errorCode = RegisterBackendErrors[3] + } + this._errorCode = errorCode + } + get errorCode() { + return this._errorCode + } + @Input() value?: string + unrecognizedError = false + constructor( + private _platformInfo: PlatformInfoService, + private _router: Router, + private _snackbar: SnackbarService, + private _signIn: SignInService, + private _errorHandler: ErrorHandlerService, + @Inject(WINDOW) private window: Window + ) {} + ngOnInit() { + if (!(this.errorCode in RegisterBackendErrors)) { + this.unrecognizedError = true + } + } + + navigateToClaim(email) { + email = encodeURIComponent(email) + this.window.location.href = `/resend-claim?email=${email}` + } + + navigateToSignin(email) { + this._platformInfo + .get() + .pipe(take(1)) + .subscribe((platform) => { + return this._router.navigate([ApplicationRoutes.signin], { + // keeps all parameters to support Oauth request + // and set show login to true + queryParams: { ...platform.queryParameters, email, show_login: true }, + }) + }) + } + + reactivateEmail(email) { + const $deactivate = this._signIn.reactivation(email) + $deactivate.subscribe((data) => { + if (data.error) { + this._errorHandler + .handleError( + new Error(data.error), + ERROR_REPORT.REGISTER_REACTIVATED_EMAIL + ) + .subscribe() + } else { + this._snackbar.showSuccessMessage({ + title: $localize`:@@register.reactivating:Reactivating your account`, + // tslint:disable-next-line: max-line-length + message: $localize`:@@ngOrcid.signin.verify.reactivationSent:Thank you for reactivating your ORCID record; please complete the process by following the steps in the email we are now sending you. If you don’t receive an email from us, please`, + action: $localize`:@@shared.contactSupport:contact support.`, + actionURL: `https://support.orcid.org/`, + closable: true, + }) + this._router.navigate([ApplicationRoutes.signin]) + } + }) + } +} diff --git a/src/app/register2/components/form-anti-robots/form-anti-robots.component.html b/src/app/register2/components/form-anti-robots/form-anti-robots.component.html new file mode 100644 index 0000000000..ca9d1ebcd1 --- /dev/null +++ b/src/app/register2/components/form-anti-robots/form-anti-robots.component.html @@ -0,0 +1,17 @@ + + Please check the recaptcha box + +
+
diff --git a/src/app/register2/components/form-anti-robots/form-anti-robots.component.scss b/src/app/register2/components/form-anti-robots/form-anti-robots.component.scss new file mode 100644 index 0000000000..caf102980c --- /dev/null +++ b/src/app/register2/components/form-anti-robots/form-anti-robots.component.scss @@ -0,0 +1,12 @@ +:host { + ::ng-deep { + div > div { + width: 100% !important; + } + } +} + +mat-error { + margin: 8px 0; + display: block; +} diff --git a/src/app/register2/components/form-anti-robots/form-anti-robots.component.spec.ts b/src/app/register2/components/form-anti-robots/form-anti-robots.component.spec.ts new file mode 100644 index 0000000000..ee7286cf9d --- /dev/null +++ b/src/app/register2/components/form-anti-robots/form-anti-robots.component.spec.ts @@ -0,0 +1,47 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { Overlay } from '@angular/cdk/overlay' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { ErrorStateMatcher } from '@angular/material/core' +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog' +import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar' +import { RouterTestingModule } from '@angular/router/testing' +import { PlatformInfoService } from '../../../cdk/platform-info' +import { SnackbarService } from '../../../cdk/snackbar/snackbar.service' +import { WINDOW_PROVIDERS } from '../../../cdk/window' +import { ErrorHandlerService } from '../../../core/error-handler/error-handler.service' +import { Register2Service } from '../../../core/register2/register2.service' +import { FormAntiRobotsComponent } from './form-anti-robots.component' + +describe('FormAntiRobotsComponent', () => { + let component: FormAntiRobotsComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule], + declarations: [FormAntiRobotsComponent], + providers: [ + WINDOW_PROVIDERS, + Register2Service, + ErrorStateMatcher, + PlatformInfoService, + ErrorHandlerService, + SnackbarService, + MatSnackBar, + MatDialog, + Overlay, + ], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(FormAntiRobotsComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/form-anti-robots/form-anti-robots.component.ts b/src/app/register2/components/form-anti-robots/form-anti-robots.component.ts new file mode 100644 index 0000000000..8e4deb5254 --- /dev/null +++ b/src/app/register2/components/form-anti-robots/form-anti-robots.component.ts @@ -0,0 +1,93 @@ +import { Component, DoCheck, forwardRef, Input, OnInit } from '@angular/core' +import { + NG_ASYNC_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormControl, + UntypedFormGroup, + ValidatorFn, + Validators, +} from '@angular/forms' +import { ErrorStateMatcher } from '@angular/material/core' +import { merge, Subject } from 'rxjs' +import { Register2Service } from 'src/app/core/register2/register2.service' + +import { BaseForm } from '../BaseForm' + +@Component({ + selector: 'app-form-anti-robots', + templateUrl: './form-anti-robots.component.html', + styleUrls: ['./form-anti-robots.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FormAntiRobotsComponent), + multi: true, + }, + { + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => FormAntiRobotsComponent), + multi: true, + }, + ], +}) +export class FormAntiRobotsComponent extends BaseForm implements OnInit { + @Input() nextButtonWasClicked + captchaFailState = false + captchaLoadedWithWidgetId: number + $widgetIdUpdated = new Subject() + errorState = false + captcha = new UntypedFormControl(null, { + validators: [this.captchaValidator()], + }) + ngOnInit(): void { + this.form = new UntypedFormGroup({ + captcha: this.captcha, + }) + } + + constructor( + private _register: Register2Service, + private _errorStateMatcher: ErrorStateMatcher + ) { + super() + } + + // Captcha must be clicked unless it was not loaded + captchaValidator(): ValidatorFn { + return (control: UntypedFormControl) => { + const hasError = Validators.required(control) + if ( + hasError && + hasError.required && + this.captchaLoadedWithWidgetId !== undefined + ) { + return { captcha: true } + } else { + return null + } + } + } + + captchaFail($event) { + this.captchaFailState = $event + } + captchaLoaded(widgetId: number) { + this.captchaLoadedWithWidgetId = widgetId + this.captcha.updateValueAndValidity() + this.$widgetIdUpdated.next() + } + + // OVERWRITE + registerOnChange(fn: any) { + merge( + this.$widgetIdUpdated.asObservable(), + this.form.valueChanges + ).subscribe(() => { + const registerForm = this._register.formGroupToRecaptchaForm( + this.form, + this.captchaLoadedWithWidgetId + ) + fn(registerForm) + }) + } +} diff --git a/src/app/register2/components/form-notifications/form-notifications.component.html b/src/app/register2/components/form-notifications/form-notifications.component.html new file mode 100644 index 0000000000..acd08e0f1e --- /dev/null +++ b/src/app/register2/components/form-notifications/form-notifications.component.html @@ -0,0 +1,19 @@ +
+

+ Tips & features email +

+

+ We occasionally send out an email with information on new features and tips + for getting the best out of your ORCID record. +

+ + +
I’d like to receive the ORCID tips & features email +
+
+
diff --git a/src/app/register2/components/form-notifications/form-notifications.component.scss b/src/app/register2/components/form-notifications/form-notifications.component.scss new file mode 100644 index 0000000000..58207ec0a4 --- /dev/null +++ b/src/app/register2/components/form-notifications/form-notifications.component.scss @@ -0,0 +1,3 @@ +:host { + max-width: 100%; +} diff --git a/src/app/register2/components/form-notifications/form-notifications.component.spec.ts b/src/app/register2/components/form-notifications/form-notifications.component.spec.ts new file mode 100644 index 0000000000..4489db6ff2 --- /dev/null +++ b/src/app/register2/components/form-notifications/form-notifications.component.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { Overlay } from '@angular/cdk/overlay' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog' +import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar' +import { RouterTestingModule } from '@angular/router/testing' +import { PlatformInfoService } from '../../../cdk/platform-info' +import { SnackbarService } from '../../../cdk/snackbar/snackbar.service' +import { WINDOW_PROVIDERS } from '../../../cdk/window' +import { ErrorHandlerService } from '../../../core/error-handler/error-handler.service' +import { Register2Service } from '../../../core/register2/register2.service' +import { FormNotificationsComponent } from './form-notifications.component' + +describe('FormNotificationsComponent', () => { + let component: FormNotificationsComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule], + declarations: [FormNotificationsComponent], + providers: [ + WINDOW_PROVIDERS, + Register2Service, + PlatformInfoService, + ErrorHandlerService, + SnackbarService, + MatSnackBar, + MatDialog, + Overlay, + ], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(FormNotificationsComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/form-notifications/form-notifications.component.ts b/src/app/register2/components/form-notifications/form-notifications.component.ts new file mode 100644 index 0000000000..edbfad6e43 --- /dev/null +++ b/src/app/register2/components/form-notifications/form-notifications.component.ts @@ -0,0 +1,55 @@ +import { Component, forwardRef, OnInit } from '@angular/core' +import { + NG_ASYNC_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms' + +import { Register2Service } from 'src/app/core/register2/register2.service' +import { BaseForm } from '../BaseForm' + +@Component({ + selector: 'app-form-notifications', + templateUrl: './form-notifications.component.html', + styleUrls: [ + './form-notifications.component.scss', + '../register2.style.scss', + '../register2.scss-theme.scss', + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FormNotificationsComponent), + multi: true, + }, + { + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => FormNotificationsComponent), + multi: true, + }, + ], +}) +export class FormNotificationsComponent extends BaseForm implements OnInit { + constructor(private _register: Register2Service) { + super() + } + ngOnInit() { + this.form = new UntypedFormGroup({ + sendOrcidNews: new UntypedFormControl(false, { + validators: Validators.required, + }), + }) + } + + // OVERWRITE + registerOnChange(fn: any) { + this.form.valueChanges.subscribe((value) => { + const registerForm = this._register.formGroupToSendOrcidNewsForm( + this.form as UntypedFormGroup + ) + fn(registerForm) + }) + } +} diff --git a/src/app/register2/components/form-password/form-password.component.html b/src/app/register2/components/form-password/form-password.component.html new file mode 100644 index 0000000000..29eaee5067 --- /dev/null +++ b/src/app/register2/components/form-password/form-password.component.html @@ -0,0 +1,157 @@ +
+

+ Your password +

+
+ Password + + + done + + + + A password is required + + + Password must not be the same as your email address + + + Password must meet all requirements + + + Password must meet be between 8 and 256 characters + + +
+ +
+
+
+ +
+ + done + + + + Retype your password + + + Password must not be the same as your email address + + + + Passwords do not match + +
+ +

Your password has:

+
    +
  1. + +
    8 or more characters
    +
  2. +
  3. + +
    + At least 1 letter or symbol +
    +
  4. +
  5. + +
    At least 1 number
    +
  6. +
+
+ + + check_circle + + + + radio_button_unchecked + + diff --git a/src/app/register2/components/form-password/form-password.component.scss b/src/app/register2/components/form-password/form-password.component.scss new file mode 100644 index 0000000000..43f5ce104c --- /dev/null +++ b/src/app/register2/components/form-password/form-password.component.scss @@ -0,0 +1,25 @@ +:host { + display: flex; + flex-direction: column; +} + +ol { + margin-block-start: 0px; + padding-inline-start: 0px; + padding: 0; + li { + list-style-type: none; + display: flex; + padding-top: 14px; + img { + margin-inline-end: 4px; + } + div { + margin: 2px; + } + } +} + +mat-icon { + margin-inline-end: 8px; +} diff --git a/src/app/register2/components/form-password/form-password.component.scss-theme.scss b/src/app/register2/components/form-password/form-password.component.scss-theme.scss new file mode 100644 index 0000000000..fd6b070566 --- /dev/null +++ b/src/app/register2/components/form-password/form-password.component.scss-theme.scss @@ -0,0 +1,20 @@ +@use '@angular/material' as mat; +@import 'src/assets/scss/material.orcid-theme.scss'; + +@mixin form-password($theme) { + $primary: map-get($theme, primary); + $accent: map-get($theme, accent); + $warn: map-get($theme, accent); + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + + ::ng-deep .mat-icon.valid { + color: map-get($foreground, 'brand-primary-dark'); + } + + ::ng-deep .mat-icon { + color: map-get($background, 'ui-background-light'); + } +} + +@include form-password($orcid-app-theme); diff --git a/src/app/register2/components/form-password/form-password.component.spec.ts b/src/app/register2/components/form-password/form-password.component.spec.ts new file mode 100644 index 0000000000..8fb75fe8d3 --- /dev/null +++ b/src/app/register2/components/form-password/form-password.component.spec.ts @@ -0,0 +1,52 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { Overlay } from '@angular/cdk/overlay' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { + MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA, + MatLegacyDialog as MatDialog, + MatLegacyDialogRef as MatDialogRef, +} from '@angular/material/legacy-dialog' +import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar' +import { RouterTestingModule } from '@angular/router/testing' +import { PlatformInfoService } from '../../../cdk/platform-info' +import { MdePopoverModule } from '../../../cdk/popover' +import { SnackbarService } from '../../../cdk/snackbar/snackbar.service' +import { WINDOW_PROVIDERS } from '../../../cdk/window' +import { ErrorHandlerService } from '../../../core/error-handler/error-handler.service' +import { Register2Service } from '../../../core/register2/register2.service' +import { FormPasswordComponent } from './form-password.component' + +describe('FormPasswordComponent', () => { + let component: FormPasswordComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, MdePopoverModule, RouterTestingModule], + declarations: [FormPasswordComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { provide: MAT_DIALOG_DATA, useValue: {} }, + WINDOW_PROVIDERS, + Register2Service, + PlatformInfoService, + ErrorHandlerService, + SnackbarService, + MatSnackBar, + MatDialog, + Overlay, + ], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(FormPasswordComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/form-password/form-password.component.ts b/src/app/register2/components/form-password/form-password.component.ts new file mode 100644 index 0000000000..5550e699c0 --- /dev/null +++ b/src/app/register2/components/form-password/form-password.component.ts @@ -0,0 +1,201 @@ +import { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core' +import { + NG_ASYNC_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormControl, + UntypedFormGroup, + ValidatorFn, + Validators, +} from '@angular/forms' +import { HAS_LETTER_OR_SYMBOL, HAS_NUMBER } from 'src/app/constants' +import { Register2Service } from 'src/app/core/register2/register2.service' +import { RegisterForm } from 'src/app/types/register.endpoint' +import { OrcidValidators } from 'src/app/validators' + +import { BaseForm } from '../BaseForm' +import { LiveAnnouncer } from '@angular/cdk/a11y' +import { environment } from 'src/environments/environment' + +@Component({ + selector: 'app-form-password', + templateUrl: './form-password.component.html', + styleUrls: [ + './form-password.component.scss-theme.scss', + './form-password.component.scss', + '../register2.scss-theme.scss', + '../register2.style.scss', + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FormPasswordComponent), + multi: true, + }, + { + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => FormPasswordComponent), + multi: true, + }, + ], + preserveWhitespaces: true, +}) +export class FormPasswordComponent extends BaseForm implements OnInit { + labelInfo = $localize`:@@register.ariaLabelInfoPassword:info about password` + labelClose = $localize`:@@register.ariaLabelClose:close` + labelConfirmPassword = $localize`:@@register.confirmPassword:Confirm password` + @ViewChild(`#passwordPopover`) passwordPopover + @ViewChild(`#passwordPopoverTrigger`) passwordPopoverTrigger + hasNumberPattern = HAS_NUMBER + hasLetterOrSymbolPattern = HAS_LETTER_OR_SYMBOL + @Input() personalData: RegisterForm + @Input() nextButtonWasClicked: boolean + currentValidate8orMoreCharactersStatus: boolean + ccurentValidateAtLeastALetterOrSymbolStatus: boolean + currentValidateAtLeastANumber: boolean + constructor( + private _register: Register2Service, + private _liveAnnouncer: LiveAnnouncer + ) { + super() + } + ngOnInit() { + this.form = new UntypedFormGroup( + { + password: new UntypedFormControl('', { + validators: [ + Validators.required, + Validators.minLength(8), + Validators.maxLength(256), + Validators.pattern(this.hasNumberPattern), + Validators.pattern(this.hasLetterOrSymbolPattern), + this.passwordDoesNotContainUserEmails(), + ], + asyncValidators: [this._register.backendValueValidate('password')], + }), + passwordConfirm: new UntypedFormControl('', Validators.required), + }, + { + validators: OrcidValidators.matchValues('password', 'passwordConfirm'), + asyncValidators: this._register.backendPasswordValidate(), + } + ) + } + + passwordDoesNotContainUserEmails(): ValidatorFn { + return (control: UntypedFormControl) => { + const password: string = control.value + let hasError = false + + if (this.personalData && password) { + Object.keys(this.personalData.emailsAdditional).forEach((key) => { + const additionalEmail = this.personalData.emailsAdditional[key].value + if (password.indexOf(additionalEmail) >= 0) { + hasError = true + } + }) + } + + if ( + this.personalData && + this.personalData.email && + password.indexOf(this.personalData.email.value) >= 0 + ) { + hasError = true + } + + if (hasError) { + return { passwordIsEqualToTheEmail: true } + } else { + return null + } + } + } + + // OVERWRITE + registerOnChange(fn: any) { + this.form.valueChanges.subscribe((value) => { + const registerForm = this._register.formGroupToPasswordRegisterForm( + this.form as UntypedFormGroup + ) + + fn(registerForm) + }) + } + + get confirmPasswordTouched() { + return ( + this.form.controls['passwordConfirm'].touched || this.nextButtonWasClicked + ) + } + get passwordTouched() { + return this.form.controls['password'].touched || this.nextButtonWasClicked + } + + get confirmPasswordValid() { + return this.form.controls['passwordConfirm'].valid + } + get passwordValid() { + return this.form.controls['password'].valid + } + + get validate8orMoreCharacters() { + const status = + this.form.hasError('required', 'password') || + this.form.hasError('minlength', 'password') + + if (this.currentValidate8orMoreCharactersStatus !== status) { + this.announce( + status + ? $localize`:@@register.passwordLengthError:Password must be 8 or more characters` + : $localize`:@@register.passwordLengthOk:Password is 8 or more characters` + ) + } + this.currentValidate8orMoreCharactersStatus = status + + return status + } + + get validateAtLeastALetterOrSymbol() { + const status = + this.form.hasError('required', 'password') || + this.form.getError('pattern', 'password')?.requiredPattern == + this.hasLetterOrSymbolPattern + + if (this.ccurentValidateAtLeastALetterOrSymbolStatus !== status) { + this.announce( + status + ? $localize`:@@register.passwordLetterOrSymbolError:Password must contain at least a letter or symbol` + : $localize`:@@register.passwordLetterOrSymbolOk:Password contains at least a letter or symbol` + ) + } + this.ccurentValidateAtLeastALetterOrSymbolStatus = status + + return status + } + + get validateAtLeastANumber() { + const status = + this.form.hasError('required', 'password') || + this.form.getError('pattern', 'password')?.requiredPattern == + this.hasNumberPattern + + if (this.currentValidateAtLeastANumber !== status) { + this.announce( + status + ? $localize`:@@register.passwordNumberError:Password must contain at least a number` + : $localize`:@@register.passwordNumberOk:Password contains at least a number` + ) + } + + this.currentValidateAtLeastANumber = status + + return status + } + + private announce(announcement: string) { + if (environment.debugger) { + console.debug('📢' + announcement) + } + this._liveAnnouncer.announce(announcement, 'assertive') + } +} diff --git a/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.html b/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.html new file mode 100644 index 0000000000..261b582c61 --- /dev/null +++ b/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.html @@ -0,0 +1,106 @@ + +
+ + Additional email + {{ i !== 0 ? i : '' }} + + + + + + + Invalid email format + + + + + + + +
+
+ + + diff --git a/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.scss b/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.scss new file mode 100644 index 0000000000..c0ebe826ac --- /dev/null +++ b/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.scss @@ -0,0 +1,11 @@ +:host { + display: flex; + flex-direction: column; +} + +a { + display: flex; + span { + margin: 2px; + } +} diff --git a/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.spec.ts b/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.spec.ts new file mode 100644 index 0000000000..f9b8f371b4 --- /dev/null +++ b/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { FormPersonalAdditionalEmailsComponent } from './form-personal-additional-emails.component' +import { MdePopoverModule } from '../../../cdk/popover' + +describe('FormPersonalAdditionalEmailsComponent', () => { + let component: FormPersonalAdditionalEmailsComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [MdePopoverModule], + declarations: [FormPersonalAdditionalEmailsComponent], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(FormPersonalAdditionalEmailsComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.ts b/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.ts new file mode 100644 index 0000000000..85784289b0 --- /dev/null +++ b/src/app/register2/components/form-personal-additional-emails/form-personal-additional-emails.component.ts @@ -0,0 +1,99 @@ +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + Input, + QueryList, + ViewChildren, +} from '@angular/core' +import { + AbstractControl, + UntypedFormControl, + UntypedFormGroup, +} from '@angular/forms' +import { OrcidValidators } from 'src/app/validators' + +import { ErrorStateMatcherForFormLevelErrors } from '../../ErrorStateMatcherForFormLevelErrors' + +@Component({ + selector: 'app-form-personal-additional-emails', + templateUrl: './form-personal-additional-emails.component.html', + styleUrls: [ + './form-personal-additional-emails.component.scss', + '../register2.style.scss', + ], +}) +export class FormPersonalAdditionalEmailsComponent implements AfterViewInit { + labelInfoAboutEmails = $localize`:@@register.ariaLabelInfoEmails:info about emails` + labelDeleteEmail = $localize`:@@register.ariaLabelDeleteEmail:delete email` + labelClose = $localize`:@@register.ariaLabelClose:close` + labelAddAnAddionalEmail = $localize`:@@register.addAnAdditionalEmail:Add an additional email` + @ViewChildren('emailInput') inputs: QueryList + @Input() additionalEmails: UntypedFormGroup + additionalEmailsPopoverTrigger + additionalEmailsCount = 1 + + constructor( + private _ref: ChangeDetectorRef, + private _changeDetectorRef: ChangeDetectorRef + ) {} + + backendErrorsMatcher = new ErrorStateMatcherForFormLevelErrors( + this.getControlErrorAtFormLevel, + 'backendErrors' + ) + + getControlErrorAtFormLevel( + control: AbstractControl | null, + errorGroup: string + ): string[] { + return ( + control?.parent?.parent?.errors?.[errorGroup]?.['additionalEmails'][ + control.value + ] || [] + ) + } + + // deleteEmailInput(id: string): void { + // this.additionalEmails.removeControl(id) + // this._changeDetectorRef.detectChanges() + + // const input = this.inputs.filter( + // (x) => this.parseInt(x.nativeElement.id) > this.parseInt(id) + // )?.[0] + // if (input) { + // input.nativeElement.focus() + // } else if (this.inputs.last) { + // this.inputs.last.nativeElement.focus() + // } + // } + + // addAdditionalEmail(): void { + // const controlName = ++this.additionalEmailsCount + // this.additionalEmails.addControl( + // this.zeroPad(controlName, 2), + // new UntypedFormControl('', { + // validators: [OrcidValidators.email], + // }) + // ) + // this._changeDetectorRef.detectChanges() + // const input = this.inputs.last.nativeElement as HTMLInputElement + // input.focus() + // } + + parseInt(number: string) { + return parseInt(number, 10) + } + + zeroPad(num, places) { + return String(num).padStart(places, '0') + } + + public ngAfterViewInit() { + this._ref.detectChanges() + } + get additionalEmailsTouched() { + return this.additionalEmails.touched + } +} diff --git a/src/app/register2/components/form-personal/form-personal.component.html b/src/app/register2/components/form-personal/form-personal.component.html new file mode 100644 index 0000000000..6b3aa05109 --- /dev/null +++ b/src/app/register2/components/form-personal/form-personal.component.html @@ -0,0 +1,363 @@ + +

+ Your names +

+ +
+ Given names + + + + + + Please enter your given names + + + Invalid name characters or format + + +
+ +
+
+
+
+ Family names + + + + + + Invalid name characters or format + + +
+ +
+
+
+ + + +

+ Your email addresses +

+ +
+ Email + + + done + + + + + Please enter your email + + + Please enter a valid email address, for example joe@institution.edu + + + +
+ +
+
+
+ + +
+
+ error +
+
+
+

+ + The email address + + {{ emails.get('email').value }} + is associated with an existing ORCID record. + +

+
+ + +
+
+
+ + + + +
+ + done + + + + + Please confirm your email address + + + Invalid email format + + + Email addresses do not match + + +
+ +
+
+
+ + +
+
+ +
+
+
+

+ This looks like a personal email +

+
+
+ Add a professional email + + as backup so we can better recommend affiliations and other + related data to you. + +
+
+
+
+ + +
+
+ +
+
+
+

+ Add another email to secure your account +

+
+
+ Adding an additional email as backup helps secure your account and + make sure you can always sign in. +
+
+
+
+ + +
+
+ +
+
+
+

+ This looks like a professional email +

+
+
+ We recommend adding a personal email + + as backup so you always have access to your ORCID account if you + change jobs or roles. +
+
+
+
+ + + +
+
+ + + +

+ First name is your given name or the name you most commonly go by. +

+

Last name is your family name.

+

+ You will have a chance to add additional names after you have created your + account. +

+ More information on names +
+
diff --git a/src/app/register2/components/form-personal/form-personal.component.scss b/src/app/register2/components/form-personal/form-personal.component.scss new file mode 100644 index 0000000000..4cef8a0ce4 --- /dev/null +++ b/src/app/register2/components/form-personal/form-personal.component.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + flex-direction: column; +} diff --git a/src/app/register2/components/form-personal/form-personal.component.spec.ts b/src/app/register2/components/form-personal/form-personal.component.spec.ts new file mode 100644 index 0000000000..50fd107f33 --- /dev/null +++ b/src/app/register2/components/form-personal/form-personal.component.spec.ts @@ -0,0 +1,48 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { Overlay } from '@angular/cdk/overlay' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog' +import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar' +import { RouterTestingModule } from '@angular/router/testing' +import { PlatformInfoService } from '../../../cdk/platform-info' +import { MdePopoverModule } from '../../../cdk/popover' +import { SnackbarService } from '../../../cdk/snackbar/snackbar.service' +import { WINDOW_PROVIDERS } from '../../../cdk/window' +import { ErrorHandlerService } from '../../../core/error-handler/error-handler.service' +import { ReactivationService } from '../../../core/reactivation/reactivation.service' +import { Register2Service } from '../../../core/register2/register2.service' +import { FormPersonalComponent } from './form-personal.component' + +describe('FormPersonalComponent', () => { + let component: FormPersonalComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, MdePopoverModule, RouterTestingModule], + declarations: [FormPersonalComponent], + providers: [ + WINDOW_PROVIDERS, + ReactivationService, + Register2Service, + PlatformInfoService, + ErrorHandlerService, + SnackbarService, + MatSnackBar, + MatDialog, + Overlay, + ], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(FormPersonalComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/form-personal/form-personal.component.ts b/src/app/register2/components/form-personal/form-personal.component.ts new file mode 100644 index 0000000000..b088be0e12 --- /dev/null +++ b/src/app/register2/components/form-personal/form-personal.component.ts @@ -0,0 +1,265 @@ +import { + AfterViewInit, + Component, + ElementRef, + forwardRef, + Input, + OnInit, + ViewChild, +} from '@angular/core' +import { + FormControl, + FormGroupDirective, + NG_ASYNC_VALIDATORS, + NG_VALUE_ACCESSOR, + NgForm, + UntypedFormControl, + UntypedFormGroup, + ValidatorFn, + Validators, +} from '@angular/forms' +import { Register2Service } from 'src/app/core/register2/register2.service' +import { OrcidValidators } from 'src/app/validators' + +import { + debounce, + debounceTime, + filter, + first, + startWith, + switchMap, +} from 'rxjs/operators' +import { ReactivationService } from '../../../core/reactivation/reactivation.service' +import { ReactivationLocal } from '../../../types/reactivation.local' +import { BaseForm } from '../BaseForm' +import { ErrorStateMatcher } from '@angular/material/core' +export class MyErrorStateMatcher implements ErrorStateMatcher { + isErrorState( + control: FormControl | null, + form: FormGroupDirective | NgForm | null + ): boolean { + const isSubmitted = form && form.submitted + return !!( + control && + control.invalid && + (control.dirty || control.touched || isSubmitted) + ) + } +} + +@Component({ + selector: 'app-form-personal', + templateUrl: './form-personal.component.html', + styleUrls: [ + './form-personal.component.scss', + '../register2.style.scss', + '../register2.scss-theme.scss', + ], + preserveWhitespaces: true, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FormPersonalComponent), + multi: true, + }, + { + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => FormPersonalComponent), + multi: true, + }, + ], +}) +export class FormPersonalComponent extends BaseForm implements OnInit { + matcher = new MyErrorStateMatcher() + @Input() nextButtonWasClicked: boolean + @Input() reactivation: ReactivationLocal + @ViewChild(FormGroupDirective) formGroupDir: FormGroupDirective + + arialabelConfirmEmail = $localize`:@@register.labelConfirmEmail:Confirm your email address` + labelInfoAboutName = $localize`:@@register.ariaLabelInfo:info about names` + labelClose = $localize`:@@register.ariaLabelClose:close` + labelConfirmEmail = $localize`:@@register.confirmEmail:Confirm primary email` + labelNameYouMostCommonly = $localize`:@@register.labelNameYouMostMost:The name you most commonly go by` + labelFamilyNamePlaceholder = $localize`:@@register.familyNamePlaceholder:Your family name or surname + ` + professionalEmail: boolean + personalEmail: boolean + undefinedEmail: boolean + constructor( + private _register: Register2Service, + private _reactivationService: ReactivationService + ) { + super() + } + + emails: UntypedFormGroup = new UntypedFormGroup({}) + additionalEmails: UntypedFormGroup = new UntypedFormGroup({ + '0': new UntypedFormControl('', { + validators: [OrcidValidators.email], + }), + }) + + ngOnInit() { + this.emails = new UntypedFormGroup( + { + email: new UntypedFormControl('', { + validators: [Validators.required, OrcidValidators.email], + asyncValidators: this._register.backendValueValidate('email'), + }), + additionalEmails: this.additionalEmails, + }, + { + validators: [ + OrcidValidators.matchValues('email', 'confirmEmail', false), + this.allEmailsAreUnique(), + ], + asyncValidators: [ + this._register.backendAdditionalEmailsValidate( + this.reactivation?.isReactivation + ), + ], + updateOn: 'change', + } + ) + + this.emails.controls['email'].valueChanges + .pipe( + debounceTime(1000), + filter(() => !this.emails.controls['email'].errors), + switchMap((value) => { + const emailDomain = value.split('@')[1] + return this._register.getEmailCategory(emailDomain) + }) + ) + .subscribe((value) => { + this.professionalEmail = value.category === 'PROFESSIONAL' + this.personalEmail = value.category === 'PERSONAL' + this.undefinedEmail = value.category === 'UNDEFINED' + }) + + if (!this.reactivation?.isReactivation) { + this.emails.addControl( + 'confirmEmail', + new UntypedFormControl('', { + validators: [Validators.required, OrcidValidators.email], + }) + ) + } + + this.form = new UntypedFormGroup({ + givenNames: new UntypedFormControl('', { + validators: [Validators.required, OrcidValidators.illegalName], + asyncValidators: this._register.backendValueValidate('givenNames'), + }), + familyNames: new UntypedFormControl('', { + validators: [OrcidValidators.illegalName], + }), + emails: this.emails, + }) + + if (this.reactivation?.isReactivation) { + this._reactivationService + .getReactivationData(this.reactivation.reactivationCode) + .pipe(first()) + .subscribe((reactivation) => { + this.emails.patchValue({ + email: reactivation.email, + }) + this.emails.controls['email'].disable() + }) + } + } + + allEmailsAreUnique(): ValidatorFn { + return (formGroup: UntypedFormGroup) => { + let hasError = false + const registerForm = + this._register.formGroupToEmailRegisterForm(formGroup) + + const error = { backendErrors: { additionalEmails: {} } } + + Object.keys(registerForm.emailsAdditional).forEach((key, i) => { + const additionalEmail = registerForm.emailsAdditional[key] + if (!error.backendErrors.additionalEmails[additionalEmail.value]) { + error.backendErrors.additionalEmails[additionalEmail.value] = [] + } + const additionalEmailsErrors = error.backendErrors.additionalEmails + if ( + registerForm.email && + additionalEmail.value === registerForm.email.value + ) { + hasError = true + additionalEmailsErrors[additionalEmail.value] = [ + 'additionalEmailCantBePrimaryEmail', + ] + } else { + Object.keys(registerForm.emailsAdditional).forEach( + (elementKey, i2) => { + const element = registerForm.emailsAdditional[elementKey] + if (i !== i2 && additionalEmail.value === element.value) { + hasError = true + additionalEmailsErrors[additionalEmail.value] = [ + 'duplicatedAdditionalEmail', + ] + } + } + ) + } + }) + + if (hasError) { + return error + } else { + return null + } + } + } + + // OVERWRITE + registerOnChange(fn: any) { + this.form.valueChanges.subscribe((value) => { + const emailsForm = this._register.formGroupToEmailRegisterForm( + this.form.controls['emails'] as UntypedFormGroup + ) + const namesForm = + this._register.formGroupToNamesRegisterForm(this.form) || {} + + fn({ ...emailsForm, ...namesForm }) + }) + } + + get emailFormTouched() { + return ( + ((this.form.controls.emails as any).controls?.email as any)?.touched || + this.nextButtonWasClicked + ) + } + + get emailConfirmationFormTouched() { + return ( + ((this.form.controls.emails as any).controls?.confirmEmail as any) + ?.touched || this.nextButtonWasClicked + ) + } + + get familyNamesFormTouched() { + return this.form.controls.familyNames?.touched || this.nextButtonWasClicked + } + + get emailValid() { + return ((this.form.controls.emails as any).controls?.email as any).valid + } + + get emailConfirmationValid() { + return ((this.form.controls.emails as any).controls?.confirmEmail as any) + .valid + } + + get givenNameFormTouched() { + return this.form.controls.givenNames?.touched || this.nextButtonWasClicked + } + + get emailsAreValid() { + return this.emailConfirmationValid && this.emailValid + } +} diff --git a/src/app/register2/components/form-terms/form-terms.component.html b/src/app/register2/components/form-terms/form-terms.component.html new file mode 100644 index 0000000000..9869e20092 --- /dev/null +++ b/src/app/register2/components/form-terms/form-terms.component.html @@ -0,0 +1,66 @@ +

Terms of Use

+You must accept the terms we use and consent to your data being processed in + the United States + + + I consent to the + + privacy policy + + and + + terms of use + + and agree to my data being publicly accessible where marked as “Visible + to Everyone”. + + + + I consent to my data being processed in the United States. + + + More information on how ORCID process your data. + + + diff --git a/src/app/register2/components/form-terms/form-terms.component.scss b/src/app/register2/components/form-terms/form-terms.component.scss new file mode 100644 index 0000000000..d54b46deff --- /dev/null +++ b/src/app/register2/components/form-terms/form-terms.component.scss @@ -0,0 +1,12 @@ +:host { + display: block; +} + +mat-checkbox { + margin-bottom: 16px; +} + +mat-error { + margin: 8px 0; + display: block; +} diff --git a/src/app/register2/components/form-terms/form-terms.component.spec.ts b/src/app/register2/components/form-terms/form-terms.component.spec.ts new file mode 100644 index 0000000000..f80a36cf46 --- /dev/null +++ b/src/app/register2/components/form-terms/form-terms.component.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { Overlay } from '@angular/cdk/overlay' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog' +import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar' +import { RouterTestingModule } from '@angular/router/testing' +import { PlatformInfoService } from '../../../cdk/platform-info' +import { SnackbarService } from '../../../cdk/snackbar/snackbar.service' +import { WINDOW_PROVIDERS } from '../../../cdk/window' +import { ErrorHandlerService } from '../../../core/error-handler/error-handler.service' +import { Register2Service } from '../../../core/register2/register2.service' +import { FormTermsComponent } from './form-terms.component' + +describe('FormTermsComponent', () => { + let component: FormTermsComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule], + declarations: [FormTermsComponent], + providers: [ + WINDOW_PROVIDERS, + Register2Service, + PlatformInfoService, + ErrorHandlerService, + SnackbarService, + MatSnackBar, + MatDialog, + Overlay, + ], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(FormTermsComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/form-terms/form-terms.component.ts b/src/app/register2/components/form-terms/form-terms.component.ts new file mode 100644 index 0000000000..307bbcafdd --- /dev/null +++ b/src/app/register2/components/form-terms/form-terms.component.ts @@ -0,0 +1,78 @@ +import { Component, DoCheck, forwardRef, Input, OnInit } from '@angular/core' +import { + NG_ASYNC_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms' +import { ErrorStateMatcher } from '@angular/material/core' +import { Register2Service } from 'src/app/core/register2/register2.service' +import { environment } from 'src/environments/environment' + +import { BaseForm } from '../BaseForm' + +@Component({ + selector: 'app-form-terms', + templateUrl: './form-terms.component.html', + styleUrls: [ + './form-terms.component.scss', + '../register2.style.scss', + '../register2.scss-theme.scss', + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FormTermsComponent), + multi: true, + }, + { + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => FormTermsComponent), + multi: true, + }, + ], + preserveWhitespaces: true, +}) +// tslint:disable-next-line: class-name +export class FormTermsComponent extends BaseForm implements OnInit, DoCheck { + @Input() nextButtonWasClicked: boolean + environment = environment + constructor( + private _register: Register2Service, + private _errorStateMatcher: ErrorStateMatcher + ) { + super() + } + errorState = false + + termsOfUse = new UntypedFormControl('', Validators.requiredTrue) + dataProcessed = new UntypedFormControl('', Validators.requiredTrue) + ngOnInit() { + this.form = new UntypedFormGroup({ + termsOfUse: this.termsOfUse, + dataProcessed: this.dataProcessed, + }) + } + + // OVERWRITE + registerOnChange(fn: any) { + this.form.valueChanges.subscribe((value) => { + const registerForm = + this._register.formGroupTermsOfUseAndDataProcessedRegisterForm( + this.form as UntypedFormGroup + ) + fn(registerForm) + }) + } + + ngDoCheck(): void { + this.errorState = + this._errorStateMatcher.isErrorState(this.termsOfUse, null) || + this._errorStateMatcher.isErrorState(this.dataProcessed, null) + } + + get termsOfUseWasTouched() { + return this.form.controls.termsOfUse.touched || this.nextButtonWasClicked + } +} diff --git a/src/app/register2/components/form-visibility/form-visibility.component.html b/src/app/register2/components/form-visibility/form-visibility.component.html new file mode 100644 index 0000000000..01e8a7c4b5 --- /dev/null +++ b/src/app/register2/components/form-visibility/form-visibility.component.html @@ -0,0 +1,131 @@ + +

+ + Your ORCID iD connects with your ORCID record that can contain links to + your research activities, affiliations, awards, other versions of your + name, and more. You control this content and who can see it. + +

+
+ + Visibility settings + + +

+ By default, what visibility should be given to new items added to your + ORCID Record? +

+
+

+ Please select a default visibility for new items +

+ + + +
+
+ Everyone + + (87% of users choose this) +
+ Everyone can see these items +
+
+
+
+ + +
+
+ Trusted Organizations + (5% of users choose this) +
+ Only people and organizations you’ve given permission +
+
+
+
+ + +
+
+ Only me + + (8% of users choose this) +
+ Items are private and only visible to you +
+
+
+
+
+
+
+

+ More information on visibility settings + + open_in_new + +

+
diff --git a/src/app/register2/components/form-visibility/form-visibility.component.scss b/src/app/register2/components/form-visibility/form-visibility.component.scss new file mode 100644 index 0000000000..e0899aacb3 --- /dev/null +++ b/src/app/register2/components/form-visibility/form-visibility.component.scss @@ -0,0 +1,10 @@ +:host img { + height: 32px; + width: 32px; + margin-left: 7px; + margin-right: 16px; + [dir='rtl'] & { + margin-right: -4px; + margin-left: 4px; + } +} diff --git a/src/app/register2/components/form-visibility/form-visibility.component.spec.ts b/src/app/register2/components/form-visibility/form-visibility.component.spec.ts new file mode 100644 index 0000000000..4e0ac5bf54 --- /dev/null +++ b/src/app/register2/components/form-visibility/form-visibility.component.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { Overlay } from '@angular/cdk/overlay' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog' +import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar' +import { RouterTestingModule } from '@angular/router/testing' +import { PlatformInfoService } from '../../../cdk/platform-info' +import { SnackbarService } from '../../../cdk/snackbar/snackbar.service' +import { WINDOW_PROVIDERS } from '../../../cdk/window' +import { ErrorHandlerService } from '../../../core/error-handler/error-handler.service' +import { Register2Service } from '../../../core/register2/register2.service' +import { FormVisibilityComponent } from './form-visibility.component' + +describe('FormVisibilityComponent', () => { + let component: FormVisibilityComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule], + declarations: [FormVisibilityComponent], + providers: [ + WINDOW_PROVIDERS, + Register2Service, + PlatformInfoService, + ErrorHandlerService, + SnackbarService, + MatSnackBar, + MatDialog, + Overlay, + ], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(FormVisibilityComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/form-visibility/form-visibility.component.ts b/src/app/register2/components/form-visibility/form-visibility.component.ts new file mode 100644 index 0000000000..e12d3ffdc6 --- /dev/null +++ b/src/app/register2/components/form-visibility/form-visibility.component.ts @@ -0,0 +1,73 @@ +import { Component, DoCheck, forwardRef, OnInit } from '@angular/core' +import { + NG_ASYNC_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms' +import { ErrorStateMatcher } from '@angular/material/core' +import { VISIBILITY_OPTIONS } from 'src/app/constants' +import { Register2Service } from 'src/app/core/register2/register2.service' + +import { BaseForm } from '../BaseForm' + +@Component({ + selector: 'app-form-visibility', + templateUrl: './form-visibility.component.html', + styleUrls: [ + './form-visibility.component.scss', + '../register2.style.scss', + '../register2.scss-theme.scss', + ], + preserveWhitespaces: true, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FormVisibilityComponent), + multi: true, + }, + { + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => FormVisibilityComponent), + multi: true, + }, + ], +}) +export class FormVisibilityComponent + extends BaseForm + implements OnInit, DoCheck +{ + ariaLabelMoreInformationOnVisibility = $localize`:@@register.ariaLabelMoreInformationOnVisibility:More information on visibility settings (Opens in new tab)` + visibilityOptions = VISIBILITY_OPTIONS + errorState = false + activitiesVisibilityDefault = new UntypedFormControl('', Validators.required) + constructor( + private _register: Register2Service, + private _errorStateMatcher: ErrorStateMatcher + ) { + super() + } + ngOnInit() { + this.form = new UntypedFormGroup({ + activitiesVisibilityDefault: this.activitiesVisibilityDefault, + }) + } + + ngDoCheck(): void { + this.errorState = this._errorStateMatcher.isErrorState( + this.activitiesVisibilityDefault, + null + ) + } + + // OVERWRITE + registerOnChange(fn: any) { + this.form.valueChanges.subscribe((value) => { + const registerForm = this._register.formGroupToActivitiesVisibilityForm( + this.form as UntypedFormGroup + ) + fn(registerForm) + }) + } +} diff --git a/src/app/register2/components/register2.scss-theme.scss b/src/app/register2/components/register2.scss-theme.scss new file mode 100644 index 0000000000..7f860f7666 --- /dev/null +++ b/src/app/register2/components/register2.scss-theme.scss @@ -0,0 +1,98 @@ +@use '@angular/material' as mat; +@import 'src/assets/scss/material.orcid-theme.scss'; + +@mixin theme($theme) { + $primary: map-get($theme, primary); + $accent: map-get($theme, accent); + $warn: map-get($theme, accent); + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + + .step-actions { + border-color: mat.get-color-from-palette($background, ui-background-light); + .mat-raised-button.mat-primary { + background-color: mat.get-color-from-palette($primary, 700); + } + a { + color: mat.get-color-from-palette($primary, 700); + } + } + + a > mat-icon { + color: mat.get-color-from-palette($primary, 200) !important; + } + + ::ng-deep .valid { + color: mat.get-color-from-palette($accent, 900); + } + + .error { + color: map-get($foreground, 'state-warning-dark'); + } + + .text-light { + color: map-get($foreground, 'text-dark-mid'); + } + + .info { + background-color: mat.get-color-from-palette( + $background, + state-notice-lightest + ); + border-color: mat.get-color-from-palette( + $foreground, + 'state-notice-dark' + ) !important; + + mat-icon { + color: mat.get-color-from-palette( + $foreground, + 'state-notice-dark' + ) !important; + } + } + + .announce { + background-color: mat.get-color-from-palette( + $background, + state-info-lightest + ); + border-color: mat.get-color-from-palette( + $foreground, + 'state-info-dark' + ) !important; + + mat-icon { + color: mat.get-color-from-palette( + $foreground, + 'state-info-dark' + ) !important; + } + } + + :host { + ::ng-deep { + .container .mat-horizontal-content-container, + .container { + background-color: map-get($background, 'ui-background-lightest'); + } + } + } + + ::ng-deep { + .valid-password-input { + mat-icon { + color: map-get($foreground, 'brand-primary-dark'); + } + .mat-form-field-outline { + color: map-get($foreground, 'brand-primary-dark'); + background-color: mat.get-color-from-palette( + $background, + brand-primary-lightest + ); + } + } + } +} + +@include theme($orcid-app-theme); diff --git a/src/app/register2/components/register2.style.scss b/src/app/register2/components/register2.style.scss new file mode 100644 index 0000000000..427a9d30f6 --- /dev/null +++ b/src/app/register2/components/register2.style.scss @@ -0,0 +1,191 @@ +a { + text-decoration: underline; + font-weight: normal; +} + +.no-top-margin { + margin-top: 0; +} + +h2.margin-top-12, +.step-actions.margin-top-12, +.margin-top-12 { + margin-top: 12px; +} + +h2, +legend { + margin-top: 32px; + margin-bottom: 14px; + font-weight: bold; +} + +fieldset { + border: none; + margin: 0; + padding: 0; +} + +.margin-tb-32 { + margin-top: 32px; + margin-bottom: 32px; +} + +mat-card-title img { + margin-bottom: 32px; +} + +.input-container { + padding-bottom: 20px; +} + +mat-label.orc-font-small-print { + font-weight: bold; + display: block; + margin-bottom: 4px; + + label { + font-weight: normal; + } +} + +.step-actions { + border-top: 1px solid; + padding-top: 32px; + a { + font-style: italic; + } +} + +:host ::ng-deep { + mat-error { + margin-top: 8px; + font-size: 12px; + } + + mat-form-field { + padding-top: 8px; + + .mat-form-field-subscript-wrapper { + padding: 0; + } + + .mat-form-field-wrapper { + padding-bottom: 2px; + } + } + + .mat-form-field-appearance-outline { + .mat-form-field-wrapper { + margin: 0; + + .mat-form-field-outline { + top: 0px; + } + + .mat-form-field-prefix { + top: 0.35em; + + mat-icon { + margin-inline-end: 8px; + } + } + + .mat-form-field-flex { + .mat-form-field-infix { + border-top: 0px; + padding: 10px 0 10px 0; + } + } + } + } + + mat-form-field.mat-form-field.mat-primary.mat-form-field-type-mat-input.mat-form-field-appearance-outline.mat-form-field-can-float.mat-form-field-invalid { + margin-bottom: 0px; + } +} + +.orc-font-heading-small { + font-style: normal; + font-weight: 500; +} + +.info, +.announce { + .content div:not(:last-child) { + margin-bottom: 16px; + } + + padding: 16px; + margin-bottom: 16px; + border: solid 2px; + border-radius: 4px; + display: flex; + + p { + margin: 0; + } + + mat-icon, + img { + margin-right: 16px; + } + + h3 { + margin: 0; + } +} + +// MATERIAL OVERWRITES + +::ng-deep { + .mat-horizontal-stepper-content { + max-width: 580px; + } + + .mat-horizontal-stepper-wrapper { + .mat-horizontal-stepper-header-container { + display: none; + } + } + + mat-vertical-stepper.orcid-stepper-wizard mat-card p, + mat-horizontal-stepper.orcid-stepper-wizard mat-card p { + margin-bottom: 16px; + } + + mat-vertical-stepper.orcid-stepper-wizard mat-card mat-form-field, + mat-vertical-stepper.orcid-stepper-wizard mat-card .input-container, + mat-horizontal-stepper.orcid-stepper-wizard mat-card mat-form-field, + mat-horizontal-stepper.orcid-stepper-wizard mat-card .input-container { + margin-bottom: 0px; + } + + mat-vertical-stepper.orcid-stepper-wizard, + mat-horizontal-stepper.orcid-stepper-wizard { + mat-card { + a > mat-icon { + margin-bottom: -11px; + margin-left: 4px; + font-size: 17px; + } + + mat-card-content.mat-card-content { + margin-top: 32px !important; + } + + mat-radio-button:not(:last-child) { + margin-bottom: 32px; + } + + .step-actions { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; + margin-top: 29px; + gap: 12px; + } + } + } +} diff --git a/src/app/register2/components/step-a/step-a.component.html b/src/app/register2/components/step-a/step-a.component.html new file mode 100644 index 0000000000..100150de90 --- /dev/null +++ b/src/app/register2/components/step-a/step-a.component.html @@ -0,0 +1,78 @@ + + + +
+ orcid logo +
+ +

+ Create your ORCID iD +

+
+ + Thank you for reactivating your ORCID iD. + +
+ Step 1 of 4 - Names and emails +
+ + +

+ Per ORCID's + terms of use, you may only register for an ORCID iD for yourself. Already have an + ORCID iD? + Sign In +

+
+ + +
+ + +
+
+
+
diff --git a/src/app/register2/components/step-a/step-a.component.scss b/src/app/register2/components/step-a/step-a.component.scss new file mode 100644 index 0000000000..4cef8a0ce4 --- /dev/null +++ b/src/app/register2/components/step-a/step-a.component.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + flex-direction: column; +} diff --git a/src/app/register2/components/step-a/step-a.component.spec.ts b/src/app/register2/components/step-a/step-a.component.spec.ts new file mode 100644 index 0000000000..693ea5d367 --- /dev/null +++ b/src/app/register2/components/step-a/step-a.component.spec.ts @@ -0,0 +1,43 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { StepAComponent } from './step-a.component' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { RouterTestingModule } from '@angular/router/testing' +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog' +import { WINDOW_PROVIDERS } from '../../../cdk/window' +import { PlatformInfoService } from '../../../cdk/platform-info' +import { ErrorHandlerService } from '../../../core/error-handler/error-handler.service' +import { SnackbarService } from '../../../cdk/snackbar/snackbar.service' +import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar' +import { Overlay } from '@angular/cdk/overlay' + +describe('StepAComponent', () => { + let component: StepAComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule], + declarations: [StepAComponent], + providers: [ + WINDOW_PROVIDERS, + PlatformInfoService, + ErrorHandlerService, + SnackbarService, + MatSnackBar, + MatDialog, + Overlay, + ], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(StepAComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/step-a/step-a.component.ts b/src/app/register2/components/step-a/step-a.component.ts new file mode 100644 index 0000000000..40e5870d80 --- /dev/null +++ b/src/app/register2/components/step-a/step-a.component.ts @@ -0,0 +1,102 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + ViewChild, +} from '@angular/core' + +import { Router } from '@angular/router' +import { first } from 'rxjs/operators' +import { PlatformInfoService } from 'src/app/cdk/platform-info' +import { ApplicationRoutes } from 'src/app/constants' +import { environment } from 'src/environments/environment' +import { ReactivationLocal } from '../../../types/reactivation.local' +import { BaseStepDirective } from '../BaseStep' + +@Component({ + selector: 'app-step-a', + templateUrl: './step-a.component.html', + styleUrls: [ + './step-a.component.scss', + '../register2.style.scss', + '../register2.scss-theme.scss', + ], + preserveWhitespaces: true, +}) +export class StepAComponent extends BaseStepDirective implements AfterViewInit { + @ViewChild('firstInput') firstInput: ElementRef + + @Input() reactivation: ReactivationLocal + nextButtonWasClicked: boolean + + constructor(private _platform: PlatformInfoService, private _router: Router) { + super() + } + infoSiteBaseUrl = environment.INFO_SITE + + goBack() { + this._platform + .get() + .pipe(first()) + .subscribe((platform) => { + if (platform.social) { + this._router.navigate([ApplicationRoutes.social], { + queryParams: { + ...platform.queryParameters, + }, + }) + } else if (platform.institutional) { + this._router.navigate([ApplicationRoutes.institutionalLinking], { + queryParams: { + ...platform.queryParameters, + }, + }) + } else { + this._router.navigate([ApplicationRoutes.signin], { + queryParams: { + ...platform.queryParameters, + }, + }) + } + }) + } + + ngAfterViewInit(): void { + // Timeout used to get focus on the first input after the first step loads + setTimeout(() => { + this.firstInput.nativeElement.focus() + }), + 100 + } + + nextButton2() { + this.nextButtonWasClicked = true + // this.formGroup.controls.personal.markAsTouched() + } + + signIn() { + this._platform + .get() + .pipe(first()) + .subscribe((platform) => { + const params = JSON.parse(JSON.stringify(platform.queryParameters)) + if (params['email']) { + delete params['email'] + } + if (params['orcid']) { + delete params['orcid'] + } + + if (params['show_login']) { + delete params['show_login'] + } + + this._router.navigate([ApplicationRoutes.signin], { + queryParams: { + ...params, + }, + }) + }) + } +} diff --git a/src/app/register2/components/step-b/step-b.component.html b/src/app/register2/components/step-b/step-b.component.html new file mode 100644 index 0000000000..1cb78c7fe6 --- /dev/null +++ b/src/app/register2/components/step-b/step-b.component.html @@ -0,0 +1,68 @@ + + + +
+ orcid logo +
+ +

+ Create your ORCID iD +

+
+ + Thank you for reactivating your ORCID iD. + +
+ Step 2 of 4 - Password +
+ + +
+ +
+ + +
+
+
+
diff --git a/src/app/register2/components/step-b/step-b.component.scss b/src/app/register2/components/step-b/step-b.component.scss new file mode 100644 index 0000000000..4cef8a0ce4 --- /dev/null +++ b/src/app/register2/components/step-b/step-b.component.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + flex-direction: column; +} diff --git a/src/app/register2/components/step-b/step-b.component.spec.ts b/src/app/register2/components/step-b/step-b.component.spec.ts new file mode 100644 index 0000000000..d8ed95bef5 --- /dev/null +++ b/src/app/register2/components/step-b/step-b.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { StepBComponent } from './step-b.component' + +describe('StepBComponent', () => { + let component: StepBComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [StepBComponent], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(StepBComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/step-b/step-b.component.ts b/src/app/register2/components/step-b/step-b.component.ts new file mode 100644 index 0000000000..4d3549abac --- /dev/null +++ b/src/app/register2/components/step-b/step-b.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core' + +import { ReactivationLocal } from '../../../types/reactivation.local' +import { BaseStepDirective } from '../BaseStep' + +@Component({ + selector: 'app-step-b', + templateUrl: './step-b.component.html', + styleUrls: [ + './step-b.component.scss', + '../register2.style.scss', + '../register2.scss-theme.scss', + ], +}) +export class StepBComponent extends BaseStepDirective { + @Input() personalData + @Input() reactivation: ReactivationLocal + nextButtonWasClicked = false + + constructor() { + super() + } +} diff --git a/src/app/register2/components/step-c-t/step-c.component.html b/src/app/register2/components/step-c-t/step-c.component.html new file mode 100644 index 0000000000..f1955d20f1 --- /dev/null +++ b/src/app/register2/components/step-c-t/step-c.component.html @@ -0,0 +1,79 @@ + + + + + +
+ orcid logo +
+ +

+ Create your ORCID iD +

+
+ + Thank you for reactivating your ORCID iD. + +
+ Step 3 of 4 - Visibility +
+ + +
+ + + +
+ + +
+
+
+ +
diff --git a/src/app/register2/components/step-c-t/step-c.component.scss b/src/app/register2/components/step-c-t/step-c.component.scss new file mode 100644 index 0000000000..4cef8a0ce4 --- /dev/null +++ b/src/app/register2/components/step-c-t/step-c.component.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + flex-direction: column; +} diff --git a/src/app/register2/components/step-c-t/step-c.component.ts b/src/app/register2/components/step-c-t/step-c.component.ts new file mode 100644 index 0000000000..d199a4a7c3 --- /dev/null +++ b/src/app/register2/components/step-c-t/step-c.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core' + +import { ReactivationLocal } from '../../../types/reactivation.local' +import { BaseStepDirective } from '../BaseStep' + +@Component({ + selector: 'app-step-ct', + templateUrl: './step-c.component.html', + styleUrls: [ + './step-c.component.scss', + '../register2.style.scss', + '../register2.scss-theme.scss', + ], +}) +export class StepCTComponent extends BaseStepDirective { + @Input() loading + @Input() reactivation: ReactivationLocal + + constructor() { + super() + } +} diff --git a/src/app/register2/components/step-c-t/step-c.spec.ts b/src/app/register2/components/step-c-t/step-c.spec.ts new file mode 100644 index 0000000000..8ee3edf0c1 --- /dev/null +++ b/src/app/register2/components/step-c-t/step-c.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { StepCTComponent } from './step-c.component' + +describe('StepCComponent', () => { + let component: StepCTComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [StepCTComponent], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(StepCTComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/step-d/step-d.component.html b/src/app/register2/components/step-d/step-d.component.html new file mode 100644 index 0000000000..e2eab81396 --- /dev/null +++ b/src/app/register2/components/step-d/step-d.component.html @@ -0,0 +1,87 @@ + + + + + +
+ orcid logo +
+ +

Create your ORCID iD

+
+ + Thank you for reactivating your ORCID iD. + +
+ Step 4 of 4 - Terms and conditions +
+ + +
+ + + +
+ + +
+
+
+ +
diff --git a/src/app/register2/components/step-d/step-d.component.scss b/src/app/register2/components/step-d/step-d.component.scss new file mode 100644 index 0000000000..4cef8a0ce4 --- /dev/null +++ b/src/app/register2/components/step-d/step-d.component.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + flex-direction: column; +} diff --git a/src/app/register2/components/step-d/step-d.component.spec.ts b/src/app/register2/components/step-d/step-d.component.spec.ts new file mode 100644 index 0000000000..33538efa82 --- /dev/null +++ b/src/app/register2/components/step-d/step-d.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { StepDComponent } from './step-d.component' + +describe('StepCComponent', () => { + let component: StepDComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [StepDComponent], + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(StepDComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/register2/components/step-d/step-d.component.ts b/src/app/register2/components/step-d/step-d.component.ts new file mode 100644 index 0000000000..4494886392 --- /dev/null +++ b/src/app/register2/components/step-d/step-d.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core' + +import { ReactivationLocal } from '../../../types/reactivation.local' +import { BaseStepDirective } from '../BaseStep' + +@Component({ + selector: 'app-step-d', + templateUrl: './step-d.component.html', + styleUrls: [ + './step-d.component.scss', + '../register2.style.scss', + '../register2.scss-theme.scss', + ], +}) +export class StepDComponent extends BaseStepDirective { + @Input() loading + @Input() reactivation: ReactivationLocal + nextButtonWasClicked = false + + constructor() { + super() + } +} diff --git a/src/app/register2/pages/register/register2.component.html b/src/app/register2/pages/register/register2.component.html new file mode 100644 index 0000000000..db7dcf6ef1 --- /dev/null +++ b/src/app/register2/pages/register/register2.component.html @@ -0,0 +1,60 @@ +
+
+
+
+ + + Personal data + + + + Security and notifications + + + + Visibility and terms + + + + Visibility and terms + + + +
+
+
+
diff --git a/src/app/register2/pages/register/register2.component.scss b/src/app/register2/pages/register/register2.component.scss new file mode 100644 index 0000000000..83134365d6 --- /dev/null +++ b/src/app/register2/pages/register/register2.component.scss @@ -0,0 +1,9 @@ +:host { + width: 100%; +} + +mat-vertical-stepper, +mat-horizontal-stepper { + max-width: 100%; + width: 100%; +} diff --git a/src/app/register2/pages/register/register2.component.scss.theme.scss b/src/app/register2/pages/register/register2.component.scss.theme.scss new file mode 100644 index 0000000000..8915a0e475 --- /dev/null +++ b/src/app/register2/pages/register/register2.component.scss.theme.scss @@ -0,0 +1,15 @@ +@use '@angular/material' as mat; +@import 'src/assets/scss/material.orcid-theme.scss'; + +@mixin theme($theme) { + $primary: map-get($theme, primary); + $accent: map-get($theme, accent); + $warn: map-get($theme, accent); + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + + :host { + background-color: map-get($background, 'ui-background-lightest'); + } +} +@include theme($orcid-app-theme); diff --git a/src/app/register2/pages/register/register2.component.ts b/src/app/register2/pages/register/register2.component.ts new file mode 100644 index 0000000000..1cf21f17b7 --- /dev/null +++ b/src/app/register2/pages/register/register2.component.ts @@ -0,0 +1,287 @@ +import { StepperSelectionEvent } from '@angular/cdk/stepper' +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + Inject, + OnInit, + ViewChild, +} from '@angular/core' +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms' +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog' +import { MatStep } from '@angular/material/stepper' +import { Router } from '@angular/router' +import { Observable, combineLatest, forkJoin } from 'rxjs' +import { catchError, first, map, switchMap } from 'rxjs/operators' +import { IsThisYouComponent } from 'src/app/cdk/is-this-you' +import { PlatformInfo, PlatformInfoService } from 'src/app/cdk/platform-info' +import { WINDOW } from 'src/app/cdk/window' +import { isRedirectToTheAuthorizationPage } from 'src/app/constants' +import { UserService } from 'src/app/core' +import { ErrorHandlerService } from 'src/app/core/error-handler/error-handler.service' +import { Register2Service } from 'src/app/core/register2/register2.service' +import { ERROR_REPORT } from 'src/app/errors' +import { RequestInfoForm, SearchResults } from 'src/app/types' +import { + RegisterConfirmResponse, + RegisterForm, +} from 'src/app/types/register.endpoint' +import { UserSession } from 'src/app/types/session.local' +import { ThirdPartyAuthData } from 'src/app/types/sign-in-data.endpoint' +import { GoogleTagManagerService } from '../../../core/google-tag-manager/google-tag-manager.service' +import { SearchService } from '../../../core/search/search.service' +import { ReactivationLocal } from '../../../types/reactivation.local' + +@Component({ + selector: 'app-register-2', + templateUrl: './register2.component.html', + styleUrls: [ + './register2.component.scss', + './register2.component.scss.theme.scss', + '../../components/register2.scss-theme.scss', + '../../components/register2.style.scss', + ], +}) +export class Register2Component implements OnInit, AfterViewInit { + @ViewChild('lastStep') lastStep: MatStep + @ViewChild('stepComponentA', { read: ElementRef }) stepComponentA: ElementRef + @ViewChild('stepComponentB', { read: ElementRef }) stepComponentB: ElementRef + @ViewChild('stepComponentC', { read: ElementRef }) stepComponentC: ElementRef + @ViewChild('stepComponentD', { read: ElementRef }) stepComponentD: ElementRef + + platform: PlatformInfo + FormGroupStepA: UntypedFormGroup + FormGroupStepB: UntypedFormGroup + FormGroupStepC: UntypedFormGroup + FormGroupStepD: UntypedFormGroup + + isLinear = true + personalData: RegisterForm + backendForm: RegisterForm + loading = false + requestInfoForm: RequestInfoForm | null + thirdPartyAuthData: ThirdPartyAuthData + reactivation = { + isReactivation: false, + reactivationCode: '', + } as ReactivationLocal + + constructor( + private _cdref: ChangeDetectorRef, + private _platformInfo: PlatformInfoService, + private _formBuilder: UntypedFormBuilder, + private _register: Register2Service, + private _dialog: MatDialog, + @Inject(WINDOW) private window: Window, + private _googleTagManagerService: GoogleTagManagerService, + private _user: UserService, + private _router: Router, + private _errorHandler: ErrorHandlerService, + private _userInfo: UserService, + private _searchService: SearchService + ) { + _platformInfo.get().subscribe((platform) => { + this.platform = platform + this.reactivation.isReactivation = this.platform.reactivation + this.reactivation.reactivationCode = this.platform.reactivationCode + }) + } + ngOnInit() { + this._register.getRegisterForm().subscribe() + + this.FormGroupStepA = this._formBuilder.group({ + personal: [''], + }) + this.FormGroupStepB = this._formBuilder.group({ + password: [''], + }) + this.FormGroupStepC = this._formBuilder.group({ + activitiesVisibilityDefault: [''], + }) + this.FormGroupStepD = this._formBuilder.group({ + sendOrcidNews: [''], + termsOfUse: [''], + captcha: [''], + }) + + combineLatest([this._userInfo.getUserSession(), this._platformInfo.get()]) + .pipe( + first(), + map(([session, platform]) => { + session = session as UserSession + platform = platform as PlatformInfo + + // TODO @leomendoza123 move the handle of social/institutional sessions to the user service + + this.thirdPartyAuthData = session.thirdPartyAuthData + this.requestInfoForm = session.oauthSession + + if (this.thirdPartyAuthData || this.requestInfoForm) { + this.FormGroupStepA = this.prefillRegisterForm( + this.requestInfoForm, + this.thirdPartyAuthData + ) + } + }) + ) + .subscribe() + } + + ngAfterViewInit(): void { + this._cdref.detectChanges() + } + + register() { + this.loading = true + this.lastStep.interacted = true + if ( + this.FormGroupStepA.valid && + this.FormGroupStepB.valid && + this.FormGroupStepC.valid && + this.FormGroupStepD.valid + ) { + this._register + .backendRegisterFormValidate( + this.FormGroupStepA, + this.FormGroupStepB, + this.FormGroupStepC, + this.FormGroupStepD + ) + .pipe( + switchMap((validator: RegisterForm) => { + if (validator.errors.length > 0) { + // At this point any backend error is unexpected + this._errorHandler.handleError( + new Error('registerUnexpectedValidateFail'), + ERROR_REPORT.REGISTER + ) + } + return this._register.register( + this.FormGroupStepA, + this.FormGroupStepB, + this.FormGroupStepC, + this.FormGroupStepD, + this.reactivation, + this.requestInfoForm + ) + }) + ) + .subscribe((response) => { + this.loading = false + if (response.url) { + const analyticsReports: Observable[] = [] + + analyticsReports.push( + this._googleTagManagerService.reportEvent( + 'New-Registration', + this.requestInfoForm || 'Website' + ) + ) + forkJoin(analyticsReports) + .pipe( + catchError((err) => + this._errorHandler.handleError( + err, + ERROR_REPORT.STANDARD_NO_VERBOSE_NO_GA + ) + ) + ) + .subscribe( + () => this.afterRegisterRedirectionHandler(response), + () => this.afterRegisterRedirectionHandler(response) + ) + } else { + this._errorHandler.handleError( + new Error('registerUnexpectedConfirmation'), + ERROR_REPORT.REGISTER + ) + } + }) + } else { + this.loading = false + } + } + + afterRegisterRedirectionHandler(response: RegisterConfirmResponse) { + if (isRedirectToTheAuthorizationPage(response)) { + this.window.location.href = response.url + } else { + if ( + response.url.indexOf('orcid.org/my-orcid') > 0 && + response.url.indexOf('justRegistered') > 0 + ) { + this.window.scrollTo(0, 0) + this._router + .navigate(['/my-orcid'], { + queryParams: { justRegistered: true }, + }) + .then(() => { + this.window.scrollTo(0, 0) + }) + } else { + this.window.location.href = response.url + } + } + } + + + + // Fix to material vertical stepper not focusing current header + // related issue https://github.com/angular/components/issues/8881 + focusCurrentStep(event: StepperSelectionEvent) { + let nextStep: ElementRef + if (event.selectedIndex === 0) { + nextStep = this.stepComponentA + } else if (event.selectedIndex === 1) { + nextStep = this.stepComponentB + } else if (event.selectedIndex === 2) { + nextStep = this.stepComponentC + } else if (event.selectedIndex === 3) { + nextStep = this.stepComponentD + } + // On mobile scroll the current step component into view + if (this.platform.columns4 || this.platform.columns8) { + setTimeout(() => { + const nativeElementNextStep = nextStep.nativeElement as HTMLElement + nativeElementNextStep.scrollIntoView() + }, 200) + } + } + + /** + * Fills the register form. + * Use the data from the Oauth session send by the Orcid integrator + * or + * Use data coming from a third party institution/social entity + * or + * Use empty values + */ + private prefillRegisterForm( + oauthData: RequestInfoForm, + thirdPartyOauthData: ThirdPartyAuthData + ) { + return this._formBuilder.group({ + personal: [ + { + givenNames: + oauthData?.userGivenNames || + thirdPartyOauthData?.signinData?.firstName || + '', + familyNames: + oauthData?.userFamilyNames || + thirdPartyOauthData?.signinData?.lastName || + '', + emails: { + email: + oauthData?.userEmail || + thirdPartyOauthData?.signinData?.email || + '', + confirmEmail: '', + additionalEmails: { '0': '' }, + }, + }, + ], + }) + } +} diff --git a/src/app/register2/register-routing.module.ts b/src/app/register2/register-routing.module.ts new file mode 100644 index 0000000000..7dbd8286d7 --- /dev/null +++ b/src/app/register2/register-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { Register2Component } from './pages/register/register2.component' + +const routes: Routes = [ + { + path: '', + component: Register2Component, + }, +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class RegisterRoutingModule {} diff --git a/src/app/register2/register.module.ts b/src/app/register2/register.module.ts new file mode 100644 index 0000000000..9516628b63 --- /dev/null +++ b/src/app/register2/register.module.ts @@ -0,0 +1,74 @@ +import { CommonModule } from '@angular/common' +import { NgModule } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { FormNotificationsComponent } from './components/form-notifications/form-notifications.component' +import { FormPasswordComponent } from './components/form-password/form-password.component' +import { FormPersonalComponent } from './components/form-personal/form-personal.component' +import { FormTermsComponent } from './components/form-terms/form-terms.component' +import { FormVisibilityComponent } from './components/form-visibility/form-visibility.component' +import { StepAComponent } from './components/step-a/step-a.component' +import { StepBComponent } from './components/step-b/step-b.component' +import { StepDComponent } from './components/step-d/step-d.component' +import { RegisterRoutingModule } from './register-routing.module' +// tslint:disable-next-line: max-line-length +import { MatIconModule } from '@angular/material/icon' +import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button' +import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card' +import { MatLegacyCheckboxModule as MatCheckboxModule } from '@angular/material/legacy-checkbox' +import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog' +import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field' +import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input' +import { MatLegacyProgressBarModule as MatProgressBarModule } from '@angular/material/legacy-progress-bar' +import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio' +import { MatStepperModule } from '@angular/material/stepper' +import { A11yLinkModule } from '../cdk/a11y-link/a11y-link.module' +import { FormDirectivesModule } from '../cdk/form-directives/form-directives.module' +import { IsThisYouModule } from '../cdk/is-this-you' +import { MdePopoverModule } from '../cdk/popover' +import { RecaptchaModule } from '../cdk/recaptcha/recaptcha.module' +import { WarningMessageModule } from '../cdk/warning-message/warning-message.module' +import { BackendErrorComponent } from './components/backend-error/backend-error.component' +import { FormAntiRobotsComponent } from './components/form-anti-robots/form-anti-robots.component' +import { FormPersonalAdditionalEmailsComponent } from './components/form-personal-additional-emails/form-personal-additional-emails.component' +import { StepCTComponent } from './components/step-c-t/step-c.component' +import { Register2Component } from './pages/register/register2.component' +@NgModule({ + declarations: [ + StepAComponent, + StepBComponent, + StepCTComponent, + StepDComponent, + FormPersonalComponent, + FormPasswordComponent, + FormNotificationsComponent, + FormVisibilityComponent, + FormTermsComponent, + FormPersonalAdditionalEmailsComponent, + FormAntiRobotsComponent, + BackendErrorComponent, + Register2Component, + ], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + RegisterRoutingModule, + MatStepperModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatCheckboxModule, + MatRadioModule, + MatIconModule, + MatDialogModule, + IsThisYouModule, + MdePopoverModule, + MatCardModule, + RecaptchaModule, + A11yLinkModule, + MatProgressBarModule, + FormDirectivesModule, + WarningMessageModule, + ], +}) +export class Register2Module {} diff --git a/src/app/types/register.email-category.ts b/src/app/types/register.email-category.ts new file mode 100644 index 0000000000..807af30464 --- /dev/null +++ b/src/app/types/register.email-category.ts @@ -0,0 +1,5 @@ +export type EmailCategory = 'PROFESSIONAL' | 'PERSONAL' | 'UNDEFINED' + +export interface EmailCategoryEndpoint { + category: EmailCategory +} diff --git a/src/assets/scss/material.light-theme.scss b/src/assets/scss/material.light-theme.scss index 2516222ff2..3bf87b9f90 100644 --- a/src/assets/scss/material.light-theme.scss +++ b/src/assets/scss/material.light-theme.scss @@ -27,6 +27,7 @@ brand-primary-lightest: $brand-primary-lightest, ui-background-light: $ui-background-light, ui-background-lightest: $ui-background-lightest, + state-notice-lightest: $state-notice-lightest, ui-background-darkest: $ui-background-darkest, ui-background: $ui-background, // COLORS OVERWRITES @@ -54,6 +55,7 @@ state-notice-dark: $state-notice-dark, state-notice-darkest: $state-notice-darkest, state-warning-dark: $state-warning-dark, + state-info-dark: $state-info-dark, text-dark-mid: $text-dark-mid, text-dark-high: $text-dark-high, text-light-high: $text-light-high, diff --git a/src/assets/scss/material.palettes.scss b/src/assets/scss/material.palettes.scss index c9b9317312..9cfc5723fb 100644 --- a/src/assets/scss/material.palettes.scss +++ b/src/assets/scss/material.palettes.scss @@ -139,6 +139,8 @@ $state-notice-light: #ffdf72; $state-notice-dark: #ff9c00; +$state-notice-lightest: #fffbee; + $state-notice-darkest: #ff6400; $state-warning-dark: #d32f2f; @@ -153,3 +155,6 @@ $dark-primary-background: #f6f6f6; $state-info-dark: #1565c0; $state-info-lightest: #bbdefb; + +$state-info-darkest: #0d47a1; +$state-info-lightest: #f1f8fe; diff --git a/src/assets/vectors/personal-email-icon.svg b/src/assets/vectors/personal-email-icon.svg new file mode 100644 index 0000000000..e66b4f6aff --- /dev/null +++ b/src/assets/vectors/personal-email-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/vectors/professional-email-icon.svg b/src/assets/vectors/professional-email-icon.svg new file mode 100644 index 0000000000..9d87208847 --- /dev/null +++ b/src/assets/vectors/professional-email-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/vectors/undefined-email-icon.svg b/src/assets/vectors/undefined-email-icon.svg new file mode 100644 index 0000000000..ad15b23993 --- /dev/null +++ b/src/assets/vectors/undefined-email-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/vectors/visibility-everyone2.svg b/src/assets/vectors/visibility-everyone2.svg new file mode 100644 index 0000000000..028475fbeb --- /dev/null +++ b/src/assets/vectors/visibility-everyone2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/vectors/visibility-only-me2.jpg b/src/assets/vectors/visibility-only-me2.jpg new file mode 100644 index 0000000000..75dff40fee Binary files /dev/null and b/src/assets/vectors/visibility-only-me2.jpg differ diff --git a/src/assets/vectors/visibility-trusted-source2.jpg b/src/assets/vectors/visibility-trusted-source2.jpg new file mode 100644 index 0000000000..e4338cf998 Binary files /dev/null and b/src/assets/vectors/visibility-trusted-source2.jpg differ diff --git a/src/index.html b/src/index.html index abffff8d94..2ddb810920 100644 --- a/src/index.html +++ b/src/index.html @@ -20,8 +20,8 @@ rel="stylesheet" />