import { type ValidationConfig, APP_ROOT_ELEMENT_REF } from '@allianz/taly-core';
import { DfBaseModule, DfCustomComponent } from '@allianz/taly-core/dynamic-form';
import { ValidationErrorsModule } from '@allianz/taly-core/validation-errors';
import { CommonModule } from '@angular/common';
import {
afterNextRender,
AfterViewInit,
Component,
computed,
ElementRef,
inject,
Injector,
LOCALE_ID,
OnDestroy,
Renderer2,
signal,
type Signal,
ViewChild
} from '@angular/core';
import { Validators } from '@angular/forms';
import { LocalizeFn } from '@angular/localize/init';
import { AclService } from '@allianz/taly-acl/angular';
import { filter, Subject, switchMap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { PfeBusinessService } from '@allianz/ngx-pfe';
declare let $localize: LocalizeFn;
declare global {
interface Window {
onloadTurnstileCallback: () => void;
turnstile: {
render: (idOrContainer: string | HTMLElement, options: TurnstileOptions) => string;
reset: (widgetIdOrContainer: string | HTMLElement) => void;
getResponse: (widgetIdOrContainer: string | HTMLElement) => string | undefined;
remove: (widgetIdOrContainer: string | HTMLElement) => void;
ready: (callback: () => void) => void;
};
}
}
// Interface extracted of the Turnstile documentation:
// https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations
interface TurnstileOptions {
sitekey: string;
action?: string;
cData?: string;
callback?: (token: string) => void;
'error-callback'?: (errorCode: string) => void;
'expired-callback'?: () => void;
theme?: 'light' | 'dark' | 'auto';
language?: string;
tabindex?: number;
appearance?: 'always' | 'execute' | 'interaction-only';
}
export type TurnstileConfig = Pick<TurnstileOptions, 'action'>;
@Component({
selector: 'df-turnstile-component',
imports: [CommonModule, ValidationErrorsModule, DfBaseModule],
templateUrl: './turnstile.component.html'
})
export class TurnstileComponent
extends DfCustomComponent<TurnstileConfig>
implements AfterViewInit, OnDestroy
{
@ViewChild('turnstileContainer') turnstileContainer!: ElementRef<HTMLDivElement>;
appRootElementRef = inject(APP_ROOT_ELEMENT_REF);
private localeId: string = inject(LOCALE_ID);
private aclService = inject(AclService);
private pfeBusinessService = inject(PfeBusinessService);
private scriptElement?: HTMLScriptElement;
private injector = inject(Injector);
private renderer = inject(Renderer2);
private scriptLoaded$ = new Subject<void>();
protected enrichedValidationConfigs: Signal<ValidationConfig[]> = signal([]);
constructor() {
super();
this.control()?.addValidators(Validators.required);
this.enrichedValidationConfigs = computed(() => {
const requiredValidationConfig: ValidationConfig = {
validator: Validators.required,
validatorName: 'required',
errorMessage: $localize`:@@validation.error.required:This field is required!`
};
const turnstileErrorValidationConfig: ValidationConfig = {
validatorName: 'turnstileError',
errorMessage: $localize`:@@validation.error.turnstile-error:An unexpected error occurred. Please try again later.`
} as unknown as ValidationConfig;
return [
...(this.validationConfigs() || []),
requiredValidationConfig,
turnstileErrorValidationConfig
];
});
}
ngAfterViewInit() {
this.handleTurstileInitialization();
this.addTurnstileScript();
}
ngOnDestroy() {
if (this.scriptElement) {
this.scriptElement.onload = null;
}
if (this.appRootElementRef.shadowRoot && this.turnstileContainer.nativeElement) {
this.renderer.removeChild(
this.appRootElementRef.nativeElement(),
this.turnstileContainer.nativeElement
);
}
}
private addTurnstileScript() {
if (window.turnstile) {
this.scriptLoaded$.next();
return;
}
this.scriptElement = document.createElement('script');
this.scriptElement.src =
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
this.scriptElement.async = false;
this.scriptElement.defer = false;
this.scriptElement.onload = () => this.scriptLoaded$.next();
document.head.appendChild(this.scriptElement);
}
private handleTurstileInitialization() {
const aclPath = this.formAclPath() + this.aclResource;
this.scriptLoaded$
.pipe(
switchMap(() => this.aclService.isHidden$(aclPath)),
filter((hidden) => !hidden),
switchMap(() =>
this.pfeBusinessService.getObservableForExpressionKey('$._taly.turnstileSitekey', true)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((sitekey) =>
afterNextRender(() => this.showTurnstile(sitekey), { injector: this.injector })
);
}
private showTurnstile(sitekey?: string) {
this.removeTurnstile();
if (!sitekey) {
console.error('Not able to initialize turnstile component because sitekey is missing.');
return;
}
const config = this.config().config;
const action = config?.action || '';
if (window?.turnstile === undefined) return;
if (this.appRootElementRef.shadowRoot) {
this.renderer.appendChild(
this.appRootElementRef.nativeElement(),
this.turnstileContainer.nativeElement
);
this.renderer.setAttribute(this.turnstileContainer.nativeElement, 'slot', 'turnstile');
}
window.turnstile.ready(() => {
window.turnstile.render(this.turnstileContainer.nativeElement, {
language: this.localeId.toLocaleLowerCase(),
action: action as string,
sitekey: sitekey,
callback: (token) => {
this.control()?.setValue(token);
},
'error-callback': (errorCode) => {
this.control()?.setValue(null);
this.control()?.setErrors({ turnstileError: true });
console.error(
'Turnstile Error. Open https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/ for more details on this error code: ',
errorCode
);
},
'expired-callback': () => {
this.control()?.setValue(null);
this.control()?.setErrors({ turnstileError: true });
console.error('Turnstile token expired and did not reset the widget');
}
});
});
}
private removeTurnstile() {
if (this.turnstileContainer && this.turnstileContainer.nativeElement) {
this.turnstileContainer.nativeElement.innerHTML = '';
}
}
}