PluginProbe
The Innovative Form Builder – IvyForms / 0.9
The Innovative Form Builder – IvyForms v0.9
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.9, at frontend/src/services/recaptcha.ts

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