File

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

Index

Properties

Properties

onloadTurnstileCallback
Type function
turnstile
Type literal type
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,
  inject,
  LOCALE_ID,
  signal,
  type Signal
} 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';

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, 'sitekey' | 'action'>;

@Component({
  selector: 'df-turnstile-component',
  standalone: true,
  imports: [CommonModule, ValidationErrorsModule, DfBaseModule],
  template: `<ng-container *aclTag="aclResource">
    <div 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
{
  private localeId: string = inject(LOCALE_ID);
  private aclService = inject(AclService);
  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
      ];
    });

    this.addTurnstileScript();
  }

  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) {
          this.showTurnstile();
        }
      });
  }

  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(() => {
        const aclPath = this.formAclPath() + this.aclResource;
        if (!this.aclService.isHidden(aclPath)) {
          this.showTurnstile();
        }
      }, 0);
    };

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

  private showTurnstile() {
    const config = this.config().config;

    if (!config?.sitekey) {
      console.error(
        'Could not initialize turnstile component. Please provide a sitekey in the config.'
      );
      return;
    }
    const sitekey = config.sitekey;
    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 as string,
        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');
        }
      });
    });
  }
}

results matching ""

    No results matching ""