File

libs/core-forms/src/lib/turnstile-component-plugin/turnstile.component.ts

Index

Properties

Properties

action (Optional)
Type string
appearance (Optional)
Type "always" | "execute" | "interaction-only"
callback (Optional)
Type function
cData (Optional)
Type string
error-callback (Optional)
Type function
expired-callback (Optional)
Type function
language (Optional)
Type string
sitekey
Type string
tabindex (Optional)
Type number
theme (Optional)
Type "light" | "dark" | "auto"
import type { ValidationConfig } 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 {
  AfterViewInit,
  Component,
  computed,
  ElementRef,
  inject,
  LOCALE_ID,
  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 { skip } 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',
  standalone: true,
  imports: [CommonModule, ValidationErrorsModule, DfBaseModule],
  template: `<ng-container *aclTag="aclResource">
    <div #turnstileContainer id="js-turnstile-container"></div>
    <taly-validation-errors
      *ngIf="control()?.touched"
      ngProjectAs="nx-error"
      nxFormfieldError
      [errorMessages]="enrichedValidationConfigs()"
      [controlErrors]="control()?.errors"
    >
    </taly-validation-errors>
  </ng-container>`
})
export class TurnstileComponent
  extends DfCustomComponent<TurnstileConfig>
  implements AfterViewInit
{
  @ViewChild('turnstileContainer') turnstileContainer!: ElementRef<HTMLDivElement>;

  private localeId: string = inject(LOCALE_ID);
  private aclService = inject(AclService);
  private pfeBusinessService = inject(PfeBusinessService);
  private scriptElement?: HTMLScriptElement;

  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() {
    const aclPath = this.formAclPath() + this.aclResource;
    this.aclService
      .isHidden$(aclPath)
      // Skip first emission from isHidden$ as addTurnstileScript() already handles initial rendering of Turnstile after script loading
      .pipe(skip(1), takeUntilDestroyed(this.destroyRef))
      .subscribe((isHidden) => {
        if (!isHidden) {
          const sitekey = this.pfeBusinessService.getValueByExpression('$._taly.turnstileSitekey');
          this.showTurnstile(sitekey);
        }
      });
    this.addTurnstileScript();
  }

  private addTurnstileScript() {
    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;

    // setTimeout ensures Turnstile API is fully initialized before accessing it, preventing race conditions
    this.scriptElement.onload = () => {
      setTimeout(() => {
        this.pfeBusinessService
          .getObservableForExpressionKey('$._taly.turnstileSitekey', true)
          .subscribe((sitekey) => {
            this.showTurnstile(sitekey);
          });
      }, 0);
    };

    document.head.appendChild(this.scriptElement);
  }

  private showTurnstile(sitekey?: string) {
    this.removeTurnstile();
    const aclPath = this.formAclPath() + this.aclResource;

    if (this.aclService.isHidden(aclPath)) {
      return;
    }

    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;

    window.turnstile.ready(() => {
      window.turnstile.render('#js-turnstile-container', {
        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 = '';
    }
  }
}

results matching ""

    No results matching ""