PluginProbe
The Innovative Form Builder – IvyForms / 0.3.1
The Innovative Form Builder – IvyForms v0.3.1
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / services / recaptcha.ts

recaptcha.ts in The Innovative Form Builder – IvyForms 0.3.1, at frontend/src/services/recaptcha.ts

200 lines 5.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * reCAPTCHA Service for Frontend Integration
3 * Handles Google reCAPTCHA v2, v3, and invisible integration
4 */
5
6 import type { RecaptchaConfig, RecaptchaWidgetOptions } from '@/types/recaptcha/recaptcha-interface'
7 import { useLabels } from '@/composables/useLabels'
8
9 const { getLabel } = useLabels()
10
11 export interface RecaptchaInstance {
12 render: (container: string | HTMLElement, options: RecaptchaWidgetOptions) => number
13 execute: (siteKeyOrWidgetId: string | number, options?: { action?: string }) => Promise<string>
14 getResponse: (widgetId?: number) => string
15 reset: (widgetId?: number) => void
16 ready: (callback: () => void) => void
17 }
18
19 declare global {
20 interface Window {
21 grecaptcha?: RecaptchaInstance
22 onRecaptchaLoad?: () => void
23 }
24 }
25
26 export class RecaptchaService {
27 private static instance: RecaptchaService
28 private config: RecaptchaConfig | null = null
29 private widgetId: number | null = null
30 private scriptLoaded = false
31 private executeResolver: ((token: string) => void) | null = null
32
33 static getInstance(): RecaptchaService {
34 if (!RecaptchaService.instance) {
35 RecaptchaService.instance = new RecaptchaService()
36 }
37 return RecaptchaService.instance
38 }
39
40 /**
41 * Initialize reCAPTCHA with configuration
42 * Note: Assumes Google reCAPTCHA script is already loaded by ShortcodeService
43 */
44 async init(config: RecaptchaConfig): Promise<void> {
45 this.config = config
46
47 return new Promise((resolve) => {
48 if (window.grecaptcha) {
49 window.grecaptcha.ready(() => {
50 this.scriptLoaded = true
51 resolve()
52 })
53 } else {
54 // If script isn't loaded yet, wait a bit and try again
55 setTimeout(() => {
56 if (window.grecaptcha) {
57 window.grecaptcha.ready(() => {
58 this.scriptLoaded = true
59 resolve()
60 })
61 } else {
62 console.warn(getLabel('recaptcha_script_not_found'))
63 resolve() // Resolve anyway to avoid hanging
64 }
65 }, 100)
66 }
67 })
68 }
69
70 /**
71 * Render reCAPTCHA widget
72 */
73 async render(
74 container: string | HTMLElement,
75 options: Partial<RecaptchaWidgetOptions> = {},
76 ): Promise<number> {
77 if (!window.grecaptcha || !this.config) {
78 throw new Error(getLabel('recaptcha_not_initialized'))
79 }
80
81 const renderOptions: RecaptchaWidgetOptions = {
82 sitekey: this.config.siteKey, // This is the critical missing piece!
83 callback: options.callback,
84 'expired-callback': options['expired-callback'],
85 'error-callback': options['error-callback'],
86 size: this.config.type === 'invisible' ? 'invisible' : options.size || 'normal',
87 theme: this.config.theme || 'light',
88 tabindex: options.tabindex,
89 }
90
91 // For invisible reCAPTCHA, we need to wrap the callback to handle execute promise resolution
92 if (this.config.type === 'invisible' && options.callback) {
93 const originalCallback = options.callback
94 renderOptions.callback = (token: string) => {
95 originalCallback(token)
96 // Also resolve any pending execute promise
97 if (this.executeResolver) {
98 this.executeResolver(token)
99 this.executeResolver = null
100 }
101 }
102 }
103
104 return new Promise((resolve) => {
105 window.grecaptcha!.ready(() => {
106 this.widgetId = window.grecaptcha!.render(container, renderOptions)
107 resolve(this.widgetId)
108 })
109 })
110 }
111
112 /**
113 * Execute reCAPTCHA (for v3 and invisible)
114 */
115 async execute(action: string = 'submit'): Promise<string> {
116 if (!window.grecaptcha || !this.config) {
117 throw new Error(getLabel('recaptcha_not_initialized'))
118 }
119
120 return new Promise((resolve, reject) => {
121 window.grecaptcha!.ready(async () => {
122 try {
123 if (this.config!.type === 'v3') {
124 const token = await window.grecaptcha!.execute(this.config!.siteKey, { action })
125 resolve(token)
126 } else if (this.config!.type === 'invisible') {
127 // For invisible v2, execute the widget and wait for callback
128 if (this.widgetId === null) {
129 reject(new Error(getLabel('recaptcha_widget_not_initialized')))
130 return
131 }
132
133 // Set up promise resolver for callback
134 this.executeResolver = resolve
135
136 // Execute the invisible reCAPTCHA
137 await window.grecaptcha!.execute(this.widgetId)
138
139 // Set timeout to reject if no response in 30 seconds
140 setTimeout(() => {
141 if (this.executeResolver) {
142 this.executeResolver = null
143 reject(new Error(getLabel('recaptcha_execution_timeout')))
144 }
145 }, 30000)
146 } else {
147 reject(new Error(getLabel('recaptcha_execute_not_supported')))
148 }
149 } catch (error) {
150 reject(error)
151 }
152 })
153 })
154 }
155
156 /**
157 * Get response from reCAPTCHA widget
158 */
159 getResponse(): string {
160 if (!window.grecaptcha) {
161 return ''
162 }
163
164 return window.grecaptcha.getResponse(this.widgetId || 0)
165 }
166
167 /**
168 * Reset reCAPTCHA widget
169 */
170 reset(): void {
171 if (window.grecaptcha && this.widgetId !== null) {
172 window.grecaptcha.reset(this.widgetId)
173 }
174 }
175
176 /**
177 * Get current widget ID
178 */
179 getWidgetId(): number | null {
180 return this.widgetId
181 }
182
183 /**
184 * Check if reCAPTCHA is ready
185 */
186 isReady(): boolean {
187 return this.scriptLoaded && !!window.grecaptcha
188 }
189
190 /**
191 * Get current configuration
192 */
193 getConfig(): RecaptchaConfig | null {
194 return this.config
195 }
196 }
197
198 // Export singleton instance
199 export const recaptchaService = RecaptchaService.getInstance()
200