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

429 lines (418 loc) 18 kB
import * as i0 from '@angular/core'; import { InjectionToken, makeEnvironmentProviders, inject, signal, Injectable, afterNextRender, APP_ID, ChangeDetectorRef, input, computed, Directive, ElementRef, effect, forwardRef } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; 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 { 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'; scriptStatus = signal(null); loadPromise = null; /** * 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.set(true); } } } /** * Load the reCAPTCHA script dynamically * @returns Promise that resolves to true when script is loaded */ loadScript() { // First check if script exists and is loaded this.checkScriptExists(); // If script is already loaded, return success immediately if (this.scriptStatus() === true) { return Promise.resolve(true); } // If script is currently loading, return the existing promise if (this.loadPromise) { return this.loadPromise; } // Create a new loading promise this.loadPromise = new Promise((resolve, reject) => { // Create a unique callback function name if not provided const callbackName = `onRecaptchaLoaded_${Date.now()}`; // Define the callback function in window scope window[callbackName] = () => { // Start checking for grecaptcha object this.checkGrecaptchaAvailability(3, resolve, reject); }; // 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}`); } // Always include onload parameter params.push(`onload=${callbackName}`); 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 error handler script.onerror = () => { this.loadPromise = null; console.error('Error loading reCAPTCHA script'); this.scriptStatus.set(false); reject(new Error('Failed to load reCAPTCHA script')); }; // Add script to document document.head.appendChild(script); }); return this.loadPromise; } /** * Check repeatedly if grecaptcha is available * @param attempts Number of attempts to check * @param resolve Promise resolve function * @param reject Promise reject function */ checkGrecaptchaAvailability(attempts, resolve, reject) { if (window['grecaptcha'] && typeof window['grecaptcha'].render === 'function') { this.scriptStatus.set(true); this.loadPromise = null; resolve(true); return; } if (attempts <= 0) { this.loadPromise = null; console.error('reCAPTCHA script loaded but grecaptcha object not available'); this.scriptStatus.set(false); reject(new Error('reCAPTCHA script loaded but grecaptcha object not available')); return; } // Try again after a short delay setTimeout(() => this.checkGrecaptchaAvailability(attempts - 1, resolve, reject), 200); } /** * Remove the reCAPTCHA script from the DOM */ removeScript() { const script = document.getElementById(this.scriptId); if (script) { script.remove(); delete window['grecaptcha']; this.scriptStatus.set(null); this.loadPromise = null; } } /** * Reset all reCAPTCHA instances on the page */ resetAll() { if (this.scriptStatus() === true && window['grecaptcha']) { try { window['grecaptcha'].reset(); } catch (e) { console.warn('Error resetting reCAPTCHA:', e); } } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: ScReCaptchaService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: ScReCaptchaService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: ScReCaptchaService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); class ScScoreReCaptcha { v3SiteKey = inject(SC_RE_CAPTCHA_V3_SITE_KEY); scReCaptchaService = inject(ScReCaptchaService); constructor() { afterNextRender(async () => { await this.scReCaptchaService.loadScript(); }); } 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: "20.0.4", ngImport: i0, type: ScScoreReCaptcha, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: ScScoreReCaptcha, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: ScScoreReCaptcha, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [] }); /** * 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: "20.0.4", ngImport: i0, type: IdGenerator, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: IdGenerator, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", 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); constructor() { afterNextRender(async () => { await this.scReCaptchaService.loadScript(); this.render(); }); } // eslint-disable-next-line @typescript-eslint/no-empty-function render() { } renderWidget(themeOrBadge, themeOrBadgeValue, sizeOrInvisible) { this.widgetId = grecaptcha.render(this.id, { sitekey: this.siteKey(), [themeOrBadge]: themeOrBadgeValue, size: sizeOrInvisible, 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; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: ScReCaptchaBase, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.0.4", 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 } }, host: { properties: { "id": "id", "class.g-recaptcha": "true" } }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: ScReCaptchaBase, decorators: [{ type: Directive, args: [{ host: { '[id]': 'id', '[class.g-recaptcha]': 'true', }, }] }], ctorParameters: () => [] }); class ScCheckboxReCaptcha extends ScReCaptchaBase { host = inject(ElementRef); theme = input('light'); size = input('normal'); render() { this.renderWidget('theme', this.theme(), this.size()); } isFirstRun = true; firstTheme; constructor() { super(); effect(() => { this.updateRecaptchaTheme(this.theme()); }); } reset() { super.reset(); if (this.firstTheme !== this.theme()) { this.updateRecaptchaTheme(this.theme()); } } updateRecaptchaTheme(newTheme) { if (this.isFirstRun) { this.firstTheme = newTheme; 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: "20.0.4", ngImport: i0, type: ScCheckboxReCaptcha, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.0.4", 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: "20.0.4", 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'); render() { this.renderWidget('badge', this.badge(), 'invisible'); } execute() { grecaptcha.execute(this.widgetId); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: ScInvisibleReCaptcha, deps: null, target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.0.4", 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: "20.0.4", 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