diff --git a/e2e/cypress/integration/framework/index.ts b/e2e/cypress/integration/framework/index.ts index ef49a08da69..a6d92d78b65 100644 --- a/e2e/cypress/integration/framework/index.ts +++ b/e2e/cypress/integration/framework/index.ts @@ -39,9 +39,8 @@ export function fillFormField(parent: string, key: string, value: number | strin 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 e06d0333862..ace3c8d462e 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/specs/system/authentication-timeouts-loggedin.b2c.e2e-spec.ts b/e2e/cypress/integration/specs/system/authentication-timeouts-loggedin.b2c.e2e-spec.ts index 4f7f47e9e38..9f0f44f075e 100644 --- a/e2e/cypress/integration/specs/system/authentication-timeouts-loggedin.b2c.e2e-spec.ts +++ b/e2e/cypress/integration/specs/system/authentication-timeouts-loggedin.b2c.e2e-spec.ts @@ -1,4 +1,4 @@ -import { at } from '../../framework'; +import { at, waitLoadingEnd } from '../../framework'; import { createUserViaREST } from '../../framework/users'; import { LoginPage } from '../../pages/account/login.page'; import { MyAccountPage } from '../../pages/account/my-account.page'; @@ -19,6 +19,7 @@ describe('Logged in Sleeping User', () => { LoginPage.navigateTo('/category/' + _.category); at(LoginPage, page => page.fillForm(_.user.login, _.user.password).submit().its('status').should('equal', 200)); at(FamilyPage, page => { + waitLoadingEnd(2000); page.productList.productTile(_.product).should('be.visible'); page.header.myAccountLink.should('have.text', `${_.user.firstName} ${_.user.lastName}`); }); diff --git a/src/app/core/configuration.module.ts b/src/app/core/configuration.module.ts index 5c8df6f4b7b..a39bfc6b24d 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 3d30d28c207..d32bb038c80 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/store/configuration/configuration.effects.ts b/src/app/core/store/configuration/configuration.effects.ts index dce27baab47..6fe0ca7680c 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 00000000000..4c4cf392f2b --- /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 00000000000..a61aef13d6c --- /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 00000000000..0027a60acf6 --- /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 00000000000..36b58e3e9ff --- /dev/null +++ b/src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.ts @@ -0,0 +1,131 @@ +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 } 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: + | 'contactUs' + | 'emailShoppingCart' + | 'forgotPassword' + | 'redemptionOfGiftCardsAndCertificates' + | 'register'; + + 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 00000000000..d0d55df6e75 --- /dev/null +++ b/src/app/extensions/captcha/facades/captcha.facade.ts @@ -0,0 +1,55 @@ +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'; + +// 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: string): Observable { + return this.store.pipe( + filter(() => !!key), + switchMapTo(this.captchaVersion$), + map(x => !!x), + whenTruthy() + /* TODO: replace with the following when configuration REST API sends correct values + 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 00000000000..5f89b12991f --- /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 85dbdf0ca54..6600a809265 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 8c6076c6e19..6fad94c7070 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 00000000000..ff7ac8d5ad3 --- /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 3b7e39e21b8..bee7f31f40b 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 00000000000..b8bb8100168 --- /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 49961553020..e80ed14d9e1 100644 --- a/src/app/pages/contact/contact-form/contact-form.component.html +++ b/src/app/pages/contact/contact-form/contact-form.component.html @@ -37,7 +37,7 @@ rows="10" > - +
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 3c8c04e098e..48354cb08ef 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), ], 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 a9239b2ae88..5dcec991592 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 8aec9ba46bc..f7e5aedd411 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/registration/registration-form/registration-form.component.html b/src/app/pages/registration/registration-form/registration-form.component.html index e1c43ace3b9..0f3a26d5c00 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 98373f10d23..a96e3085fd7 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/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 1217eb4e537..00000000000 --- 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 255fa4edb86..00000000000 --- 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 dd75f4bc0b9..00000000000 --- 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 6053f819086..00000000000 --- 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 26d8fb6da58..00000000000 --- 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 4dc79b4489e..0980d01c12d 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 92325660567..9c10ace4c23 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 b2d28a43929..cb652c819fd 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 af668d66ebf..9910742da69 100644 --- a/tslint.json +++ b/tslint.json @@ -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$"