diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 024b46cdde..70da23f044 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -24,7 +24,7 @@ import 'cypress-axe'; beforeEach(() => { // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // This just ensures it doesn't get in the way of matching other objects in the page. - cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true}'); + cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}'); }); // For better stability between tests, we visit "about:blank" (i.e. blank page) after each test. diff --git a/package.json b/package.json index 661a068299..f7f4a96fcd 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@nguniversal/express-engine": "^13.0.2", "@ngx-translate/core": "^13.0.0", "@nicky-lenaers/ngx-scroll-to": "^9.0.0", + "@types/grecaptcha": "^3.0.4", "angular-idle-preload": "3.0.0", "angulartics2": "^12.0.0", "axios": "^0.27.2", diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index dc13fff3a0..afd4927103 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -9,6 +9,8 @@ import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; import { RequestEntry } from './request-entry.model'; +import { HttpHeaders } from '@angular/common/http'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; describe('EpersonRegistrationService', () => { let testScheduler; @@ -79,8 +81,23 @@ describe('EpersonRegistrationService', () => { it('should send an email registration', () => { const expected = service.registerEmail('test@mail.org'); + let headers = new HttpHeaders(); + const options: HttpOptions = Object.create({}); + options.headers = headers; - expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration)); + expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options)); + expect(expected).toBeObservable(cold('(a|)', { a: rd })); + }); + + it('should send an email registration with captcha', () => { + + const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken'); + let headers = new HttpHeaders(); + const options: HttpOptions = Object.create({}); + headers = headers.append('x-recaptcha-token', 'afreshcaptchatoken'); + options.headers = headers; + + expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options)); expect(expected).toBeObservable(cold('(a|)', { a: rd })); }); }); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 3b033f693a..bfbecdaecb 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -3,15 +3,17 @@ import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { GetRequest, PostRequest } from './request.models'; import { Observable } from 'rxjs'; -import { filter, find, map, skipWhile } from 'rxjs/operators'; +import { filter, find, map } from 'rxjs/operators'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { Registration } from '../shared/registration.model'; -import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../shared/operators'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { ResponseParsingService } from './parsing.service'; import { GenericConstructor } from '../shared/generic-constructor'; import { RegistrationResponseParsingService } from './registration-response-parsing.service'; import { RemoteData } from './remote-data'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HttpHeaders } from '@angular/common/http'; @Injectable({ providedIn: 'root', @@ -51,8 +53,9 @@ export class EpersonRegistrationService { /** * Register a new email address * @param email + * @param captchaToken the value of x-recaptcha-token header */ - registerEmail(email: string): Observable> { + registerEmail(email: string, captchaToken: string = null): Observable> { const registration = new Registration(); registration.email = email; @@ -60,10 +63,17 @@ export class EpersonRegistrationService { const href$ = this.getRegistrationEndpoint(); + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + if (captchaToken) { + headers = headers.append('x-recaptcha-token', captchaToken); + } + options.headers = headers; + href$.pipe( find((href: string) => hasValue(href)), map((href: string) => { - const request = new PostRequest(requestId, href, registration); + const request = new PostRequest(requestId, href, registration, options); this.requestService.send(request); }) ).subscribe(); diff --git a/src/app/core/google-recaptcha/google-recaptcha.module.ts b/src/app/core/google-recaptcha/google-recaptcha.module.ts new file mode 100644 index 0000000000..64620a48f4 --- /dev/null +++ b/src/app/core/google-recaptcha/google-recaptcha.module.ts @@ -0,0 +1,25 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { GoogleRecaptchaComponent } from '../../shared/google-recaptcha/google-recaptcha.component'; + +import { GoogleRecaptchaService } from './google-recaptcha.service'; + +const PROVIDERS = [ + GoogleRecaptchaService +]; + +const COMPONENTS = [ + GoogleRecaptchaComponent +]; + +@NgModule({ + imports: [ CommonModule ], + providers: [...PROVIDERS], + declarations: [...COMPONENTS], + exports: [...COMPONENTS] +}) + +/** + * This module handles google recaptcha functionalities + */ +export class GoogleRecaptchaModule {} diff --git a/src/app/core/google-recaptcha/google-recaptcha.service.spec.ts b/src/app/core/google-recaptcha/google-recaptcha.service.spec.ts new file mode 100644 index 0000000000..545e3b9873 --- /dev/null +++ b/src/app/core/google-recaptcha/google-recaptcha.service.spec.ts @@ -0,0 +1,57 @@ +import { GoogleRecaptchaService } from './google-recaptcha.service'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { NativeWindowRef } from '../services/window.service'; + +describe('GoogleRecaptchaService', () => { + let service: GoogleRecaptchaService; + + let rendererFactory2; + let configurationDataService; + let spy: jasmine.Spy; + let scriptElementMock: any; + let cookieService; + let window; + const innerHTMLTestValue = 'mock-script-inner-html'; + const document = { documentElement: { lang: 'en' } } as Document; + scriptElementMock = { + set innerHTML(newVal) { /* noop */ }, + get innerHTML() { return innerHTMLTestValue; } + }; + + function init() { + window = new NativeWindowRef(); + rendererFactory2 = jasmine.createSpyObj('rendererFactory2', { + createRenderer: observableOf('googleRecaptchaToken'), + createElement: scriptElementMock + }); + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$({ values: ['googleRecaptchaToken'] }) + }); + cookieService = jasmine.createSpyObj('cookieService', { + get: '{%22token_item%22:true%2C%22impersonation%22:true%2C%22redirect%22:true%2C%22language%22:true%2C%22klaro%22:true%2C%22has_agreed_end_user%22:true%2C%22google-analytics%22:true}', + set: () => { + /* empty */ + } + }); + service = new GoogleRecaptchaService(cookieService, document, window, rendererFactory2, configurationDataService); + } + + beforeEach(() => { + init(); + }); + + describe('getRecaptchaToken', () => { + let result; + + beforeEach(() => { + spy = spyOn(service, 'getRecaptchaToken').and.stub(); + }); + + it('should send a Request with action', () => { + service.getRecaptchaToken('test'); + expect(spy).toHaveBeenCalledWith('test'); + }); + + }); +}); diff --git a/src/app/core/google-recaptcha/google-recaptcha.service.ts b/src/app/core/google-recaptcha/google-recaptcha.service.ts new file mode 100644 index 0000000000..72de1bb26c --- /dev/null +++ b/src/app/core/google-recaptcha/google-recaptcha.service.ts @@ -0,0 +1,176 @@ +import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { DOCUMENT } from '@angular/common'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { RemoteData } from '../data/remote-data'; +import { map, switchMap, take } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; +import { CookieService } from '../services/cookie.service'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; + +export const CAPTCHA_COOKIE = '_GRECAPTCHA'; +export const CAPTCHA_NAME = 'google-recaptcha'; + +/** + * A GoogleRecaptchaService used to send action and get a token from REST + */ +@Injectable() +export class GoogleRecaptchaService { + + private renderer: Renderer2; + + /** + * A Google Recaptcha version + */ + private captchaVersionSubject$ = new BehaviorSubject(null); + + /** + * The Google Recaptcha Key + */ + private captchaKeySubject$ = new BehaviorSubject(null); + + /** + * The Google Recaptcha mode + */ + private captchaModeSubject$ = new BehaviorSubject(null); + + captchaKey(): Observable { + return this.captchaKeySubject$.asObservable(); + } + + captchaMode(): Observable { + return this.captchaModeSubject$.asObservable(); + } + + captchaVersion(): Observable { + return this.captchaVersionSubject$.asObservable(); + } + + constructor( + private cookieService: CookieService, + @Inject(DOCUMENT) private _document: Document, + @Inject(NativeWindowService) private _window: NativeWindowRef, + rendererFactory: RendererFactory2, + private configService: ConfigurationDataService, + ) { + if (this._window.nativeWindow) { + this._window.nativeWindow.refreshCaptchaScript = this.refreshCaptchaScript; + } + this.renderer = rendererFactory.createRenderer(null, null); + const registrationVerification$ = this.configService.findByPropertyName('registration.verification.enabled').pipe( + take(1), + getFirstCompletedRemoteData(), + map((res: RemoteData) => { + return res.hasSucceeded && res.payload && isNotEmpty(res.payload.values) && res.payload.values[0].toLowerCase() === 'true'; + }) + ); + registrationVerification$.subscribe(registrationVerification => { + if (registrationVerification) { + this.loadRecaptchaProperties(); + } + }); + } + + loadRecaptchaProperties() { + const recaptchaKeyRD$ = this.configService.findByPropertyName('google.recaptcha.key.site').pipe( + getFirstCompletedRemoteData(), + ); + const recaptchaVersionRD$ = this.configService.findByPropertyName('google.recaptcha.version').pipe( + getFirstCompletedRemoteData(), + ); + const recaptchaModeRD$ = this.configService.findByPropertyName('google.recaptcha.mode').pipe( + getFirstCompletedRemoteData(), + ); + combineLatest([recaptchaVersionRD$, recaptchaModeRD$, recaptchaKeyRD$]).subscribe(([recaptchaVersionRD, recaptchaModeRD, recaptchaKeyRD]) => { + + if ( + this.cookieService.get('klaro-anonymous') && this.cookieService.get('klaro-anonymous')[CAPTCHA_NAME] && + recaptchaKeyRD.hasSucceeded && recaptchaVersionRD.hasSucceeded && + isNotEmpty(recaptchaVersionRD.payload?.values) && isNotEmpty(recaptchaKeyRD.payload?.values) + ) { + const key = recaptchaKeyRD.payload?.values[0]; + const version = recaptchaVersionRD.payload?.values[0]; + this.captchaKeySubject$.next(key); + this.captchaVersionSubject$.next(version); + + let captchaUrl; + switch (version) { + case 'v3': + if (recaptchaKeyRD.hasSucceeded && isNotEmpty(recaptchaKeyRD.payload?.values)) { + captchaUrl = this.buildCaptchaUrl(key); + this.captchaModeSubject$.next('invisible'); + } + break; + case 'v2': + if (recaptchaModeRD.hasSucceeded && isNotEmpty(recaptchaModeRD.payload?.values)) { + captchaUrl = this.buildCaptchaUrl(); + this.captchaModeSubject$.next(recaptchaModeRD.payload?.values[0]); + } + break; + default: + // TODO handle error + } + if (captchaUrl) { + this.loadScript(captchaUrl); + } + } + }); + } + + /** + * Returns an observable of string + * @param action action is the process type in which used to protect multiple spam REST calls + */ + public getRecaptchaToken(action) { + return this.captchaKey().pipe( + switchMap((key) => grecaptcha.execute(key, {action: action})) + ); + } + + /** + * Returns an observable of string + */ + public executeRecaptcha() { + return of(grecaptcha.execute()); + } + + public getRecaptchaTokenResponse() { + return grecaptcha.getResponse(); + } + + /** + * Return the google captcha ur with google captchas api key + * + * @param key contains a secret key of a google captchas + * @returns string which has google captcha url with google captchas key + */ + buildCaptchaUrl(key?: string) { + const apiUrl = 'https://www.google.com/recaptcha/api.js'; + return key ? `${apiUrl}?render=${key}` : apiUrl; + } + + /** + * Append the google captchas script to the document + * + * @param url contains a script url which will be loaded into page + * @returns A promise + */ + private loadScript(url) { + return new Promise((resolve, reject) => { + const script = this.renderer.createElement('script'); + script.type = 'text/javascript'; + script.src = url; + script.text = ``; + script.onload = resolve; + script.onerror = reject; + this.renderer.appendChild(this._document.head, script); + }); + } + + refreshCaptchaScript = () => { + this.loadRecaptchaProperties(); + }; + +} diff --git a/src/app/core/shared/registration.model.ts b/src/app/core/shared/registration.model.ts index d679eec0ff..bc4488964f 100644 --- a/src/app/core/shared/registration.model.ts +++ b/src/app/core/shared/registration.model.ts @@ -25,5 +25,12 @@ export class Registration implements UnCacheableObject { * The token linked to the registration */ token: string; - + /** + * The token linked to the registration + */ + groupNames: string[]; + /** + * The token linked to the registration + */ + groups: string[]; } diff --git a/src/app/register-email-form/register-email-form.component.html b/src/app/register-email-form/register-email-form.component.html index e47eedb6ae..cc0ce4c782 100644 --- a/src/app/register-email-form/register-email-form.component.html +++ b/src/app/register-email-form/register-email-form.component.html @@ -2,7 +2,7 @@

{{MESSAGE_PREFIX + '.header'|translate}}

{{MESSAGE_PREFIX + '.info' | translate}}

-
+
@@ -28,9 +28,30 @@
+ + +

+

{{ MESSAGE_PREFIX + '.google-recaptcha.open-cookie-settings' | translate }}

+
+ +
+ +
+ + + + + + + +
- - + + diff --git a/src/app/register-email-form/register-email-form.component.spec.ts b/src/app/register-email-form/register-email-form.component.spec.ts index a415ef4808..bac922c73b 100644 --- a/src/app/register-email-form/register-email-form.component.spec.ts +++ b/src/app/register-email-form/register-email-form.component.spec.ts @@ -1,5 +1,5 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; -import { of as observableOf } from 'rxjs'; +import { waitForAsync, ComponentFixture, TestBed, tick, fakeAsync } from '@angular/core/testing'; +import { of as observableOf, of } from 'rxjs'; import { RestResponse } from '../core/cache/response.models'; import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; @@ -14,6 +14,10 @@ import { RouterStub } from '../shared/testing/router.stub'; import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; import { RegisterEmailFormComponent } from './register-email-form.component'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { ConfigurationDataService } from '../core/data/configuration-data.service'; +import { GoogleRecaptchaService } from '../core/google-recaptcha/google-recaptcha.service'; +import { CookieService } from '../core/services/cookie.service'; +import { CookieServiceMock } from '../shared/mocks/cookie.service.mock'; describe('RegisterEmailComponent', () => { @@ -24,6 +28,22 @@ describe('RegisterEmailComponent', () => { let epersonRegistrationService: EpersonRegistrationService; let notificationsService; + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: jasmine.createSpy('findByPropertyName') + }); + + const captchaVersion$ = of('v3'); + const captchaMode$ = of('invisible'); + const confResponse$ = createSuccessfulRemoteDataObject$({ values: ['true'] }); + const confResponseDisabled$ = createSuccessfulRemoteDataObject$({ values: ['false'] }); + + const googleRecaptchaService = jasmine.createSpyObj('googleRecaptchaService', { + getRecaptchaToken: Promise.resolve('googleRecaptchaToken'), + executeRecaptcha: Promise.resolve('googleRecaptchaToken'), + getRecaptchaTokenResponse: Promise.resolve('googleRecaptchaToken'), + captchaVersion: captchaVersion$, + captchaMode: captchaMode$, + }); beforeEach(waitForAsync(() => { router = new RouterStub(); @@ -39,8 +59,11 @@ describe('RegisterEmailComponent', () => { providers: [ {provide: Router, useValue: router}, {provide: EpersonRegistrationService, useValue: epersonRegistrationService}, + {provide: ConfigurationDataService, useValue: configurationDataService}, {provide: FormBuilder, useValue: new FormBuilder()}, {provide: NotificationsService, useValue: notificationsService}, + {provide: CookieService, useValue: new CookieServiceMock()}, + {provide: GoogleRecaptchaService, useValue: googleRecaptchaService}, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); @@ -48,6 +71,9 @@ describe('RegisterEmailComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(RegisterEmailFormComponent); comp = fixture.componentInstance; + googleRecaptchaService.captchaVersion$ = captchaVersion$; + googleRecaptchaService.captchaMode$ = captchaMode$; + configurationDataService.findByPropertyName.and.returnValues(confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$); fixture.detectChanges(); }); @@ -90,4 +116,33 @@ describe('RegisterEmailComponent', () => { expect(router.navigate).not.toHaveBeenCalled(); }); }); + describe('register with google recaptcha', () => { + beforeEach(fakeAsync(() => { + configurationDataService.findByPropertyName.and.returnValues(confResponse$, confResponse$, confResponse$, confResponse$, confResponse$, confResponse$, confResponse$, confResponse$, confResponse$, confResponse$); + googleRecaptchaService.captchaVersion$ = captchaVersion$; + googleRecaptchaService.captchaMode$ = captchaMode$; + comp.ngOnInit(); + fixture.detectChanges(); + })); + + it('should send a registration to the service and on success display a message and return to home', fakeAsync(() => { + comp.form.patchValue({email: 'valid@email.org'}); + comp.register(); + tick(); + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', 'googleRecaptchaToken'); + expect(notificationsService.success).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/home']); + })); + it('should send a registration to the service and on error display a message', fakeAsync(() => { + (epersonRegistrationService.registerEmail as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 400, 'Bad Request'))); + + comp.form.patchValue({email: 'valid@email.org'}); + + comp.register(); + tick(); + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', 'googleRecaptchaToken'); + expect(notificationsService.error).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + })); + }); }); diff --git a/src/app/register-email-form/register-email-form.component.ts b/src/app/register-email-form/register-email-form.component.ts index d40629f597..ced87b9e75 100644 --- a/src/app/register-email-form/register-email-form.component.ts +++ b/src/app/register-email-form/register-email-form.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnInit, Optional } from '@angular/core'; import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; @@ -6,6 +6,16 @@ import { Router } from '@angular/router'; import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { Registration } from '../core/shared/registration.model'; import { RemoteData } from '../core/data/remote-data'; +import { ConfigurationDataService } from '../core/data/configuration-data.service'; +import { getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; +import { ConfigurationProperty } from '../core/shared/configuration-property.model'; +import { isNotEmpty } from '../shared/empty.util'; +import { BehaviorSubject, combineLatest, Observable, of, switchMap } from 'rxjs'; +import { map, startWith, take } from 'rxjs/operators'; +import { CAPTCHA_NAME, GoogleRecaptchaService } from '../core/google-recaptcha/google-recaptcha.service'; +import { AlertType } from '../shared/alert/aletr-type'; +import { KlaroService } from '../shared/cookies/klaro.service'; +import { CookieService } from '../core/services/cookie.service'; @Component({ selector: 'ds-register-email-form', @@ -27,12 +37,40 @@ export class RegisterEmailFormComponent implements OnInit { @Input() MESSAGE_PREFIX: string; + public AlertTypeEnum = AlertType; + + /** + * registration verification configuration + */ + registrationVerification = false; + + /** + * Return true if the user completed the reCaptcha verification (checkbox mode) + */ + checkboxCheckedSubject$ = new BehaviorSubject(false); + + disableUntilChecked = true; + + captchaVersion(): Observable { + return this.googleRecaptchaService.captchaVersion(); + } + + captchaMode(): Observable { + return this.googleRecaptchaService.captchaMode(); + } + constructor( private epersonRegistrationService: EpersonRegistrationService, private notificationService: NotificationsService, private translateService: TranslateService, private router: Router, - private formBuilder: FormBuilder + private formBuilder: FormBuilder, + private configService: ConfigurationDataService, + public googleRecaptchaService: GoogleRecaptchaService, + public cookieService: CookieService, + @Optional() public klaroService: KlaroService, + private changeDetectorRef: ChangeDetectorRef, + private notificationsService: NotificationsService, ) { } @@ -45,30 +83,127 @@ export class RegisterEmailFormComponent implements OnInit { ], }) }); + this.configService.findByPropertyName('registration.verification.enabled').pipe( + getFirstSucceededRemoteDataPayload(), + map((res: ConfigurationProperty) => res?.values[0].toLowerCase() === 'true') + ).subscribe((res: boolean) => { + this.registrationVerification = res; + }); + this.disableUntilCheckedFcn().subscribe((res) => { + this.disableUntilChecked = res; + this.changeDetectorRef.detectChanges(); + }); + + } + + /** + * execute the captcha function for v2 invisible + */ + executeRecaptcha() { + this.googleRecaptchaService.executeRecaptcha(); } /** * Register an email address */ - register() { + register(tokenV2?) { if (!this.form.invalid) { - this.epersonRegistrationService.registerEmail(this.email.value).subscribe((response: RemoteData) => { - if (response.hasSucceeded) { - this.notificationService.success(this.translateService.get(`${this.MESSAGE_PREFIX}.success.head`), - this.translateService.get(`${this.MESSAGE_PREFIX}.success.content`, {email: this.email.value})); - this.router.navigate(['/home']); - } else { - this.notificationService.error(this.translateService.get(`${this.MESSAGE_PREFIX}.error.head`), - this.translateService.get(`${this.MESSAGE_PREFIX}.error.content`, {email: this.email.value})); + if (this.registrationVerification) { + combineLatest([this.captchaVersion(), this.captchaMode()]).pipe( + switchMap(([captchaVersion, captchaMode]) => { + if (captchaVersion === 'v3') { + return this.googleRecaptchaService.getRecaptchaToken('register_email'); + } else if (captchaVersion === 'v2' && captchaMode === 'checkbox') { + return of(this.googleRecaptchaService.getRecaptchaTokenResponse()); + } else if (captchaVersion === 'v2' && captchaMode === 'invisible') { + return of(tokenV2); + } else { + console.error(`Invalid reCaptcha configuration: version = ${captchaVersion}, mode = ${captchaMode}`); + this.showNotification('error'); + } + }), + take(1), + ).subscribe((token) => { + if (isNotEmpty(token)) { + this.registration(token); + } else { + console.error('reCaptcha error'); + this.showNotification('error'); + } } - } - ); + ); + } else { + this.registration(); + } } } + /** + * Registration of an email address + */ + registration(captchaToken = null) { + let registerEmail$ = captchaToken ? + this.epersonRegistrationService.registerEmail(this.email.value, captchaToken) : + this.epersonRegistrationService.registerEmail(this.email.value); + registerEmail$.subscribe((response: RemoteData) => { + if (response.hasSucceeded) { + this.notificationService.success(this.translateService.get(`${this.MESSAGE_PREFIX}.success.head`), + this.translateService.get(`${this.MESSAGE_PREFIX}.success.content`, {email: this.email.value})); + this.router.navigate(['/home']); + } else { + this.notificationService.error(this.translateService.get(`${this.MESSAGE_PREFIX}.error.head`), + this.translateService.get(`${this.MESSAGE_PREFIX}.error.content`, {email: this.email.value})); + } + }); + } + + /** + * Return true if the user has accepted the required cookies for reCaptcha + */ + isRecaptchaCookieAccepted(): boolean { + const klaroAnonymousCookie = this.cookieService.get('klaro-anonymous'); + return isNotEmpty(klaroAnonymousCookie) ? klaroAnonymousCookie[CAPTCHA_NAME] : false; + } + + /** + * Return true if the user has not completed the reCaptcha verification (checkbox mode) + */ + disableUntilCheckedFcn(): Observable { + const checked$ = this.checkboxCheckedSubject$.asObservable(); + return combineLatest([this.captchaVersion(), this.captchaMode(), checked$]).pipe( + // disable if checkbox is not checked or if reCaptcha is not in v2 checkbox mode + switchMap(([captchaVersion, captchaMode, checked]) => captchaVersion === 'v2' && captchaMode === 'checkbox' ? of(!checked) : of(false)), + startWith(true), + ); + } + get email() { return this.form.get('email'); } + onCheckboxChecked(checked: boolean) { + this.checkboxCheckedSubject$.next(checked); + } + + /** + * Show a notification to the user + * @param key + */ + showNotification(key) { + const notificationTitle = this.translateService.get(this.MESSAGE_PREFIX + '.google-recaptcha.notification.title'); + const notificationErrorMsg = this.translateService.get(this.MESSAGE_PREFIX + '.google-recaptcha.notification.message.error'); + const notificationExpiredMsg = this.translateService.get(this.MESSAGE_PREFIX + '.google-recaptcha.notification.message.expired'); + switch (key) { + case 'expired': + this.notificationsService.warning(notificationTitle, notificationExpiredMsg); + break; + case 'error': + this.notificationsService.error(notificationTitle, notificationErrorMsg); + break; + default: + console.warn(`Unimplemented notification '${key}' from reCaptcha service`); + } + } + } diff --git a/src/app/register-email-form/register-email-form.module.ts b/src/app/register-email-form/register-email-form.module.ts index f19e869beb..a765759413 100644 --- a/src/app/register-email-form/register-email-form.module.ts +++ b/src/app/register-email-form/register-email-form.module.ts @@ -6,7 +6,7 @@ import { RegisterEmailFormComponent } from './register-email-form.component'; @NgModule({ imports: [ CommonModule, - SharedModule, + SharedModule ], declarations: [ RegisterEmailFormComponent, diff --git a/src/app/shared/cookies/browser-klaro.service.spec.ts b/src/app/shared/cookies/browser-klaro.service.spec.ts index c7b08a45c9..9db9caf364 100644 --- a/src/app/shared/cookies/browser-klaro.service.spec.ts +++ b/src/app/shared/cookies/browser-klaro.service.spec.ts @@ -21,6 +21,8 @@ describe('BrowserKlaroService', () => { const trackingIdProp = 'google.analytics.key'; const trackingIdTestValue = 'mock-tracking-id'; const googleAnalytics = 'google-analytics'; + const recaptchaProp = 'registration.verification.enabled'; + const recaptchaValue = 'true'; let translateService; let ePersonService; let authService; @@ -31,8 +33,8 @@ describe('BrowserKlaroService', () => { let configurationDataService: ConfigurationDataService; const createConfigSuccessSpy = (...values: string[]) => jasmine.createSpyObj('configurationDataService', { findByPropertyName: createSuccessfulRemoteDataObject$({ - ...new ConfigurationProperty(), - name: trackingIdProp, + ... new ConfigurationProperty(), + name: recaptchaProp, values: values, }), }); @@ -57,7 +59,7 @@ describe('BrowserKlaroService', () => { isAuthenticated: observableOf(true), getAuthenticatedUserFromStore: observableOf(user) }); - configurationDataService = createConfigSuccessSpy(trackingIdTestValue); + configurationDataService = createConfigSuccessSpy(recaptchaValue); findByPropertyName = configurationDataService.findByPropertyName; cookieService = jasmine.createSpyObj('cookieService', { get: '{%22token_item%22:true%2C%22impersonation%22:true%2C%22redirect%22:true%2C%22language%22:true%2C%22klaro%22:true%2C%22has_agreed_end_user%22:true%2C%22google-analytics%22:true}', @@ -298,15 +300,18 @@ describe('BrowserKlaroService', () => { describe('initialize google analytics configuration', () => { let GOOGLE_ANALYTICS_KEY; + let REGISTRATION_VERIFICATION_ENABLED_KEY; beforeEach(() => { GOOGLE_ANALYTICS_KEY = clone((service as any).GOOGLE_ANALYTICS_KEY); - configurationDataService.findByPropertyName = findByPropertyName; + REGISTRATION_VERIFICATION_ENABLED_KEY = clone((service as any).REGISTRATION_VERIFICATION_ENABLED_KEY); spyOn((service as any), 'getUser$').and.returnValue(observableOf(user)); translateService.get.and.returnValue(observableOf('loading...')); spyOn(service, 'addAppMessages'); spyOn((service as any), 'initializeUser'); spyOn(service, 'translateConfiguration'); + configurationDataService.findByPropertyName = findByPropertyName; }); + it('should not filter googleAnalytics when servicesToHide are empty', () => { const filteredConfig = (service as any).filterConfigServices([]); expect(filteredConfig).toContain(jasmine.objectContaining({ name: googleAnalytics })); @@ -316,31 +321,75 @@ describe('BrowserKlaroService', () => { expect(filteredConfig).not.toContain(jasmine.objectContaining({ name: googleAnalytics })); }); it('should have been initialized with googleAnalytics', () => { + configurationDataService.findByPropertyName = jasmine.createSpy('configurationDataService').and.returnValue( + createSuccessfulRemoteDataObject$({ + ...new ConfigurationProperty(), + name: trackingIdProp, + values: [googleAnalytics], + }) + ); service.initialize(); expect(service.klaroConfig.services).toContain(jasmine.objectContaining({ name: googleAnalytics })); }); it('should filter googleAnalytics when empty configuration is retrieved', () => { - configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue( - createSuccessfulRemoteDataObject$({ - ...new ConfigurationProperty(), - name: googleAnalytics, - values: [], - })); + configurationDataService.findByPropertyName = + jasmine.createSpy() + .withArgs(GOOGLE_ANALYTICS_KEY) + .and + .returnValue( + createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: googleAnalytics, + values: [], + } + ) + ) + .withArgs(REGISTRATION_VERIFICATION_ENABLED_KEY) + .and + .returnValue( + createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: trackingIdTestValue, + values: ['false'], + }) + ); service.initialize(); expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics })); }); it('should filter googleAnalytics when an error occurs', () => { - configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue( - createFailedRemoteDataObject$('Erro while loading GA') - ); + configurationDataService.findByPropertyName = + jasmine.createSpy() + .withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue( + createFailedRemoteDataObject$('Error while loading GA') + ) + .withArgs(REGISTRATION_VERIFICATION_ENABLED_KEY) + .and + .returnValue( + createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: trackingIdTestValue, + values: ['false'], + }) + ); service.initialize(); expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics })); }); it('should filter googleAnalytics when an invalid payload is retrieved', () => { - configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue( - createSuccessfulRemoteDataObject$(null) - ); + configurationDataService.findByPropertyName = + jasmine.createSpy() + .withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue( + createSuccessfulRemoteDataObject$(null) + ) + .withArgs(REGISTRATION_VERIFICATION_ENABLED_KEY) + .and + .returnValue( + createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: trackingIdTestValue, + values: ['false'], + }) + ); service.initialize(); expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics })); }); diff --git a/src/app/shared/cookies/browser-klaro.service.ts b/src/app/shared/cookies/browser-klaro.service.ts index 0648afd17a..c6819012d9 100644 --- a/src/app/shared/cookies/browser-klaro.service.ts +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -15,6 +15,7 @@ import { ANONYMOUS_STORAGE_NAME_KLARO, klaroConfiguration } from './klaro-config import { Operation } from 'fast-json-patch'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { CAPTCHA_NAME } from '../../core/google-recaptcha/google-recaptcha.service'; /** * Metadata field to store a user's cookie consent preferences in @@ -49,6 +50,8 @@ export class BrowserKlaroService extends KlaroService { private readonly GOOGLE_ANALYTICS_KEY = 'google.analytics.key'; + private readonly REGISTRATION_VERIFICATION_ENABLED_KEY = 'registration.verification.enabled'; + private readonly GOOGLE_ANALYTICS_SERVICE_NAME = 'google-analytics'; /** @@ -78,15 +81,30 @@ export class BrowserKlaroService extends KlaroService { this.klaroConfig.translations.en.consentNotice.description = 'cookies.consent.content-notice.description.no-privacy'; } - const servicesToHide$: Observable = this.configService.findByPropertyName(this.GOOGLE_ANALYTICS_KEY).pipe( + const hideGoogleAnalytics$ = this.configService.findByPropertyName(this.GOOGLE_ANALYTICS_KEY).pipe( getFirstCompletedRemoteData(), - map(remoteData => { - if (!remoteData.hasSucceeded || !remoteData.payload || isEmpty(remoteData.payload.values)) { - return [this.GOOGLE_ANALYTICS_SERVICE_NAME]; - } else { - return []; + map(remoteData => !remoteData.hasSucceeded || !remoteData.payload || isEmpty(remoteData.payload.values)), + ); + + const hideRegistrationVerification$ = this.configService.findByPropertyName(this.REGISTRATION_VERIFICATION_ENABLED_KEY).pipe( + getFirstCompletedRemoteData(), + map((remoteData) => + !remoteData.hasSucceeded || !remoteData.payload || isEmpty(remoteData.payload.values) || remoteData.payload.values[0].toLowerCase() !== 'true' + ), + ); + + const servicesToHide$: Observable = observableCombineLatest([hideGoogleAnalytics$, hideRegistrationVerification$]).pipe( + map(([hideGoogleAnalytics, hideRegistrationVerification]) => { + let servicesToHideArray: string[] = []; + if (hideGoogleAnalytics) { + servicesToHideArray.push(this.GOOGLE_ANALYTICS_SERVICE_NAME); } - }), + if (hideRegistrationVerification) { + servicesToHideArray.push(CAPTCHA_NAME); + } + console.log(servicesToHideArray); + return servicesToHideArray; + }) ); this.translateService.setDefaultLang(environment.defaultLanguage); @@ -308,4 +326,5 @@ export class BrowserKlaroService extends KlaroService { private filterConfigServices(servicesToHide: string[]): Pick[] { return this.klaroConfig.services.filter(service => !servicesToHide.some(name => name === service.name)); } + } diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts index fb7c660322..8a9855bd89 100644 --- a/src/app/shared/cookies/klaro-configuration.ts +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -1,6 +1,7 @@ import { TOKENITEM } from '../../core/auth/models/auth-token-info.model'; import { IMPERSONATING_COOKIE, REDIRECT_COOKIE } from '../../core/auth/auth.service'; import { LANG_COOKIE } from '../../core/locale/locale.service'; +import { CAPTCHA_COOKIE, CAPTCHA_NAME } from '../../core/google-recaptcha/google-recaptcha.service'; /** * Cookie for has_agreed_end_user @@ -157,5 +158,17 @@ export const klaroConfiguration: any = { */ onlyOnce: true, }, + { + name: CAPTCHA_NAME, + purposes: ['registration-password-recovery'], + required: false, + cookies: [ + [/^klaro-.+$/], + CAPTCHA_COOKIE + ], + onAccept: `window.refreshCaptchaScript?.call()`, + onDecline: `window.refreshCaptchaScript?.call()`, + onlyOnce: true, + } ], }; diff --git a/src/app/shared/google-recaptcha/google-recaptcha.component.html b/src/app/shared/google-recaptcha/google-recaptcha.component.html new file mode 100644 index 0000000000..64c05cb739 --- /dev/null +++ b/src/app/shared/google-recaptcha/google-recaptcha.component.html @@ -0,0 +1,6 @@ +
diff --git a/src/app/shared/google-recaptcha/google-recaptcha.component.scss b/src/app/shared/google-recaptcha/google-recaptcha.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/google-recaptcha/google-recaptcha.component.spec.ts b/src/app/shared/google-recaptcha/google-recaptcha.component.spec.ts new file mode 100644 index 0000000000..67f66c9757 --- /dev/null +++ b/src/app/shared/google-recaptcha/google-recaptcha.component.spec.ts @@ -0,0 +1,50 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NativeWindowService } from '../../core/services/window.service'; + +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { NativeWindowMockFactory } from '../mocks/mock-native-window-ref'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { GoogleRecaptchaComponent } from './google-recaptcha.component'; + +describe('GoogleRecaptchaComponent', () => { + + let component: GoogleRecaptchaComponent; + + let fixture: ComponentFixture; + + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: jasmine.createSpy('findByPropertyName') + }); + + const confResponse$ = createSuccessfulRemoteDataObject$({ values: ['valid-google-recaptcha-key'] }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ GoogleRecaptchaComponent ], + providers: [ + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + ] + }) + .compileComponents(); + }); + + + beforeEach(() => { + fixture = TestBed.createComponent(GoogleRecaptchaComponent); + component = fixture.componentInstance; + configurationDataService.findByPropertyName.and.returnValues(confResponse$); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should rendered google recaptcha.',() => { + const container = fixture.debugElement.query(By.css('.g-recaptcha')); + expect(container).toBeTruthy(); + }); +}); diff --git a/src/app/shared/google-recaptcha/google-recaptcha.component.ts b/src/app/shared/google-recaptcha/google-recaptcha.component.ts new file mode 100644 index 0000000000..16c49ba45b --- /dev/null +++ b/src/app/shared/google-recaptcha/google-recaptcha.component.ts @@ -0,0 +1,70 @@ +import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; + +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { Observable } from 'rxjs'; +import { NativeWindowRef, NativeWindowService } from 'src/app/core/services/window.service'; +import { isNotEmpty } from '../empty.util'; + +@Component({ + selector: 'ds-google-recaptcha', + templateUrl: './google-recaptcha.component.html', + styleUrls: ['./google-recaptcha.component.scss'], +}) +export class GoogleRecaptchaComponent implements OnInit { + + @Input() captchaMode: string; + + /** + * An EventEmitter that's fired whenever the form is being submitted + */ + @Output() executeRecaptcha: EventEmitter = new EventEmitter(); + + @Output() checkboxChecked: EventEmitter = new EventEmitter(); + + @Output() showNotification: EventEmitter = new EventEmitter(); + + recaptchaKey$: Observable; + + constructor( + @Inject(NativeWindowService) private _window: NativeWindowRef, + private configService: ConfigurationDataService, + ) { + } + + /** + * Retrieve the google recaptcha site key + */ + ngOnInit() { + this.recaptchaKey$ = this.configService.findByPropertyName('google.recaptcha.key.site').pipe( + getFirstSucceededRemoteDataPayload(), + ); + this._window.nativeWindow.dataCallback = this.dataCallbackFcn; + this._window.nativeWindow.expiredCallback = this.expiredCallbackFcn; + this._window.nativeWindow.errorCallback = this.errorCallbackFcn; + } + + dataCallbackFcn = ($event) => { + switch (this.captchaMode) { + case 'invisible': + this.executeRecaptcha.emit($event); + break; + case 'checkbox': + this.checkboxChecked.emit(isNotEmpty($event)); + break; + default: + console.error(`Invalid reCaptcha mode '${this.captchaMode}`); + this.showNotification.emit('error'); + } + }; + + expiredCallbackFcn = () => { + this.checkboxChecked.emit(false); + this.showNotification.emit('expired'); + }; + + errorCallbackFcn = () => { + this.showNotification.emit('error'); + }; + +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 9f9f937d59..45e9764151 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -323,6 +323,7 @@ import { ItemPageTitleFieldComponent } from '../item-page/simple/field-components/specific-field/title/item-page-title-field.component'; import { MarkdownPipe } from './utils/markdown.pipe'; +import { GoogleRecaptchaModule } from '../core/google-recaptcha/google-recaptcha.module'; const MODULES = [ CommonModule, @@ -343,7 +344,8 @@ const MODULES = [ NouisliderModule, MomentModule, DragDropModule, - CdkTreeModule + CdkTreeModule, + GoogleRecaptchaModule, ]; const ROOT_MODULES = [ diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index d73d4ae5e8..597f226cc7 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1279,10 +1279,20 @@ + "cookies.consent.app.title.google-recaptcha": "Google reCaptcha", + + "cookies.consent.app.description.google-recaptcha": "We use google reCAPTCHA service during registration and password recovery", + + + "cookies.consent.purpose.functional": "Functional", "cookies.consent.purpose.statistical": "Statistical", + "cookies.consent.purpose.registration-password-recovery": "Registration and Password recovery", + + "cookies.consent.purpose.sharing": "Sharing", + "curation-task.task.citationpage.label": "Generate Citation Page", "curation-task.task.checklinks.label": "Check Links in Metadata", @@ -3248,7 +3258,17 @@ "register-page.registration.error.content": "An error occured when registering the following email address: {{ email }}", + "register-page.registration.error.recaptcha": "Error when trying to authenticate with recaptcha", + "register-page.registration.google-recaptcha.must-accept-cookies": "In order to register you must accept the Registration and Password recovery (Google reCaptcha) cookies.", + + "register-page.registration.google-recaptcha.open-cookie-settings": "Open cookie settings", + + "register-page.registration.google-recaptcha.notification.title": "Google reCaptcha", + + "register-page.registration.google-recaptcha.notification.message.error": "An error occurred during reCaptcha verification", + + "register-page.registration.google-recaptcha.notification.message.expired": "Verification expired. Please verify again.", "relationships.add.error.relationship-type.content": "No suitable match could be found for relationship type {{ type }} between the two items", diff --git a/tsconfig.app.json b/tsconfig.app.json index f67e8b25d8..c89834db4a 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", - "types": [] + "types": ["grecaptcha"] }, "files": [ "src/main.browser.ts", diff --git a/tsconfig.server.json b/tsconfig.server.json index 76be339fca..d2fb2c9d40 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -4,7 +4,8 @@ "outDir": "./out-tsc/app-server", "target": "es2016", "types": [ - "node" + "node", + "grecaptcha" ] }, "files": [ diff --git a/tsconfig.spec.json b/tsconfig.spec.json index 05d55a31f8..0d92677545 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -5,7 +5,8 @@ "outDir": "./out-tsc/spec", "types": [ "jasmine", - "node" + "node", + "grecaptcha" ] }, "files": [ diff --git a/yarn.lock b/yarn.lock index 3aa0c91b3f..9542bfdbe1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2247,6 +2247,11 @@ resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.5.tgz#9ee342a5d1314bb0928375424a2f162f97c310c7" integrity sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ== +"@types/grecaptcha@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/grecaptcha/-/grecaptcha-3.0.4.tgz#3de601f3b0cd0298faf052dd5bd62aff64c2be2e" + integrity sha512-7l1Y8DTGXkx/r4pwU1nMVAR+yD/QC+MCHKXAyEX/7JZhwcN1IED09aZ9vCjjkcGdhSQiu/eJqcXInpl6eEEEwg== + "@types/hoist-non-react-statics@^3.3.0", "@types/hoist-non-react-statics@^3.3.1": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"