UNPKG

@semantic-components/re-captcha

Version:

**@semantic-components/re-captcha** is an Angular library designed to simplify the integration of Google reCAPTCHA into your Angular applications. It supports reCAPTCHA v2 and v3, providing an easy-to-use API and seamless setup for enhancing your app's se

455 lines (444 loc) 19 kB
import * as i0 from '@angular/core'; import { InjectionToken, makeEnvironmentProviders, inject, NgZone, Injectable, APP_ID, ChangeDetectorRef, input, computed, signal, ElementRef, output, Directive, effect, forwardRef } from '@angular/core'; import { filter, take } from 'rxjs/operators'; import { BehaviorSubject } from 'rxjs'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { Router } from '@angular/router'; const SC_RE_CAPTCHA_V2_SITE_KEY = new InjectionToken('SC_RE_CAPTCHA_V2_SITE_KEY'); const SC_RE_CAPTCHA_V3_SITE_KEY = new InjectionToken('SC_RE_CAPTCHA_V3_SITE_KEY'); const SC_RE_CAPTCHA_LANGUAGE_CODE = new InjectionToken('SC_RE_CAPTCHA_LANGUAGE_CODE'); function provideScReCaptchaSettings(settings) { const providers = []; if (settings.v2SiteKey) { providers.push({ provide: SC_RE_CAPTCHA_V2_SITE_KEY, useValue: settings.v2SiteKey, }); } if (settings.v3SiteKey) { providers.push({ provide: SC_RE_CAPTCHA_V3_SITE_KEY, useValue: settings.v3SiteKey, }); } if (settings.languageCode) { providers.push({ provide: SC_RE_CAPTCHA_LANGUAGE_CODE, useValue: settings.languageCode, }); } return makeEnvironmentProviders(providers); } class ScReCaptchaService { zone = inject(NgZone); v3SiteKey = inject(SC_RE_CAPTCHA_V3_SITE_KEY, { optional: true }); languageCode = inject(SC_RE_CAPTCHA_LANGUAGE_CODE, { optional: true }); scriptId = 'recaptcha-script'; apiUrl = 'https://www.google.com/recaptcha/api.js'; // Use BehaviorSubject with three states: null (initial), true (loaded), false (error) scriptStatus$ = new BehaviorSubject(null); scriptLoading = false; constructor() { // Check if script already exists on page load this.checkScriptExists(); } /** * Check if the script already exists in the document */ checkScriptExists() { const existingScript = document.getElementById(this.scriptId); if (existingScript) { // Check if grecaptcha is actually available in window object if (window['grecaptcha'] && typeof window['grecaptcha'].render === 'function') { this.scriptStatus$.next(true); } } } /** * Load the reCAPTCHA script dynamically * @param onload Optional callback function name for script load * @returns Observable that emits true when script is loaded */ loadScript(onload) { // First check if script exists and is loaded this.checkScriptExists(); // If script is already loaded, return success immediately if (this.scriptStatus$.value === true) { return this.scriptStatus$.pipe(filter((status) => status === true), take(1)); } // If script is currently loading, return the observable without creating a new script if (this.scriptLoading) { return this.scriptStatus$.pipe(filter((status) => status !== null), take(1)); } this.scriptLoading = true; // Build URL with parameters if provided let url = this.apiUrl; const params = []; if (this.v3SiteKey) { params.push(`render=${this.v3SiteKey}`); } else { params.push(`render=explicit`); } if (this.languageCode) { params.push(`hl=${this.languageCode}`); } if (onload) { params.push(`onload=${onload}`); } if (params.length > 0) { url = `${url}?${params.join('&')}`; } // Create script element const script = document.createElement('script'); script.id = this.scriptId; script.src = url; script.async = true; script.defer = true; // Set up load and error handlers script.onload = () => { this.zone.run(() => { this.scriptLoading = false; // Wait a brief moment to ensure grecaptcha is fully initialized setTimeout(() => { if (window['grecaptcha'] && typeof window['grecaptcha'].render === 'function') { this.scriptStatus$.next(true); } else { console.error('reCAPTCHA script loaded but grecaptcha object not available'); this.scriptStatus$.next(false); } }, 100); }); }; script.onerror = () => { this.zone.run(() => { this.scriptLoading = false; console.error('Error loading reCAPTCHA script'); this.scriptStatus$.next(false); }); }; // Add script to document document.head.appendChild(script); return this.scriptStatus$.pipe(filter((status) => status !== null), take(1)); } /** * Remove the reCAPTCHA script from the DOM */ removeScript() { const script = document.getElementById(this.scriptId); if (script) { script.remove(); delete window['grecaptcha']; this.scriptStatus$.next(null); this.scriptLoading = false; } } /** * Check if the script is loaded */ isLoaded() { return this.scriptStatus$.asObservable(); } /** * Reset all reCAPTCHA instances on the page */ resetAll() { if (this.scriptStatus$.value === true && window['grecaptcha']) { try { window['grecaptcha'].reset(); } catch (e) { console.warn('Error resetting reCAPTCHA:', e); } } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScReCaptchaService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScReCaptchaService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScReCaptchaService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [] }); class ScScoreReCaptcha { v3SiteKey = inject(SC_RE_CAPTCHA_V3_SITE_KEY); scReCaptchaService = inject(ScReCaptchaService); async execute(actionName) { return new Promise((resolve) => { grecaptcha.ready(() => { grecaptcha.execute(this.v3SiteKey, { action: actionName }).then((token) => { resolve(token); }); }); }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScScoreReCaptcha, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScScoreReCaptcha, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScScoreReCaptcha, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); /** * Keeps track of the ID count per prefix. This helps us make the IDs a bit more deterministic * like they were before the service was introduced. Note that ideally we wouldn't have to do * this, but there are some internal tests that rely on the IDs. */ const counters = {}; /** Service that generates unique IDs for DOM nodes. */ class IdGenerator { appId = inject(APP_ID); /** * Generates a unique ID with a specific prefix. * @param prefix Prefix to add to the ID. */ getId(prefix) { // Omit the app ID if it's the default `ng`. Since the vast majority of pages have one // Angular app on them, we can reduce the amount of breakages by not adding it. if (this.appId !== 'ng') { prefix += this.appId; } // eslint-disable-next-line no-prototype-builtins if (!counters.hasOwnProperty(prefix)) { counters[prefix] = 0; } return `${prefix}${counters[prefix]++}`; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: IdGenerator, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: IdGenerator, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: IdGenerator, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); class ScReCaptchaBase { id = inject(IdGenerator).getId('sc-re-captcha-'); widgetId = ''; changeDetectorRef = inject(ChangeDetectorRef); scReCaptchaService = inject(ScReCaptchaService); v2SiteKey = inject(SC_RE_CAPTCHA_V2_SITE_KEY, { optional: true, }); siteKeyInput = input('', { alias: 'siteKey', }); siteKey = computed(() => { if (this.siteKeyInput()) { return this.siteKeyInput(); } return this.v2SiteKey ?? ''; }); tabindex = input('0'); callback = input(undefined); expiredCallback = input(undefined, { alias: 'expired-callback', }); errorCallback = input(undefined, { alias: 'error-callback', }); value = signal(null); disabledByCva = signal(false); scriptLoaded = false; router = inject(Router); recaptchaContainer = inject(ElementRef); subscriptions = []; scriptLoadError = output(); ngOnInit() { // this.registerCallbacks(); this.loadRecaptcha(); } ngOnDestroy() { // Clean up all subscriptions this.subscriptions.forEach((sub) => sub.unsubscribe()); } // Check if widget is actually rendered in the DOM isWidgetRendered() { if (!this.recaptchaContainer?.nativeElement) { return false; } // Check if iframe exists inside the container (reCAPTCHA creates an iframe when rendered) return this.recaptchaContainer.nativeElement.querySelector('iframe') !== null; } loadRecaptcha() { const scriptSub = this.scReCaptchaService.loadScript().subscribe((loaded) => { this.scriptLoaded = loaded; if (!loaded) { this.scriptLoadError.emit(); return; } // If callbacks aren't registered yet, do it now // if (!this.callbacksRegistered) { // this.registerCallbacks(); // } // If container is available (view initialized), render widget if (this.recaptchaContainer) { setTimeout(() => this.render(), 0); } }); this.subscriptions.push(scriptSub); } // eslint-disable-next-line @typescript-eslint/no-empty-function render() { } renderWidget(themeOrBadge, themeOrBadgeValue, size) { this.widgetId = grecaptcha.render(this.id, { sitekey: this.siteKey(), [themeOrBadge]: themeOrBadgeValue, size: size, tabindex: this.tabindex(), callback: this.callback() ? this.callback() : this.defaultCallback.bind(this), 'expired-callback': this.expiredCallback() ? this.expiredCallback() : this.defaultExpiredCallback.bind(this), 'error-callback': this.errorCallback() ? this.errorCallback() : this.defaultErrorCallback.bind(this), }, true); } defaultCallback(token) { this.setValue(token); } defaultExpiredCallback() { this.setValue(null); } defaultErrorCallback() { console.error('error'); this.setValue(null); } getResponse() { grecaptcha.getResponse(this.widgetId); } reset() { grecaptcha.reset(this.widgetId); } setValue(newValue) { this.value.set(newValue); this.onChange(newValue); this.changeDetectorRef.markForCheck(); } writeValue(obj) { this.value.set(obj); } // eslint-disable-next-line @typescript-eslint/no-empty-function onChange = () => { }; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouch = () => { }; registerOnChange(fn) { this.onChange = fn; } registerOnTouched(fn) { this.onTouch = fn; } setDisabledState(isDisabled) { this.disabledByCva.set(isDisabled); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScReCaptchaBase, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.7", type: ScReCaptchaBase, isStandalone: true, inputs: { siteKeyInput: { classPropertyName: "siteKeyInput", publicName: "siteKey", isSignal: true, isRequired: false, transformFunction: null }, tabindex: { classPropertyName: "tabindex", publicName: "tabindex", isSignal: true, isRequired: false, transformFunction: null }, callback: { classPropertyName: "callback", publicName: "callback", isSignal: true, isRequired: false, transformFunction: null }, expiredCallback: { classPropertyName: "expiredCallback", publicName: "expired-callback", isSignal: true, isRequired: false, transformFunction: null }, errorCallback: { classPropertyName: "errorCallback", publicName: "error-callback", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { scriptLoadError: "scriptLoadError" }, host: { properties: { "id": "id", "class.g-recaptcha": "true" } }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScReCaptchaBase, decorators: [{ type: Directive, args: [{ host: { '[id]': 'id', '[class.g-recaptcha]': 'true', }, }] }] }); class ScCheckboxReCaptcha extends ScReCaptchaBase { host = inject(ElementRef); theme = input('light'); size = input('normal'); render() { this.renderWidget('theme', this.theme(), this.size()); } isFirstRun = true; constructor() { super(); effect(() => { this.updateRecaptchaTheme(this.theme()); }); } updateRecaptchaTheme(newTheme) { if (this.isFirstRun) { this.isFirstRun = false; return; } // Find the reCAPTCHA iframe const recaptchaFrame = this.host.nativeElement.querySelector('iframe[src*="recaptcha"]'); if (recaptchaFrame) { // Get the iframe URL let frameSource = recaptchaFrame.src; // Replace the theme parameter in the URL if (frameSource.includes('theme=')) { frameSource = frameSource.replace(/theme=(light|dark)/, `theme=${newTheme}`); } else { frameSource += `&theme=${newTheme}`; } // Update the iframe source recaptchaFrame.src = frameSource; } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScCheckboxReCaptcha, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.7", type: ScCheckboxReCaptcha, isStandalone: true, selector: "div[sc-checkbox-re-captcha]", inputs: { theme: { classPropertyName: "theme", publicName: "theme", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null } }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ScCheckboxReCaptcha), multi: true, }, ], exportAs: ["scCheckboxReCaptcha"], usesInheritance: true, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScCheckboxReCaptcha, decorators: [{ type: Directive, args: [{ selector: 'div[sc-checkbox-re-captcha]', exportAs: 'scCheckboxReCaptcha', providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ScCheckboxReCaptcha), multi: true, }, ], }] }], ctorParameters: () => [] }); class ScInvisibleReCaptcha extends ScReCaptchaBase { badge = input('bottomright'); size = signal('invisible'); render() { this.renderWidget('badge', this.badge(), this.size()); } execute() { grecaptcha.execute(this.widgetId); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScInvisibleReCaptcha, deps: null, target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.7", type: ScInvisibleReCaptcha, isStandalone: true, selector: "div[sc-invisible-re-captcha], button[sc-invisible-re-captcha]", inputs: { badge: { classPropertyName: "badge", publicName: "badge", isSignal: true, isRequired: false, transformFunction: null } }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ScInvisibleReCaptcha), multi: true, }, ], exportAs: ["scInvisibleReCaptcha"], usesInheritance: true, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.7", ngImport: i0, type: ScInvisibleReCaptcha, decorators: [{ type: Directive, args: [{ selector: 'div[sc-invisible-re-captcha], button[sc-invisible-re-captcha]', exportAs: 'scInvisibleReCaptcha', providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ScInvisibleReCaptcha), multi: true, }, ], }] }] }); /** * Generated bundle index. Do not edit. */ export { ScCheckboxReCaptcha, ScInvisibleReCaptcha, ScScoreReCaptcha, provideScReCaptchaSettings }; //# sourceMappingURL=semantic-components-re-captcha.mjs.map