diff --git a/e2e/cypress/integration/framework/index.ts b/e2e/cypress/integration/framework/index.ts index 71ffccfff3..a6d92d78b6 100644 --- a/e2e/cypress/integration/framework/index.ts +++ b/e2e/cypress/integration/framework/index.ts @@ -32,16 +32,15 @@ export function fillFormField(parent: string, key: string, value: number | strin const field = form.find(`[data-testing-id="${key}"]`); expect(field.length).to.equal(1, `expected to find one form field "${key}" in "${parent}"`); const tagName = field.prop('tagName'); - expect(tagName).to.match(/^(INPUT|SELECT)$/); + expect(tagName).to.match(/^(INPUT|SELECT|TEXTAREA)$/); cy.get(parent).within(() => { - if (tagName === 'INPUT') { + if (/^(INPUT|TEXTAREA)$/.test(tagName)) { const inputField = cy.get(`[data-testing-id="${key}"]`); inputField.clear(); if (value) { - inputField.type(value.toString()); + inputField.focus().type(value.toString()); } - inputField.blur(); } else if (tagName === 'SELECT') { if (typeof value === 'number') { cy.get(`[data-testing-id="${key}"]`) diff --git a/e2e/cypress/integration/pages/account/registration.page.ts b/e2e/cypress/integration/pages/account/registration.page.ts index e06d033386..ace3c8d462 100644 --- a/e2e/cypress/integration/pages/account/registration.page.ts +++ b/e2e/cypress/integration/pages/account/registration.page.ts @@ -74,6 +74,15 @@ export class RegistrationPage { .forEach((key: keyof Registration) => { fillFormField(this.tag, key, register[key]); }); + + // special handling as captcha component steals focus + if (register.login) { + fillFormField(this.tag, 'login', register.login); + } + if (register.password) { + fillFormField(this.tag, 'password', register.password); + } + return this; } diff --git a/e2e/cypress/integration/pages/contact/contact.page.ts b/e2e/cypress/integration/pages/contact/contact.page.ts index d97ac8f01c..1dd578b8d3 100644 --- a/e2e/cypress/integration/pages/contact/contact.page.ts +++ b/e2e/cypress/integration/pages/contact/contact.page.ts @@ -1,5 +1,14 @@ +import { fillFormField } from '../../framework'; import { BreadcrumbModule } from '../breadcrumb.module'; +declare interface ContactForm { + name: string; + email: string; + phone: string; + subject: string; + comment: string; +} + export class ContactPage { readonly tag = 'ish-contact-page'; readonly breadcrumb = new BreadcrumbModule(); @@ -18,17 +27,13 @@ export class ContactPage { return cy.get('input[data-testing-id="email"]'); } - get phoneInput() { - return cy.get('input[data-testing-id="phone"]'); - } + fillForm(content: ContactForm) { + Object.keys(content) + .filter(key => content[key] !== undefined) + .forEach((key: keyof ContactForm) => { + fillFormField(this.tag, key, content[key]); + }); - fillForm(name: string, email: string, phone: string, subject: string, comments: string) { - this.nameInput.clear().type(name).blur(); - this.emailInput.clear().type(email).blur(); - this.phoneInput.clear().type(phone).blur(); - // tslint:disable-next-line:ban - cy.get('select[data-testing-id="subject"]').select(subject); - cy.get('textarea[data-testing-id="comments"]').clear().type(comments).blur(); return this; } diff --git a/e2e/cypress/integration/specs/account/contact.b2c.e2e-spec.ts b/e2e/cypress/integration/specs/account/contact.b2c.e2e-spec.ts index 864b4ca5c6..3f559cfce8 100644 --- a/e2e/cypress/integration/specs/account/contact.b2c.e2e-spec.ts +++ b/e2e/cypress/integration/specs/account/contact.b2c.e2e-spec.ts @@ -7,13 +7,18 @@ const _ = { email: 'patricia@test.intershop.de', password: '!InterShop00!', fullName: 'Patricia Miller', + formContent: { + phone: '12345', + subject: 'Returns', + comment: "Don't fit.", + }, }; describe('Contact', () => { it('anonymous user should send the contact successfully', () => { ContactPage.navigateTo(); at(ContactPage, page => { - page.fillForm(_.fullName, _.email, '12345', 'Returns', "Don't fit."); + page.fillForm({ ..._.formContent, email: _.email, name: _.fullName }); page.submit(); }); at(ContactConfirmationPage, page => { diff --git a/src/app/core/configuration.module.ts b/src/app/core/configuration.module.ts index 5c8df6f4b7..a39bfc6b24 100644 --- a/src/app/core/configuration.module.ts +++ b/src/app/core/configuration.module.ts @@ -3,7 +3,6 @@ import localeDe from '@angular/common/locales/de'; import localeFr from '@angular/common/locales/fr'; import { Inject, LOCALE_ID, NgModule } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { RECAPTCHA_V3_SITE_KEY } from 'ng-recaptcha'; import { environment } from '../../environments/environment'; @@ -14,8 +13,6 @@ import { ThemeService } from './utils/theme/theme.service'; @NgModule({ imports: [FeatureToggleModule], providers: [ - // tslint:disable-next-line:no-string-literal - { provide: RECAPTCHA_V3_SITE_KEY, useValue: environment['captchaSiteKey'] }, // tslint:disable-next-line:no-string-literal { provide: injectionKeys.MOCK_SERVER_API, useValue: environment['mockServerAPI'] }, // tslint:disable-next-line:no-string-literal @@ -28,8 +25,6 @@ import { ThemeService } from './utils/theme/theme.service'; { provide: injectionKeys.DEFAULT_PRODUCT_LISTING_VIEW_TYPE, useValue: environment.defaultProductListingViewType }, // TODO: get from REST call { provide: injectionKeys.USER_REGISTRATION_LOGIN_TYPE, useValue: 'email' }, - // tslint:disable-next-line:no-string-literal - { provide: injectionKeys.CAPTCHA_SITE_KEY, useValue: environment['captchaSiteKey'] }, { provide: injectionKeys.SMALL_BREAKPOINT_WIDTH, useValue: environment.smallBreakpointWidth }, { provide: injectionKeys.MEDIUM_BREAKPOINT_WIDTH, useValue: environment.mediumBreakpointWidth }, { provide: injectionKeys.LARGE_BREAKPOINT_WIDTH, useValue: environment.largeBreakpointWidth }, diff --git a/src/app/core/configurations/injection-keys.ts b/src/app/core/configurations/injection-keys.ts index 3d30d28c20..d32bb038c8 100644 --- a/src/app/core/configurations/injection-keys.ts +++ b/src/app/core/configurations/injection-keys.ts @@ -36,11 +36,6 @@ export const MEDIUM_BREAKPOINT_WIDTH = new InjectionToken('mediumBreakpo export const LARGE_BREAKPOINT_WIDTH = new InjectionToken('largeBreakpointWidth'); export const EXTRALARGE_BREAKPOINT_WIDTH = new InjectionToken('extralargeBreakpointWidth'); -/** - * The captcha configuration siteKey - */ -export const CAPTCHA_SITE_KEY = new InjectionToken('captchaSiteKey'); - /** * The configured theme for the application (or 'default' if not configured) */ diff --git a/src/app/core/models/captcha/captcha.model.ts b/src/app/core/models/captcha/captcha.model.ts new file mode 100644 index 0000000000..e76f869ed5 --- /dev/null +++ b/src/app/core/models/captcha/captcha.model.ts @@ -0,0 +1,7 @@ +/** + * The contact request to send, when a customer want to get in contact with the shop + */ +export interface Captcha { + captcha?: string; + captchaAction?: string; +} diff --git a/src/app/core/models/contact/contact.model.ts b/src/app/core/models/contact/contact.model.ts index 1d02a3e132..da015c3060 100644 --- a/src/app/core/models/contact/contact.model.ts +++ b/src/app/core/models/contact/contact.model.ts @@ -1,7 +1,9 @@ +import { Captcha } from 'ish-core/models/captcha/captcha.model'; + /** * The contact request to send, when a customer want to get in contact with the shop */ -export interface Contact { +export interface Contact extends Captcha { name: string; type?: string; email: string; diff --git a/src/app/core/models/customer/customer.model.ts b/src/app/core/models/customer/customer.model.ts index 0587358a69..4d6fce1616 100644 --- a/src/app/core/models/customer/customer.model.ts +++ b/src/app/core/models/customer/customer.model.ts @@ -1,4 +1,5 @@ import { Address } from 'ish-core/models/address/address.model'; +import { Captcha } from 'ish-core/models/captcha/captcha.model'; import { Credentials } from 'ish-core/models/credentials/credentials.model'; import { User } from 'ish-core/models/user/user.model'; @@ -29,9 +30,7 @@ export interface CustomerUserType { /** * registration request data type */ -export interface CustomerRegistrationType extends CustomerUserType { +export interface CustomerRegistrationType extends CustomerUserType, Captcha { credentials: Credentials; address: Address; - captchaResponse?: string; - captchaAction?: string; } diff --git a/src/app/core/models/password-reminder/password-reminder.model.ts b/src/app/core/models/password-reminder/password-reminder.model.ts index 9e470189e3..0ed747b898 100644 --- a/src/app/core/models/password-reminder/password-reminder.model.ts +++ b/src/app/core/models/password-reminder/password-reminder.model.ts @@ -1,8 +1,8 @@ -export interface PasswordReminder { +import { Captcha } from 'ish-core/models/captcha/captcha.model'; + +export interface PasswordReminder extends Captcha { email: string; firstName: string; lastName: string; answer?: string; - captcha?: string; - captchaAction?: string; } diff --git a/src/app/core/models/server-config/server-config.interface.ts b/src/app/core/models/server-config/server-config.interface.ts index 2f768bf977..46d4aa5dfc 100644 --- a/src/app/core/models/server-config/server-config.interface.ts +++ b/src/app/core/models/server-config/server-config.interface.ts @@ -1,7 +1,7 @@ export interface ServerConfigDataEntry { id: string; elements?: ServerConfigDataEntry[]; - [key: string]: string | boolean | string[] | ServerConfigDataEntry[]; + [key: string]: string | boolean | number | string[] | ServerConfigDataEntry[]; } export interface ServerConfigData { diff --git a/src/app/core/models/server-config/server-config.mapper.spec.ts b/src/app/core/models/server-config/server-config.mapper.spec.ts index decd0acb8a..5e880632a2 100644 --- a/src/app/core/models/server-config/server-config.mapper.spec.ts +++ b/src/app/core/models/server-config/server-config.mapper.spec.ts @@ -13,8 +13,8 @@ describe('Server Config Mapper', () => { id: 'services', elements: [ { id: 'captcha', siteKey: 'ASDF' }, - { id: 'gtm', token: 'QWERTY', monitor: true }, - { id: 'deeper', elements: [{ id: 'hidden', foo: 'bar' }] }, + { id: 'gtm', token: 'QWERTY', monitor: 'true' }, + { id: 'deeper', elements: [{ id: 'hidden', foo: 'bar', num: 123, alt: '123' }] }, ], }, ], @@ -41,7 +41,9 @@ describe('Server Config Mapper', () => { }, "deeper": Object { "hidden": Object { + "alt": 123, "foo": "bar", + "num": 123, }, }, "gtm": Object { diff --git a/src/app/core/models/server-config/server-config.mapper.ts b/src/app/core/models/server-config/server-config.mapper.ts index 237b0d2087..e56426b729 100644 --- a/src/app/core/models/server-config/server-config.mapper.ts +++ b/src/app/core/models/server-config/server-config.mapper.ts @@ -1,9 +1,22 @@ -import { omit } from 'lodash-es'; +import { mapValues, omit } from 'lodash-es'; import { ServerConfigData, ServerConfigDataEntry } from './server-config.interface'; import { ServerConfig } from './server-config.model'; export class ServerConfigMapper { + private static transformType(val) { + if (typeof val === 'string') { + if (!isNaN(+val)) { + return +val; + } else if (val === 'true') { + return true; + } else if (val === 'false') { + return false; + } + } + return val; + } + private static mapEntries(entries: ServerConfigDataEntry[]) { return entries.reduce( (acc, entry) => ({ @@ -11,8 +24,12 @@ export class ServerConfigMapper { [entry.id]: Array.isArray(entry.elements) ? // do recursion if elements array is set ServerConfigMapper.mapEntries(entry.elements) - : // filter out unnecessary 'id' attribute - omit(entry, 'id'), + : mapValues( + // filter out unnecessary 'id' attribute + omit(entry, 'id'), + // transform string types to better values + ServerConfigMapper.transformType + ), }), {} ); diff --git a/src/app/core/services/api/api.service.spec.ts b/src/app/core/services/api/api.service.spec.ts index 6a78bc105c..dc20685331 100644 --- a/src/app/core/services/api/api.service.spec.ts +++ b/src/app/core/services/api/api.service.spec.ts @@ -487,5 +487,32 @@ describe('Api Service', () => { const req = httpTestingController.expectOne(`${REST_URL}/dummy`); expect(req.request.headers.has(ApiService.TOKEN_HEADER_KEY)).toBeFalse(); }); + + it('should set Captcha V2 authorization header key when captcha is supplied without captchaAction', () => { + apiService.get('dummy', { captcha: { captcha: 'captchatoken' } }).subscribe(fail, fail, fail); + + const req = httpTestingController.expectOne(`${REST_URL}/dummy`); + expect(req.request.headers.get(ApiService.AUTHORIZATION_HEADER_KEY)).toMatchInlineSnapshot( + `"CAPTCHA g-recaptcha-response=captchatoken foo=bar"` + ); + }); + + it('should set Captcha V3 authorization header key when captcha is supplied', () => { + apiService + .get('dummy', { captcha: { captcha: 'captchatoken', captchaAction: 'create_account' } }) + .subscribe(fail, fail, fail); + + const req = httpTestingController.expectOne(`${REST_URL}/dummy`); + expect(req.request.headers.get(ApiService.AUTHORIZATION_HEADER_KEY)).toMatchInlineSnapshot( + `"CAPTCHA recaptcha_token=captchatoken action=create_account"` + ); + }); + + it('should not set header when captcha config object is empty', () => { + apiService.get('dummy', { captcha: {} }).subscribe(fail, fail, fail); + + const req = httpTestingController.expectOne(`${REST_URL}/dummy`); + expect(req.request.headers.get(ApiService.AUTHORIZATION_HEADER_KEY)).toBeFalsy(); + }); }); }); diff --git a/src/app/core/services/api/api.service.ts b/src/app/core/services/api/api.service.ts index 97671969ea..7eef4dd2c3 100644 --- a/src/app/core/services/api/api.service.ts +++ b/src/app/core/services/api/api.service.ts @@ -4,6 +4,7 @@ import { Store, select } from '@ngrx/store'; import { Observable, OperatorFunction, Subject, forkJoin, of, throwError } from 'rxjs'; import { catchError, concatMap, defaultIfEmpty, filter, map, switchMap, tap, throwIfEmpty } from 'rxjs/operators'; +import { Captcha } from 'ish-core/models/captcha/captcha.model'; import { Link } from 'ish-core/models/link/link.model'; import { Locale } from 'ish-core/models/locale/locale.model'; import { getCurrentLocale, getICMServerURL, getRestEndpoint } from 'ish-core/store/configuration'; @@ -105,6 +106,7 @@ export interface AvailableOptions { headers?: HttpHeaders; skipApiErrorHandling?: boolean; runExclusively?: boolean; + captcha?: Captcha; } @Injectable({ providedIn: 'root' }) @@ -137,17 +139,44 @@ export class ApiService { : headers; } + /** +- * sets the request header for the appropriate captcha service +- @param captcha captcha token for captcha V2 and V3 +- @param captchaAction captcha action for captcha V3 +- @returns HttpHeader http header with captcha Authorization key +- */ + private appendCaptchaTokenToHeaders(headers: HttpHeaders, captcha: string, captchaAction: string) { + // testing token gets 'null' from captcha service, so we accept it as a valid value here + if (captchaAction !== undefined) { + // captcha V3 + return headers.set( + ApiService.AUTHORIZATION_HEADER_KEY, + `CAPTCHA recaptcha_token=${captcha} action=${captchaAction}` + ); + } else { + // captcha V2 + // TODO: remove second parameter 'foo=bar' that currently only resolves a shortcoming of the server side implemenation that still requires two parameters + return headers.set(ApiService.AUTHORIZATION_HEADER_KEY, `CAPTCHA g-recaptcha-response=${captcha} foo=bar`); + } + } + /** * merges supplied and default headers */ - private constructHeaders(options?: { headers?: HttpHeaders }): HttpHeaders { + private constructHeaders(options?: AvailableOptions): HttpHeaders { const defaultHeaders = new HttpHeaders().set('content-type', 'application/json').set('Accept', 'application/json'); let newHeaders = defaultHeaders; if (options && options.headers) { newHeaders = options.headers.keys().reduce((acc, key) => acc.set(key, options.headers.get(key)), defaultHeaders); } - return this.appendAPITokenToHeaders(newHeaders); + + // testing token gets 'null' from captcha service, so we accept it as a valid value here + if (options && options.captcha && options.captcha.captcha !== undefined) { + return this.appendCaptchaTokenToHeaders(newHeaders, options.captcha.captcha, options.captcha.captchaAction); + } else { + return this.appendAPITokenToHeaders(newHeaders); + } } private wrapHttpCall(httpCall: () => Observable, options: AvailableOptions) { diff --git a/src/app/core/services/contact/contact.service.ts b/src/app/core/services/contact/contact.service.ts index 08ee6acdb9..28f711c2b3 100644 --- a/src/app/core/services/contact/contact.service.ts +++ b/src/app/core/services/contact/contact.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@angular/core'; +import { pick } from 'lodash-es'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Contact } from 'ish-core/models/contact/contact.model'; -import { ApiService, unpackEnvelope } from 'ish-core/services/api/api.service'; +import { ApiService, AvailableOptions, unpackEnvelope } from 'ish-core/services/api/api.service'; @Injectable({ providedIn: 'root' }) export class ContactService { @@ -23,6 +24,11 @@ export class ContactService { * Send contact us request, when a customer want to get in contact with the shop */ createContactRequest(contactData: Contact): Observable { - return this.apiService.post(`contact`, contactData, { skipApiErrorHandling: true }); + const options: AvailableOptions = { + skipApiErrorHandling: true, + captcha: pick(contactData, ['captcha', 'captchaAction']), + }; + + return this.apiService.post(`contact`, { ...contactData, captcha: undefined, captchaAction: undefined }, options); } } diff --git a/src/app/core/services/user/user.service.spec.ts b/src/app/core/services/user/user.service.spec.ts index 08a9f5088e..03d3e83202 100644 --- a/src/app/core/services/user/user.service.spec.ts +++ b/src/app/core/services/user/user.service.spec.ts @@ -80,7 +80,7 @@ describe('User Service', () => { }); it("should create a new private user when 'createUser' is called with type 'PrivateCustomer'", done => { - when(apiServiceMock.post(anyString(), anything())).thenReturn(of({})); + when(apiServiceMock.post(anyString(), anything(), anything())).thenReturn(of({})); const payload = { customer: { customerNo: '4711', type: 'PrivateCustomer' } as Customer, @@ -90,7 +90,7 @@ describe('User Service', () => { } as CustomerRegistrationType; userService.createUser(payload).subscribe(() => { - verify(apiServiceMock.post('customers', anything())).once(); + verify(apiServiceMock.post('customers', anything(), anything())).once(); done(); }); }); diff --git a/src/app/core/services/user/user.service.ts b/src/app/core/services/user/user.service.ts index 3a323a9d37..6a0724fad9 100644 --- a/src/app/core/services/user/user.service.ts +++ b/src/app/core/services/user/user.service.ts @@ -1,6 +1,7 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import b64u from 'b64u'; +import { pick } from 'lodash-es'; import { EMPTY, Observable, throwError } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; @@ -97,14 +98,9 @@ export class UserService { }; } - if (body.captchaResponse) { - return this.apiService.post('customers', newCustomer, { - headers: this.appendCaptchaHeaders(body.captchaResponse, body.captchaAction), - }); - // without captcha - } else { - return this.apiService.post('customers', newCustomer); - } + return this.apiService.post('customers', newCustomer, { + captcha: pick(body, ['captcha', 'captchaAction']), + }); } /** @@ -197,12 +193,9 @@ export class UserService { requestPasswordReminder(data: PasswordReminder) { const options: AvailableOptions = { skipApiErrorHandling: true, + captcha: pick(data, ['captcha', 'captchaAction']), }; - if (data.captcha) { - options.headers = this.appendCaptchaHeaders(data.captcha, data.captchaAction); - } - return this.apiService.post('security/reminder', { answer: '', ...data }, options); } @@ -216,21 +209,4 @@ export class UserService { }; return this.apiService.post('security/password', data, options); } - - // provides the request header for the appropriate captcha service - private appendCaptchaHeaders(captcha: string, captchaAction: string): HttpHeaders { - let headers = new HttpHeaders(); - // captcha V3 - if (captchaAction) { - headers = headers.set( - ApiService.AUTHORIZATION_HEADER_KEY, - `CAPTCHA recaptcha_token=${captcha} action=${captchaAction}` - ); - // captcha V2 - } else { - // TODO: remove second parameter 'foo=bar' that currently only resolves a shortcoming of the server side implemenation that still requires two parameters - headers = headers.set(ApiService.AUTHORIZATION_HEADER_KEY, `CAPTCHA g-recaptcha-response=${captcha} foo=bar`); - } - return headers; - } } diff --git a/src/app/core/store/configuration/configuration.effects.ts b/src/app/core/store/configuration/configuration.effects.ts index dce27baab4..6fe0ca7680 100644 --- a/src/app/core/store/configuration/configuration.effects.ts +++ b/src/app/core/store/configuration/configuration.effects.ts @@ -126,7 +126,7 @@ export class ConfigurationEffects { ); @Effect() - setDeviceType$ = iif( + setDeviceType$ = iif( () => isPlatformBrowser(this.platformId), defer(() => merge(this.actions$.pipe(ofType(ROOT_EFFECTS_INIT)), fromEvent(window, 'resize')).pipe( diff --git a/src/app/extensions/captcha/exports/captcha-exports.module.ts b/src/app/extensions/captcha/exports/captcha-exports.module.ts new file mode 100644 index 0000000000..4c4cf392f2 --- /dev/null +++ b/src/app/extensions/captcha/exports/captcha-exports.module.ts @@ -0,0 +1,14 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { SitekeyProviderService } from '../services/sitekey-provider/sitekey-provider.service'; + +import { LazyCaptchaComponent } from './captcha/lazy-captcha/lazy-captcha.component'; + +@NgModule({ + imports: [CommonModule], + declarations: [LazyCaptchaComponent], + exports: [LazyCaptchaComponent], + providers: [SitekeyProviderService], +}) +export class CaptchaExportsModule {} diff --git a/src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.html b/src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.html new file mode 100644 index 0000000000..a61aef13d6 --- /dev/null +++ b/src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.html @@ -0,0 +1 @@ + diff --git a/src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.spec.ts b/src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.spec.ts new file mode 100644 index 0000000000..0027a60acf --- /dev/null +++ b/src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.spec.ts @@ -0,0 +1,112 @@ +import { ComponentFixture, TestBed, async, fakeAsync, tick } from '@angular/core/testing'; +import { FormControl, FormGroup } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { RECAPTCHA_V3_SITE_KEY, RecaptchaComponent } from 'ng-recaptcha'; +import { EMPTY, of } from 'rxjs'; +import { anyString, instance, mock, when } from 'ts-mockito'; + +import { configurationReducer } from 'ish-core/store/configuration/configuration.reducer'; +import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; + +import { CaptchaFacade } from '../../../facades/captcha.facade'; +import { CaptchaV2Component, CaptchaV2ComponentModule } from '../../../shared/captcha/captcha-v2/captcha-v2.component'; +import { CaptchaV3Component, CaptchaV3ComponentModule } from '../../../shared/captcha/captcha-v3/captcha-v3.component'; + +import { LazyCaptchaComponent } from './lazy-captcha.component'; + +describe('Lazy Captcha Component', () => { + let fixture: ComponentFixture; + let component: LazyCaptchaComponent; + let element: HTMLElement; + let captchaFacade: CaptchaFacade; + + beforeEach(async(() => { + captchaFacade = mock(CaptchaFacade); + when(captchaFacade.captchaVersion$).thenReturn(EMPTY); + when(captchaFacade.captchaSiteKey$).thenReturn(of('captchaSiteKeyASDF')); + when(captchaFacade.captchaActive$(anyString())).thenReturn(of(true)); + + TestBed.configureTestingModule({ + declarations: [MockComponent(RecaptchaComponent)], + imports: [ + CaptchaV2ComponentModule, + CaptchaV3ComponentModule, + RouterTestingModule, + TranslateModule.forRoot(), + ngrxTesting({ + reducers: { configuration: configurationReducer }, + }), + ], + providers: [ + { provide: CaptchaFacade, useFactory: () => instance(captchaFacade) }, + { provide: RECAPTCHA_V3_SITE_KEY, useValue: 'captchaSiteKeyQWERTY' }, + ], + }) + .overrideModule(CaptchaV2ComponentModule, { set: { entryComponents: [CaptchaV2Component] } }) + .overrideModule(CaptchaV3ComponentModule, { set: { entryComponents: [CaptchaV3Component] } }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LazyCaptchaComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + component.form = new FormGroup({ + captcha: new FormControl(''), + captchaAction: new FormControl(''), + }); + component.cssClass = 'd-none'; + component.topic = 'register'; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should render v2 component when configured', fakeAsync(() => { + when(captchaFacade.captchaVersion$).thenReturn(of(2 as 2)); + fixture.detectChanges(); + + tick(500); + expect(element).toMatchInlineSnapshot(``); + const v2Cmp: CaptchaV2Component = fixture.debugElement.query(By.css('ish-captcha-v2'))?.componentInstance; + expect(v2Cmp).toBeTruthy(); + expect(v2Cmp.cssClass).toEqual('d-none'); + })); + it('should render v3 component when configured', fakeAsync(() => { + when(captchaFacade.captchaVersion$).thenReturn(of(3 as 3)); + fixture.detectChanges(); + + tick(500); + expect(element).toMatchInlineSnapshot(``); + const v3Cmp: CaptchaV3Component = fixture.debugElement.query(By.css('ish-captcha-v3'))?.componentInstance; + expect(v3Cmp).toBeTruthy(); + })); + + // errors are thrown if required input parameters are missing + it('should throw an error if there is no form set as input parameter', () => { + component.form = undefined; + expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot( + `"required input parameter
is missing for LazyCaptchaComponent"` + ); + }); + + it('should throw an error if there is no control "captcha" in the given form', () => { + delete component.form.controls.captcha; + expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot( + `"form control 'captcha' does not exist in the given form for LazyCaptchaComponent"` + ); + }); + + it('should throw an error if there is no control "captchaAction" in the given form', () => { + delete component.form.controls.captchaAction; + expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot( + `"form control 'captchaAction' does not exist in the given form for LazyCaptchaComponent"` + ); + }); +}); diff --git a/src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.ts b/src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.ts new file mode 100644 index 0000000000..2fcfa1e7d8 --- /dev/null +++ b/src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.ts @@ -0,0 +1,126 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Compiler, + Component, + Injector, + Input, + NgModuleFactory, + OnInit, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { AbstractControl, FormGroup, Validators } from '@angular/forms'; +import { switchMapTo, take } from 'rxjs/operators'; + +import { whenTruthy } from 'ish-core/utils/operators'; + +import { CaptchaFacade, CaptchaTopic } from '../../../facades/captcha.facade'; + +/** + * The Captcha Component + * + * Displays a captcha form control (V2) or widget (V3) if the captchaV2 or the captchaV3 feature is enabled. + * It expects the given form to have the form controls for the captcha (controlName) and the captcha action (actionControlName). + * If the captcha is confirmed the captcha form control contains the captcha response token provided by the captcha service. + * + * The parent form supplied must have controls for 'captcha' and 'captchaAction' + * + * @example + * + */ +@Component({ + selector: 'ish-lazy-captcha', + templateUrl: './lazy-captcha.component.html', + changeDetection: ChangeDetectionStrategy.Default, +}) +export class LazyCaptchaComponent implements OnInit, AfterViewInit { + @ViewChild('anchor', { read: ViewContainerRef, static: true }) anchor: ViewContainerRef; + + /** + form containing the captcha form controls + */ + @Input() form: FormGroup; + + /** + css Class for rendering the captcha V2 control, default='offset-md-4 col-md-8' + */ + @Input() cssClass = 'offset-md-4 col-md-8'; + + @Input() topic: CaptchaTopic; + + constructor(private captchaFacade: CaptchaFacade, private compiler: Compiler, private injector: Injector) {} + + ngOnInit() { + this.sanityCheck(); + } + + ngAfterViewInit() { + this.captchaFacade + .captchaActive$(this.topic) + .pipe(whenTruthy(), switchMapTo(this.captchaFacade.captchaVersion$), whenTruthy(), take(1)) + .subscribe(async version => { + if (version === 3) { + this.actionFormControl.setValue(this.topic); + + const { CaptchaV3Component, CaptchaV3ComponentModule } = await import( + '../../../shared/captcha/captcha-v3/captcha-v3.component' + ); + const moduleFactory = await this.loadModuleFactory(CaptchaV3ComponentModule); + const moduleRef = moduleFactory.create(this.injector); + const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(CaptchaV3Component); + + const componentRef = this.anchor.createComponent(factory); + + componentRef.instance.parentForm = this.form; + } else if (version === 2) { + this.formControl.setValidators([Validators.required]); + this.formControl.updateValueAndValidity(); + + const { CaptchaV2Component, CaptchaV2ComponentModule } = await import( + '../../../shared/captcha/captcha-v2/captcha-v2.component' + ); + const moduleFactory = await this.loadModuleFactory(CaptchaV2ComponentModule); + const moduleRef = moduleFactory.create(this.injector); + const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(CaptchaV2Component); + + const componentRef = this.anchor.createComponent(factory); + + componentRef.instance.cssClass = this.cssClass; + componentRef.instance.parentForm = this.form; + } + }); + } + + // tslint:disable-next-line: no-any + private async loadModuleFactory(t: any) { + if (t instanceof NgModuleFactory) { + return t; + } else { + return await this.compiler.compileModuleAsync(t); + } + } + + private sanityCheck() { + if (!this.form) { + throw new Error('required input parameter is missing for LazyCaptchaComponent'); + } + if (!this.formControl) { + throw new Error(`form control 'captcha' does not exist in the given form for LazyCaptchaComponent`); + } + if (!this.actionFormControl) { + throw new Error(`form control 'captchaAction' does not exist in the given form for LazyCaptchaComponent`); + } + if (!this.topic) { + throw new Error(`required input parameter is missing for LazyCaptchaComponent`); + } + } + + private get formControl(): AbstractControl { + return this.form?.get('captcha'); + } + + private get actionFormControl(): AbstractControl { + return this.form?.get('captchaAction'); + } +} diff --git a/src/app/extensions/captcha/facades/captcha.facade.ts b/src/app/extensions/captcha/facades/captcha.facade.ts new file mode 100644 index 0000000000..2167521f12 --- /dev/null +++ b/src/app/extensions/captcha/facades/captcha.facade.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { filter, map, switchMap, switchMapTo } from 'rxjs/operators'; + +import { getServerConfigParameter } from 'ish-core/store/configuration'; +import { whenTruthy } from 'ish-core/utils/operators'; + +export type CaptchaTopic = + | 'contactUs' + | 'emailShoppingCart' + | 'forgotPassword' + | 'redemptionOfGiftCardsAndCertificates' + | 'register'; + +// tslint:disable:member-ordering +@Injectable({ providedIn: 'root' }) +export class CaptchaFacade { + constructor(private store: Store) {} + + captchaVersion$: Observable<2 | 3 | undefined> = this.store.pipe( + select( + getServerConfigParameter<{ + ReCaptchaV2ServiceDefinition: { runnable: boolean }; + ReCaptchaV3ServiceDefinition: { runnable: boolean }; + }>('services') + ), + whenTruthy(), + map(services => + services.ReCaptchaV3ServiceDefinition && services.ReCaptchaV3ServiceDefinition.runnable + ? 3 + : services.ReCaptchaV2ServiceDefinition && services.ReCaptchaV2ServiceDefinition.runnable + ? 2 + : undefined + ) + ); + + captchaSiteKey$ = this.captchaVersion$.pipe( + whenTruthy(), + switchMap(version => + this.store.pipe( + select(getServerConfigParameter(`services.ReCaptchaV${version}ServiceDefinition.SiteKey`)), + whenTruthy() + ) + ) + ); + + /** + * @param key feature name according to the captcha ICM configuration, e.g. register, forgotPassword, contactUs + */ + captchaActive$(key: CaptchaTopic): Observable { + return this.store.pipe( + filter(() => !!key), + switchMapTo(this.captchaVersion$), + whenTruthy(), + switchMapTo(this.store.pipe(select(getServerConfigParameter('captcha.' + key)))) + ); + } +} diff --git a/src/app/extensions/captcha/services/sitekey-provider/sitekey-provider.service.ts b/src/app/extensions/captcha/services/sitekey-provider/sitekey-provider.service.ts new file mode 100644 index 0000000000..5f89b12991 --- /dev/null +++ b/src/app/extensions/captcha/services/sitekey-provider/sitekey-provider.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { take } from 'rxjs/operators'; + +import { whenTruthy } from 'ish-core/utils/operators'; + +import { CaptchaFacade } from '../../facades/captcha.facade'; + +@Injectable() +export class SitekeyProviderService { + // not-dead-code + siteKey: string; + + constructor(captchaFacade: CaptchaFacade) { + // synchronize asynchronous site key so we can provide it synchronously for the recaptcha service later on. + captchaFacade.captchaSiteKey$.pipe(whenTruthy(), take(1)).subscribe(storeSiteKey => (this.siteKey = storeSiteKey)); + } +} + +export function getSynchronizedSiteKey(service: SitekeyProviderService) { + return service.siteKey; +} diff --git a/src/app/shared/forms/components/captcha-v2/captcha-v2.component.html b/src/app/extensions/captcha/shared/captcha/captcha-v2/captcha-v2.component.html similarity index 79% rename from src/app/shared/forms/components/captcha-v2/captcha-v2.component.html rename to src/app/extensions/captcha/shared/captcha/captcha-v2/captcha-v2.component.html index 85dbdf0ca5..6600a80926 100644 --- a/src/app/shared/forms/components/captcha-v2/captcha-v2.component.html +++ b/src/app/extensions/captcha/shared/captcha/captcha-v2/captcha-v2.component.html @@ -1,4 +1,4 @@ -
+
diff --git a/src/app/shared/forms/components/captcha-v2/captcha-v2.component.spec.ts b/src/app/extensions/captcha/shared/captcha/captcha-v2/captcha-v2.component.spec.ts similarity index 60% rename from src/app/shared/forms/components/captcha-v2/captcha-v2.component.spec.ts rename to src/app/extensions/captcha/shared/captcha/captcha-v2/captcha-v2.component.spec.ts index 8c6076c6e1..6fad94c707 100644 --- a/src/app/shared/forms/components/captcha-v2/captcha-v2.component.spec.ts +++ b/src/app/extensions/captcha/shared/captcha/captcha-v2/captcha-v2.component.spec.ts @@ -1,8 +1,11 @@ import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { TranslateModule } from '@ngx-translate/core'; import { RecaptchaModule } from 'ng-recaptcha'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; -import { CAPTCHA_SITE_KEY } from 'ish-core/configurations/injection-keys'; +import { CaptchaFacade } from '../../../facades/captcha.facade'; import { CaptchaV2Component } from './captcha-v2.component'; @@ -10,13 +13,15 @@ describe('Captcha V2 Component', () => { let component: CaptchaV2Component; let fixture: ComponentFixture; let element: HTMLElement; - const captchaSiteKey = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'; beforeEach(async(() => { + const captchaFacade = mock(CaptchaFacade); + when(captchaFacade.captchaSiteKey$).thenReturn(of('captchaV2SiteKey')); + TestBed.configureTestingModule({ declarations: [CaptchaV2Component], - imports: [RecaptchaModule.forRoot(), TranslateModule.forRoot()], - providers: [{ provide: CAPTCHA_SITE_KEY, useValue: captchaSiteKey }], + imports: [ReactiveFormsModule, RecaptchaModule.forRoot(), TranslateModule.forRoot()], + providers: [{ provide: CaptchaFacade, useFactory: () => instance(captchaFacade) }], }).compileComponents(); })); @@ -24,6 +29,12 @@ describe('Captcha V2 Component', () => { fixture = TestBed.createComponent(CaptchaV2Component); component = fixture.componentInstance; element = fixture.nativeElement; + + const fb = TestBed.inject(FormBuilder); + component.parentForm = fb.group({ + captcha: fb.control(''), + captchaAction: fb.control(''), + }); }); it('should be created', () => { diff --git a/src/app/extensions/captcha/shared/captcha/captcha-v2/captcha-v2.component.ts b/src/app/extensions/captcha/shared/captcha/captcha-v2/captcha-v2.component.ts new file mode 100644 index 0000000000..ff7ac8d5ad --- /dev/null +++ b/src/app/extensions/captcha/shared/captcha/captcha-v2/captcha-v2.component.ts @@ -0,0 +1,53 @@ +import { ChangeDetectionStrategy, Component, Input, NgModule, OnInit } from '@angular/core'; +import { AbstractControl, FormGroup } from '@angular/forms'; +import { RecaptchaModule } from 'ng-recaptcha'; +import { Observable } from 'rxjs'; + +import { SharedModule } from 'ish-shared/shared.module'; + +import { CaptchaFacade } from '../../../facades/captcha.facade'; + +/** + * The Captcha V2 Component + * + * Displays a captcha form control (V2) and saves the response token in the given form. It should only be used by {@link CaptchaComponent} + */ +@Component({ + selector: 'ish-captcha-v2', + templateUrl: './captcha-v2.component.html', + changeDetection: ChangeDetectionStrategy.Default, +}) +export class CaptchaV2Component implements OnInit { + @Input() parentForm: FormGroup; + @Input() cssClass: string; + + captchaSiteKey$: Observable; + + constructor(private captchaFacade: CaptchaFacade) {} + + ngOnInit() { + this.captchaSiteKey$ = this.captchaFacade.captchaSiteKey$; + + this.parentForm.get('captchaAction').setValue(undefined); + this.formControl.updateValueAndValidity(); + } + + /* writes the captcha response token in the captcha form field */ + resolved(captchaResponse: string) { + this.formControl.setValue(captchaResponse); + } + + private get formControl(): AbstractControl { + return this.parentForm?.get('captcha'); + } + + get hasError(): boolean { + return this.formControl?.invalid && this?.formControl.dirty; + } +} + +@NgModule({ + declarations: [CaptchaV2Component], + imports: [RecaptchaModule, SharedModule], +}) +export class CaptchaV2ComponentModule {} diff --git a/src/app/shared/forms/components/captcha-v3/captcha-v3.component.spec.ts b/src/app/extensions/captcha/shared/captcha/captcha-v3/captcha-v3.component.spec.ts similarity index 84% rename from src/app/shared/forms/components/captcha-v3/captcha-v3.component.spec.ts rename to src/app/extensions/captcha/shared/captcha/captcha-v3/captcha-v3.component.spec.ts index 3b7e39e21b..bee7f31f40 100644 --- a/src/app/shared/forms/components/captcha-v3/captcha-v3.component.spec.ts +++ b/src/app/extensions/captcha/shared/captcha/captcha-v3/captcha-v3.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { FormControl, FormGroup } from '@angular/forms'; import { RECAPTCHA_V3_SITE_KEY, RecaptchaV3Module } from 'ng-recaptcha'; import { CaptchaV3Component } from './captcha-v3.component'; @@ -21,6 +22,10 @@ describe('Captcha V3 Component', () => { fixture = TestBed.createComponent(CaptchaV3Component); component = fixture.componentInstance; element = fixture.nativeElement; + component.parentForm = new FormGroup({ + captcha: new FormControl(''), + captchaAction: new FormControl(''), + }); }); it('should be created', () => { diff --git a/src/app/extensions/captcha/shared/captcha/captcha-v3/captcha-v3.component.ts b/src/app/extensions/captcha/shared/captcha/captcha-v3/captcha-v3.component.ts new file mode 100644 index 0000000000..b8bb810016 --- /dev/null +++ b/src/app/extensions/captcha/shared/captcha/captcha-v3/captcha-v3.component.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, Input, NgModule, OnDestroy, OnInit } from '@angular/core'; +import { FormGroup, Validators } from '@angular/forms'; +import { RECAPTCHA_V3_SITE_KEY, ReCaptchaV3Service, RecaptchaV3Module } from 'ng-recaptcha'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { + SitekeyProviderService, + getSynchronizedSiteKey, +} from '../../../services/sitekey-provider/sitekey-provider.service'; + +/** + * The Captcha V3 Component + * + * Displays a captcha widget (V3) and saves the response token in the given form. It should only be used by {@link CaptchaComponent} + */ +@Component({ + selector: 'ish-captcha-v3', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +// tslint:disable-next-line: no-intelligence-in-artifacts +export class CaptchaV3Component implements OnInit, OnDestroy { + @Input() parentForm: FormGroup; + + private destroy$ = new Subject(); + + constructor(private recaptchaV3Service: ReCaptchaV3Service) {} + + ngOnInit() { + this.parentForm.get('captchaAction').setValidators([Validators.required]); + + this.recaptchaV3Service + .execute(this.parentForm.get('captchaAction').value) + .pipe(takeUntil(this.destroy$)) + .subscribe(token => { + this.parentForm.get('captcha').setValue(token); + this.parentForm.get('captcha').updateValueAndValidity(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + } +} + +@NgModule({ + imports: [RecaptchaV3Module], + declarations: [CaptchaV3Component], + providers: [ + { + provide: RECAPTCHA_V3_SITE_KEY, + useFactory: getSynchronizedSiteKey, + deps: [SitekeyProviderService], + }, + ], +}) +export class CaptchaV3ComponentModule {} diff --git a/src/app/pages/contact/contact-form/contact-form.component.html b/src/app/pages/contact/contact-form/contact-form.component.html index 5d2bab861e..e80ed14d9e 100644 --- a/src/app/pages/contact/contact-form/contact-form.component.html +++ b/src/app/pages/contact/contact-form/contact-form.component.html @@ -29,7 +29,7 @@ [translateOptionLabels]="true" > - +
diff --git a/src/app/pages/contact/contact-form/contact-form.component.spec.ts b/src/app/pages/contact/contact-form/contact-form.component.spec.ts index 5af4947f4c..48354cb08e 100644 --- a/src/app/pages/contact/contact-form/contact-form.component.spec.ts +++ b/src/app/pages/contact/contact-form/contact-form.component.spec.ts @@ -5,11 +5,12 @@ import { MockComponent } from 'ng-mocks'; import { anything, instance, mock, spy, verify } from 'ts-mockito'; import { FeatureToggleModule, FeatureToggleService } from 'ish-core/feature-toggle.module'; -import { CaptchaComponent } from 'ish-shared/forms/components/captcha/captcha.component'; import { InputComponent } from 'ish-shared/forms/components/input/input.component'; import { SelectComponent, SelectOption } from 'ish-shared/forms/components/select/select.component'; import { TextareaComponent } from 'ish-shared/forms/components/textarea/textarea.component'; +import { LazyCaptchaComponent } from '../../../extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component'; + import { ContactFormComponent } from './contact-form.component'; describe('Contact Form Component', () => { @@ -21,8 +22,8 @@ describe('Contact Form Component', () => { TestBed.configureTestingModule({ declarations: [ ContactFormComponent, - MockComponent(CaptchaComponent), MockComponent(InputComponent), + MockComponent(LazyCaptchaComponent), MockComponent(SelectComponent), MockComponent(TextareaComponent), ], @@ -69,7 +70,7 @@ describe('Contact Form Component', () => { component.contactForm.get('phone').setValue('123456'); component.contactForm.get('order').setValue('456789'); component.contactForm.get('subject').setValue('Return'); - component.contactForm.get('comments').setValue('want to return stuff'); + component.contactForm.get('comment').setValue('want to return stuff'); component.submitForm(); verify(emitter.emit(anything())).once(); }); diff --git a/src/app/pages/contact/contact-form/contact-form.component.ts b/src/app/pages/contact/contact-form/contact-form.component.ts index 7fd0e8562d..94315c6247 100644 --- a/src/app/pages/contact/contact-form/contact-form.component.ts +++ b/src/app/pages/contact/contact-form/contact-form.component.ts @@ -22,7 +22,7 @@ export class ContactFormComponent implements OnChanges, OnInit { @Input() subjects: string[] = []; @Input() user: User; /** The contact request to send. */ - @Output() request = new EventEmitter<{ contact: Contact; captcha?: string }>(); + @Output() request = new EventEmitter(); subjectOptions: SelectOption[]; @@ -43,18 +43,9 @@ export class ContactFormComponent implements OnChanges, OnInit { /** emit contact request, when for is valid or mark form as dirty, when form is invalid */ submitForm() { if (this.contactForm.valid) { - const formValue = this.contactForm.value; - const contact: Contact = { - name: formValue.name, - email: formValue.email, - phone: formValue.phone, - subject: formValue.subject, - comment: formValue.comments, - order: formValue.order, - }; + const contact: Contact = this.contactForm.value; - /* ToDo: send captcha data if captcha is supported by REST, see #IS-28299 */ - this.request.emit({ contact }); + this.request.emit(contact); } else { markAsDirtyRecursive(this.contactForm); this.submitted = true; @@ -77,9 +68,9 @@ export class ContactFormComponent implements OnChanges, OnInit { phone: [phone, Validators.required], order: [''], subject: ['', Validators.required], - comments: ['', Validators.required], + comment: ['', Validators.required], captcha: [''], - captchaAction: ['contact_us'], + captchaAction: ['contactUs'], }); } diff --git a/src/app/pages/contact/contact-page.component.ts b/src/app/pages/contact/contact-page.component.ts index 102b78e8ef..b1829cd9c1 100644 --- a/src/app/pages/contact/contact-page.component.ts +++ b/src/app/pages/contact/contact-page.component.ts @@ -52,8 +52,8 @@ export class ContactPageComponent implements OnInit, OnDestroy { } /** dispatch contact request */ - createRequest(request: { contact: Contact; captcha?: string }) { - this.accountFacade.createContact(request.contact); + createRequest(contact: Contact) { + this.accountFacade.createContact(contact); this.router.navigate([], { queryParams: { submitted: true } }); } diff --git a/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.html b/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.html index a9239b2ae8..5dcec99159 100644 --- a/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.html +++ b/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.html @@ -33,7 +33,7 @@ }" > - +
diff --git a/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.spec.ts b/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.spec.ts index 8aec9ba46b..f7e5aedd41 100644 --- a/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.spec.ts +++ b/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.spec.ts @@ -7,9 +7,10 @@ import { MockComponent } from 'ng-mocks'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { coreReducers } from 'ish-core/store/core-store.module'; import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; -import { CaptchaComponent } from 'ish-shared/forms/components/captcha/captcha.component'; import { InputComponent } from 'ish-shared/forms/components/input/input.component'; +import { LazyCaptchaComponent } from '../../../extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component'; + import { RequestReminderFormComponent } from './request-reminder-form.component'; describe('Request Reminder Form Component', () => { @@ -19,7 +20,7 @@ describe('Request Reminder Form Component', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [MockComponent(CaptchaComponent), MockComponent(InputComponent), RequestReminderFormComponent], + declarations: [MockComponent(InputComponent), MockComponent(LazyCaptchaComponent), RequestReminderFormComponent], imports: [ FeatureToggleModule, ReactiveFormsModule, diff --git a/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.ts b/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.ts index 85e4ef2c5e..0a5a87ad40 100644 --- a/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.ts +++ b/src/app/pages/forgot-password/request-reminder-form/request-reminder-form.component.ts @@ -33,7 +33,7 @@ export class RequestReminderFormComponent implements OnInit { firstName: new FormControl('', Validators.required), lastName: new FormControl('', Validators.required), captcha: new FormControl(''), - captchaAction: new FormControl('forgot_password'), + captchaAction: new FormControl('forgotPassword'), }); } diff --git a/src/app/pages/registration/registration-form/registration-form.component.html b/src/app/pages/registration/registration-form/registration-form.component.html index e1c43ace3b..0f3a26d5c0 100644 --- a/src/app/pages/registration/registration-form/registration-form.component.html +++ b/src/app/pages/registration/registration-form/registration-form.component.html @@ -48,7 +48,7 @@

[customerForm]="form" > - +
diff --git a/src/app/pages/registration/registration-form/registration-form.component.spec.ts b/src/app/pages/registration/registration-form/registration-form.component.spec.ts index 98373f10d2..a96e3085fd 100644 --- a/src/app/pages/registration/registration-form/registration-form.component.spec.ts +++ b/src/app/pages/registration/registration-form/registration-form.component.spec.ts @@ -15,9 +15,9 @@ import { AddressFormFactory } from 'ish-shared/address-forms/components/address- import { AddressFormFactoryProvider } from 'ish-shared/address-forms/configurations/address-form-factory.provider'; import { ContentIncludeComponent } from 'ish-shared/cms/components/content-include/content-include.component'; import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; -import { CaptchaComponent } from 'ish-shared/forms/components/captcha/captcha.component'; import { CheckboxComponent } from 'ish-shared/forms/components/checkbox/checkbox.component'; +import { LazyCaptchaComponent } from '../../../extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component'; import { RegistrationCompanyFormComponent } from '../registration-company-form/registration-company-form.component'; import { RegistrationCredentialsFormComponent } from '../registration-credentials-form/registration-credentials-form.component'; @@ -39,9 +39,9 @@ describe('Registration Form Component', () => { TestBed.configureTestingModule({ declarations: [ MockComponent(AddressFormContainerComponent), - MockComponent(CaptchaComponent), MockComponent(CheckboxComponent), MockComponent(ContentIncludeComponent), + MockComponent(LazyCaptchaComponent), MockComponent(ModalDialogComponent), MockComponent(RegistrationCompanyFormComponent), MockComponent(RegistrationCredentialsFormComponent), diff --git a/src/app/pages/registration/registration-form/registration-form.component.ts b/src/app/pages/registration/registration-form/registration-form.component.ts index 3570df3731..ed8a55f5c9 100644 --- a/src/app/pages/registration/registration-form/registration-form.component.ts +++ b/src/app/pages/registration/registration-form/registration-form.component.ts @@ -74,7 +74,7 @@ export class RegistrationFormComponent implements OnInit, OnChanges { birthday: [''], termsAndConditions: [false, [Validators.required, Validators.pattern('true')]], captcha: [''], - captchaAction: ['create_account'], + captchaAction: ['register'], address: this.afs.getFactory('default').getGroup({ isBusinessAddress: this.businessCustomerRegistration }), // filled dynamically when country code changes }); @@ -147,7 +147,7 @@ export class RegistrationFormComponent implements OnInit, OnChanges { } const registration: CustomerRegistrationType = { customer, user, credentials, address }; - registration.captchaResponse = this.form.get('captcha').value; + registration.captcha = this.form.get('captcha').value; registration.captchaAction = this.form.get('captchaAction').value; this.create.emit(registration); diff --git a/src/app/shared/forms/components/captcha-v2/captcha-v2.component.ts b/src/app/shared/forms/components/captcha-v2/captcha-v2.component.ts deleted file mode 100644 index 1217eb4e53..0000000000 --- a/src/app/shared/forms/components/captcha-v2/captcha-v2.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ChangeDetectionStrategy, Component, Inject, Input } from '@angular/core'; -import { FormGroup } from '@angular/forms'; - -import { CAPTCHA_SITE_KEY } from 'ish-core/configurations/injection-keys'; - -/** - * The Captcha V2 Component - * - * Displays a captcha form control (V2) and saves the response token in the given form. It should only be used by {@link CaptchaComponent} - */ -@Component({ - selector: 'ish-captcha-v2', - templateUrl: './captcha-v2.component.html', - changeDetection: ChangeDetectionStrategy.Default, -}) -export class CaptchaV2Component { - @Input() parentForm: FormGroup; - @Input() controlName: string; - @Input() cssClass: string; - - constructor(@Inject(CAPTCHA_SITE_KEY) public captchaSiteKey) {} - - /* writes the captcha response token in the captcha farm field */ - resolved(captchaResponse: string) { - this.parentForm.get(this.controlName).setValue(captchaResponse); - } - - get hasError(): boolean { - return ( - this.parentForm && this.parentForm.get(this.controlName).invalid && this.parentForm.get(this.controlName).dirty - ); - } -} diff --git a/src/app/shared/forms/components/captcha-v3/captcha-v3.component.ts b/src/app/shared/forms/components/captcha-v3/captcha-v3.component.ts deleted file mode 100644 index 255fa4edb8..0000000000 --- a/src/app/shared/forms/components/captcha-v3/captcha-v3.component.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; -import { FormGroup } from '@angular/forms'; -import { ReCaptchaV3Service } from 'ng-recaptcha'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; - -/** - * The Captcha V3 Component - * - * Displays a captcha widget (V3) and saves the response token in the given form. It should only be used by {@link CaptchaComponent} - */ -@Component({ - selector: 'ish-captcha-v3', - template: '', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class CaptchaV3Component implements OnChanges, OnDestroy { - @Input() parentForm: FormGroup; - @Input() controlName: string; - @Input() actionControlName: string; - - private destroy$ = new Subject(); - - constructor(private recaptchaV3Service: ReCaptchaV3Service) {} - - /* write the captcha response token in the captcha form field */ - ngOnChanges(c: SimpleChanges) { - if (this.parentForm && c.parentForm.isFirstChange()) { - this.recaptchaV3Service - .execute(this.parentForm.get(this.actionControlName).value) - .pipe(takeUntil(this.destroy$)) - .subscribe(token => this.parentForm.get(this.controlName).setValue(token)); - } - } - - ngOnDestroy() { - this.destroy$.next(); - } -} diff --git a/src/app/shared/forms/components/captcha/captcha.component.html b/src/app/shared/forms/components/captcha/captcha.component.html deleted file mode 100644 index dd75f4bc0b..0000000000 --- a/src/app/shared/forms/components/captcha/captcha.component.html +++ /dev/null @@ -1,12 +0,0 @@ - - diff --git a/src/app/shared/forms/components/captcha/captcha.component.spec.ts b/src/app/shared/forms/components/captcha/captcha.component.spec.ts deleted file mode 100644 index 6053f81908..0000000000 --- a/src/app/shared/forms/components/captcha/captcha.component.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ComponentFixture, TestBed, async } from '@angular/core/testing'; -import { FormControl, FormGroup } from '@angular/forms'; -import { MockComponent } from 'ng-mocks'; - -import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; -import { configurationReducer } from 'ish-core/store/configuration/configuration.reducer'; -import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; -import { CaptchaV2Component } from 'ish-shared/forms/components/captcha-v2/captcha-v2.component'; -import { CaptchaV3Component } from 'ish-shared/forms/components/captcha-v3/captcha-v3.component'; - -import { CaptchaComponent } from './captcha.component'; - -describe('Captcha Component', () => { - let fixture: ComponentFixture; - let component: CaptchaComponent; - let element: HTMLElement; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [CaptchaComponent, MockComponent(CaptchaV2Component), MockComponent(CaptchaV3Component)], - imports: [ - FeatureToggleModule, - ngrxTesting({ - reducers: { configuration: configurationReducer }, - config: { - initialState: { configuration: { features: ['captchaV2'] } }, - }, - }), - ], - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(CaptchaComponent); - component = fixture.componentInstance; - element = fixture.nativeElement; - component.form = new FormGroup({ - captcha: new FormControl(''), - captchaAction: new FormControl(''), - }); - }); - - it('should be created', () => { - expect(component).toBeTruthy(); - expect(element).toBeTruthy(); - expect(() => fixture.detectChanges()).not.toThrow(); - expect(element).toMatchInlineSnapshot(` - - `); - }); - - // errors are thrown if required input parameters are missing - it('should throw an error if there is no form set as input parameter', () => { - component.form = undefined; - expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot( - `"required input parameter is missing for FormElementComponent"` - ); - }); - - it('should throw an error if there is no control with controlName in the given form', () => { - component.controlName = 'xxx'; - expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot( - `"input parameter with value 'xxx' does not exist in the given form for CaptchaComponent"` - ); - }); - - it('should throw an error if there is no control with actionControlName in the given form', () => { - component.actionControlName = 'xxx'; - expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot( - `"input parameter with value 'xxx' does not exist in the given form for CaptchaComponent"` - ); - }); -}); diff --git a/src/app/shared/forms/components/captcha/captcha.component.ts b/src/app/shared/forms/components/captcha/captcha.component.ts deleted file mode 100644 index 26d8fb6da5..0000000000 --- a/src/app/shared/forms/components/captcha/captcha.component.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; -import { AbstractControl, FormGroup, Validators } from '@angular/forms'; - -import { FeatureToggleService } from 'ish-core/feature-toggle.module'; - -/** - * The Captcha Component - * - * Displays a captcha form control (V2) or widget (V3) if the captchaV2 or the captchaV3 feature is enabled. - * It expects the given form to have the form controls for the captcha (controlName) and the captcha action (actionControlName). - * If the captcha is confirmed the captcha form control contains the captcha response token provided by the captcha service. - * - * @example - * - */ -@Component({ - selector: 'ish-captcha', - templateUrl: './captcha.component.html', - changeDetection: ChangeDetectionStrategy.Default, -}) -export class CaptchaComponent implements OnInit { - /** - form containing the captcha form controls - */ - @Input() form: FormGroup; - - /** - name of the captcha form control, default = 'captcha' - */ - @Input() controlName = 'captcha'; - - /** - name of the captcha action form control, default = 'captchaAction' - */ - @Input() actionControlName = 'captchaAction'; - - /** - css Class for rendering the captcha V2 control, default='offset-md-4 col-md-8' - */ - @Input() cssClass = 'offset-md-4 col-md-8'; - - captchaV2Enabled = false; - captchaV3Enabled = false; - - constructor(private featureToggle: FeatureToggleService) {} - - ngOnInit() { - this.initComponent(); - } - - initComponent() { - this.captchaV2Enabled = this.featureToggle.enabled('captchaV2'); - this.captchaV3Enabled = this.featureToggle.enabled('captchaV3'); - - /* check, if there is max only one captcha service enabled */ - if (this.captchaV2Enabled && this.captchaV3Enabled) { - throw new Error('it is not allowed to enable more than one captcha service'); - } - - /* check, if required input parameters are available */ - if (this.isCaptchaEnabled()) { - if (!this.form) { - throw new Error('required input parameter is missing for FormElementComponent'); - } - if (!this.formControl) { - throw new Error( - `input parameter with value '${this.controlName}' does not exist in the given form for CaptchaComponent` - ); - } - if (!this.actionFormControl) { - throw new Error( - `input parameter with value '${this.actionControlName}' does not exist in the given form for CaptchaComponent` - ); - } - - // set captcha field required - this.formControl.setValidators([Validators.required]); - } - - /* for captchaV2 the action is not needed, if the action is missing the service will send captcha in v2 format */ - if (this.captchaV2Enabled) { - this.actionFormControl.setValue(undefined); - } - - // for captcha V3 the action is required - if (this.captchaV3Enabled) { - this.actionFormControl.setValidators([Validators.required]); - } - } - - /** - * get the captcha form control according to the controlName - */ - get formControl(): AbstractControl { - return this.form.get(this.controlName); - } - - /** - * get the captcha action form control according to the controlName - */ - get actionFormControl(): AbstractControl { - return this.form.get(this.actionControlName); - } - - isCaptchaEnabled(): boolean { - return this.captchaV2Enabled || this.captchaV3Enabled; - } -} diff --git a/src/app/shared/forms/forms.module.ts b/src/app/shared/forms/forms.module.ts index 4dc79b4489..0980d01c12 100644 --- a/src/app/shared/forms/forms.module.ts +++ b/src/app/shared/forms/forms.module.ts @@ -3,14 +3,10 @@ import { NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { RecaptchaModule, RecaptchaV3Module } from 'ng-recaptcha'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { IconModule } from 'ish-core/icon.module'; -import { CaptchaV2Component } from './components/captcha-v2/captcha-v2.component'; -import { CaptchaV3Component } from './components/captcha-v3/captcha-v3.component'; -import { CaptchaComponent } from './components/captcha/captcha.component'; import { CheckboxComponent } from './components/checkbox/checkbox.component'; import { CounterComponent } from './components/counter/counter.component'; import { FormControlFeedbackComponent } from './components/form-control-feedback/form-control-feedback.component'; @@ -27,10 +23,7 @@ import { SelectComponent } from './components/select/select.component'; import { TextareaComponent } from './components/textarea/textarea.component'; import { ShowFormFeedbackDirective } from './directives/show-form-feedback.directive'; -const declaredComponents = [CaptchaV2Component, CaptchaV3Component]; - const exportedComponents = [ - CaptchaComponent, CheckboxComponent, CounterComponent, FormControlFeedbackComponent, @@ -48,17 +41,8 @@ const exportedComponents = [ TextareaComponent, ]; @NgModule({ - imports: [ - CommonModule, - FeatureToggleModule, - IconModule, - ReactiveFormsModule, - RecaptchaModule, - RecaptchaV3Module, - RouterModule, - TranslateModule, - ], - declarations: [...declaredComponents, ...exportedComponents], + imports: [CommonModule, FeatureToggleModule, IconModule, ReactiveFormsModule, RouterModule, TranslateModule], + declarations: [...exportedComponents], exports: [...exportedComponents], }) export class FormsSharedModule {} diff --git a/src/app/shell/shell.module.ts b/src/app/shell/shell.module.ts index 9232566056..9c10ace4c2 100644 --- a/src/app/shell/shell.module.ts +++ b/src/app/shell/shell.module.ts @@ -11,6 +11,7 @@ import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { IconModule } from 'ish-core/icon.module'; import { PipesModule } from 'ish-core/pipes.module'; +import { CaptchaExportsModule } from '../extensions/captcha/exports/captcha-exports.module'; import { QuickorderExportsModule } from '../extensions/quickorder/exports/quickorder-exports.module'; import { QuotingExportsModule } from '../extensions/quoting/exports/quoting-exports.module'; import { WishlistsExportsModule } from '../extensions/wishlists/exports/wishlists-exports.module'; @@ -30,7 +31,12 @@ import { SearchBoxComponent } from './header/search-box/search-box.component'; import { SubCategoryNavigationComponent } from './header/sub-category-navigation/sub-category-navigation.component'; import { UserInformationMobileComponent } from './header/user-information-mobile/user-information-mobile.component'; -const importExportModules = [QuickorderExportsModule, QuotingExportsModule, WishlistsExportsModule]; +const importExportModules = [ + CaptchaExportsModule, + QuickorderExportsModule, + QuotingExportsModule, + WishlistsExportsModule, +]; const exportedComponents = [ FooterComponent, diff --git a/src/environments/environment.model.ts b/src/environments/environment.model.ts index b2d28a4392..cb652c819f 100644 --- a/src/environments/environment.model.ts +++ b/src/environments/environment.model.ts @@ -21,8 +21,6 @@ export interface Environment { /* FEATURE TOOGLES */ features: ( - | 'captchaV2' - | 'captchaV3' | 'compare' | 'rating' | 'recently' @@ -47,16 +45,6 @@ export interface Environment { // log client-side javascript errors to sentry.io (to be used with 'sentry' feature, works with server side rendering only) sentryDSN?: string; - // protect form submission with captchas (to be used with 'captcha' feature) - /** For production systems the captcha site key needs to match the one configured in the ICM ReCaptcha service configuration. - * For development we use the development keys provided by Google (https://developers.google.com/recaptcha/docs/faq) - * that don't require real verification and are used in the ICM as well. - Site key: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI - Secret key: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe - */ - // TODO: get captcha site key, when Configuration Response in REST API is available - captchaSiteKey?: string; - /* PROGRESSIVE WEB APP CONFIGURATIONS */ // Bootstrap grid system breakpoint widths as defined in the variables-bootstrap-customized.scss for usage in Javascript logic diff --git a/tslint.json b/tslint.json index a2539672ff..9910742da6 100644 --- a/tslint.json +++ b/tslint.json @@ -337,7 +337,7 @@ "from": "lodash.*" }, { - "import": "^(?!(range|uniq|memoize|once|groupBy|countBy|flatten|isEqual|intersection|omit|pick)$).*", + "import": "^(?!(range|uniq|memoize|once|groupBy|countBy|flatten|isEqual|intersection|omit|pick|mapValues)$).*", "from": "lodash.*" }, { @@ -654,6 +654,10 @@ "name": "^([A-Z].*)StoreModule$", "file": ".*/(|store)/-store\\.module\\.ts$" }, + { + "name": "^([A-Z].*)ComponentModule$", + "file": ".*/\\.component\\.ts$" + }, { "name": "^(.*)Module$", "file": ".*(cypress.*|//|/projects//src/app/|/core/[a-z][a-z0-9-]+)\\.module\\.ts$"