@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
JavaScript
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