-
Notifications
You must be signed in to change notification settings - Fork 87
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: display captcha component according to the related ICM captcha …
…service (#200) - introduces lazy components for captcha - use temporary feature toggle 'noCaptcha' to disable captchas in PWA (server API bug) BREAKING CHANGE: feature toggles captchaV2 and captchaV3 are obsolete
- Loading branch information
Showing
30 changed files
with
497 additions
and
323 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
14 changes: 14 additions & 0 deletions
14
src/app/extensions/captcha/exports/captcha-exports.module.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 {} |
1 change: 1 addition & 0 deletions
1
src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<ng-template #anchor></ng-template> |
113 changes: 113 additions & 0 deletions
113
src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
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<LazyCaptchaComponent>; | ||
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] } }) | ||
// .overrideComponent(LazyCaptchaComponent, { set: { entryComponents: [CaptchaV2Component, 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(`<ish-captcha-v2></ish-captcha-v2>`); | ||
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(`<ish-captcha-v3></ish-captcha-v3>`); | ||
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 <form> is missing for FormElementComponent"` | ||
); | ||
}); | ||
|
||
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"` | ||
); | ||
}); | ||
}); |
131 changes: 131 additions & 0 deletions
131
src/app/extensions/captcha/exports/captcha/lazy-captcha/lazy-captcha.component.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
import { | ||
ChangeDetectionStrategy, | ||
Compiler, | ||
Component, | ||
Injector, | ||
Input, | ||
NgModuleFactory, | ||
OnDestroy, | ||
OnInit, | ||
ViewChild, | ||
ViewContainerRef, | ||
} from '@angular/core'; | ||
import { AbstractControl, FormGroup, Validators } from '@angular/forms'; | ||
import { Subject } from 'rxjs'; | ||
import { switchMapTo, takeUntil } from 'rxjs/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 | ||
* <ish-captcha [form]="form" cssClass="offset-md-2 col-md-8" topic="contactUs"></ish-captcha> | ||
*/ | ||
@Component({ | ||
selector: 'ish-lazy-captcha', | ||
templateUrl: './lazy-captcha.component.html', | ||
changeDetection: ChangeDetectionStrategy.Default, | ||
}) | ||
export class LazyCaptchaComponent implements OnInit, OnDestroy { | ||
@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'; | ||
|
||
private destroy$ = new Subject(); | ||
|
||
constructor(private captchaFacade: CaptchaFacade, private compiler: Compiler, private injector: Injector) {} | ||
|
||
ngOnInit() { | ||
this.sanityCheck(); | ||
|
||
this.captchaFacade | ||
.captchaActive$(this.topic) | ||
.pipe(switchMapTo(this.captchaFacade.captchaVersion$), takeUntil(this.destroy$)) | ||
.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 <form> is missing for FormElementComponent'); | ||
} | ||
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`); | ||
} | ||
} | ||
|
||
private get formControl(): AbstractControl { | ||
return this.form?.get('captcha'); | ||
} | ||
|
||
private get actionFormControl(): AbstractControl { | ||
return this.form?.get('captchaAction'); | ||
} | ||
|
||
ngOnDestroy() { | ||
this.destroy$.next(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import { Injectable } from '@angular/core'; | ||
import { Store, select } from '@ngrx/store'; | ||
import { Observable } from 'rxjs'; | ||
import { filter, map, switchMap, switchMapTo } from 'rxjs/operators'; | ||
|
||
import { getFeatures, 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<string>(`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<boolean> { | ||
return this.store.pipe( | ||
filter(() => !!key), | ||
select(getFeatures), | ||
filter(features => !features.includes('noCaptcha')), | ||
switchMapTo(this.captchaVersion$), | ||
map(x => !!x), | ||
whenTruthy() | ||
/* TODO: replace with the following when configuration REST API sends correct values | ||
select(getServerConfigParameter<boolean>('captcha.' + key)) | ||
*/ | ||
); | ||
} | ||
} |
Oops, something went wrong.