@rozie-ui/captcha-react 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/dist/index.cjs +359 -0
- package/dist/index.d.cts +41 -0
- package/dist/index.d.mts +41 -0
- package/dist/index.mjs +353 -0
- package/package.json +73 -0
- package/src/Captcha.d.ts +27 -0
- package/src/Captcha.tsx +141 -0
- package/src/RecaptchaV3.d.ts +21 -0
- package/src/RecaptchaV3.tsx +109 -0
- package/src/index.ts +9 -0
- package/src/internal/loadCaptchaApi.ts +174 -0
- package/src/internal/loadRecaptchaV3.ts +117 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
2
|
+
import { clsx, useControllableState } from "@rozie/runtime-react";
|
|
3
|
+
import { Fragment, jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/internal/loadCaptchaApi.ts
|
|
5
|
+
/**
|
|
6
|
+
* Bridge Friendly Captcha's `createWidget` SDK (the `@friendlycaptcha/sdk`
|
|
7
|
+
* compat build, loaded from the CDN — NO npm peer dependency) onto `CaptchaApi`.
|
|
8
|
+
*
|
|
9
|
+
* FC has no explicit-`render`/widget-id model: `createWidget({ element, ... })`
|
|
10
|
+
* returns an event-emitter handle (`frc:widget.complete|expire|error`) with
|
|
11
|
+
* `reset()` / `start()` / `getResponse()` / `destroy()`. We treat that handle AS
|
|
12
|
+
* the opaque widget id the rest of the family already threads around. FC also
|
|
13
|
+
* has no `size` concept — `startMode` is the closest analog and rides through
|
|
14
|
+
* the `options` escape hatch (or a `startMode` config key if a consumer passes
|
|
15
|
+
* one directly).
|
|
16
|
+
*/
|
|
17
|
+
const adaptFriendly = (g) => {
|
|
18
|
+
const fc = g;
|
|
19
|
+
return {
|
|
20
|
+
render(el, cfg) {
|
|
21
|
+
const h = fc.createWidget({
|
|
22
|
+
element: el,
|
|
23
|
+
sitekey: cfg.sitekey,
|
|
24
|
+
theme: cfg.theme,
|
|
25
|
+
startMode: cfg.startMode
|
|
26
|
+
});
|
|
27
|
+
h.addEventListener("frc:widget.complete", (e) => {
|
|
28
|
+
const cb = cfg.callback;
|
|
29
|
+
if (typeof cb === "function") cb(e.detail?.response ?? "");
|
|
30
|
+
});
|
|
31
|
+
h.addEventListener("frc:widget.expire", () => {
|
|
32
|
+
const cb = cfg["expired-callback"];
|
|
33
|
+
if (typeof cb === "function") cb();
|
|
34
|
+
});
|
|
35
|
+
h.addEventListener("frc:widget.error", () => {
|
|
36
|
+
const cb = cfg["error-callback"];
|
|
37
|
+
if (typeof cb === "function") cb();
|
|
38
|
+
});
|
|
39
|
+
return h;
|
|
40
|
+
},
|
|
41
|
+
reset: (id) => id.reset(),
|
|
42
|
+
execute: (id) => id.start(),
|
|
43
|
+
getResponse: (id) => id.getResponse(),
|
|
44
|
+
remove: (id) => id.destroy()
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const CAPTCHA_PROVIDERS = {
|
|
48
|
+
recaptcha: {
|
|
49
|
+
src: "https://www.google.com/recaptcha/api.js?render=explicit",
|
|
50
|
+
global: "grecaptcha"
|
|
51
|
+
},
|
|
52
|
+
hcaptcha: {
|
|
53
|
+
src: "https://js.hcaptcha.com/1/api.js?render=explicit",
|
|
54
|
+
global: "hcaptcha"
|
|
55
|
+
},
|
|
56
|
+
turnstile: {
|
|
57
|
+
src: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
|
|
58
|
+
global: "turnstile"
|
|
59
|
+
},
|
|
60
|
+
friendly: {
|
|
61
|
+
src: "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@1/site.compat.min.js",
|
|
62
|
+
global: "frcaptcha",
|
|
63
|
+
adapt: adaptFriendly
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Load a provider's api.js ONCE across the whole document, shared by every
|
|
68
|
+
* `<Captcha>` instance via a `globalThis` singleton (a per-instance cache would
|
|
69
|
+
* re-inject per component). Resolves with the provider global once its
|
|
70
|
+
* `render()` is callable; rejects on an unknown provider, a script load error,
|
|
71
|
+
* or a `LOAD_TIMEOUT_MS` timeout. `providers` is injectable for tests.
|
|
72
|
+
*/
|
|
73
|
+
function loadCaptchaApi(provider, providers = CAPTCHA_PROVIDERS) {
|
|
74
|
+
const cfg = providers[provider];
|
|
75
|
+
if (!cfg) return Promise.reject(/* @__PURE__ */ new Error("Unknown captcha provider: " + provider));
|
|
76
|
+
const root = globalThis;
|
|
77
|
+
const cache = root.__rozieCaptchaLoaders ||= {};
|
|
78
|
+
if (cache[provider]) return cache[provider];
|
|
79
|
+
const win = globalThis;
|
|
80
|
+
const ready = (g) => {
|
|
81
|
+
if (!g) return false;
|
|
82
|
+
const o = g;
|
|
83
|
+
return typeof o.render === "function" || typeof o.createWidget === "function";
|
|
84
|
+
};
|
|
85
|
+
const resolveApi = (g) => cfg.adapt ? cfg.adapt(g) : g;
|
|
86
|
+
const p = new Promise((resolve, reject) => {
|
|
87
|
+
if (ready(win[cfg.global])) {
|
|
88
|
+
resolve(resolveApi(win[cfg.global]));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!document.querySelector("script[data-rozie-captcha=\"" + provider + "\"]")) {
|
|
92
|
+
const el = document.createElement("script");
|
|
93
|
+
el.src = cfg.src;
|
|
94
|
+
el.async = true;
|
|
95
|
+
el.defer = true;
|
|
96
|
+
el.setAttribute("data-rozie-captcha", provider);
|
|
97
|
+
el.addEventListener("error", () => reject(/* @__PURE__ */ new Error("Failed to load " + provider + " script")));
|
|
98
|
+
document.head.appendChild(el);
|
|
99
|
+
}
|
|
100
|
+
const started = Date.now();
|
|
101
|
+
const poll = setInterval(() => {
|
|
102
|
+
if (ready(win[cfg.global])) {
|
|
103
|
+
clearInterval(poll);
|
|
104
|
+
resolve(resolveApi(win[cfg.global]));
|
|
105
|
+
} else if (Date.now() - started > 2e4) {
|
|
106
|
+
clearInterval(poll);
|
|
107
|
+
reject(/* @__PURE__ */ new Error(provider + " script load timeout"));
|
|
108
|
+
}
|
|
109
|
+
}, 50);
|
|
110
|
+
});
|
|
111
|
+
cache[provider] = p;
|
|
112
|
+
return p;
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/Captcha.tsx
|
|
116
|
+
const Captcha = forwardRef(function Captcha(_props, ref) {
|
|
117
|
+
const __defaultOptions = useState(() => ({}))[0];
|
|
118
|
+
const props = {
|
|
119
|
+
..._props,
|
|
120
|
+
provider: _props.provider ?? "recaptcha",
|
|
121
|
+
theme: _props.theme ?? "light",
|
|
122
|
+
size: _props.size ?? "normal",
|
|
123
|
+
tabindex: _props.tabindex ?? null,
|
|
124
|
+
options: _props.options ?? __defaultOptions
|
|
125
|
+
};
|
|
126
|
+
const attrs = (() => {
|
|
127
|
+
const { provider, sitekey, token, theme, size, tabindex, options, defaultValue, onTokenChange, defaultToken, ...rest } = _props;
|
|
128
|
+
return rest;
|
|
129
|
+
})();
|
|
130
|
+
const disposed = useRef(false);
|
|
131
|
+
const api = useRef(null);
|
|
132
|
+
const widgetId = useRef(null);
|
|
133
|
+
const [token, setToken] = useControllableState({
|
|
134
|
+
value: props.token,
|
|
135
|
+
defaultValue: props.defaultToken ?? "",
|
|
136
|
+
onValueChange: props.onTokenChange
|
|
137
|
+
});
|
|
138
|
+
const widgetEl = useRef(null);
|
|
139
|
+
const { onError: _rozieProp_onError, onExpire: _rozieProp_onExpire, onVerify: _rozieProp_onVerify } = props;
|
|
140
|
+
const buildConfig = useCallback(() => ({
|
|
141
|
+
sitekey: props.sitekey,
|
|
142
|
+
theme: props.theme,
|
|
143
|
+
size: props.size,
|
|
144
|
+
...props.tabindex != null ? { tabindex: props.tabindex } : {},
|
|
145
|
+
callback: (response) => {
|
|
146
|
+
setToken(response);
|
|
147
|
+
_rozieProp_onVerify && _rozieProp_onVerify({
|
|
148
|
+
token: response,
|
|
149
|
+
provider: props.provider
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
"expired-callback": () => {
|
|
153
|
+
setToken("");
|
|
154
|
+
_rozieProp_onExpire && _rozieProp_onExpire({ provider: props.provider });
|
|
155
|
+
},
|
|
156
|
+
"error-callback": () => {
|
|
157
|
+
setToken("");
|
|
158
|
+
_rozieProp_onError && _rozieProp_onError({ provider: props.provider });
|
|
159
|
+
},
|
|
160
|
+
...props.options
|
|
161
|
+
}), [
|
|
162
|
+
_rozieProp_onError,
|
|
163
|
+
_rozieProp_onExpire,
|
|
164
|
+
_rozieProp_onVerify,
|
|
165
|
+
props.options,
|
|
166
|
+
props.provider,
|
|
167
|
+
props.sitekey,
|
|
168
|
+
props.size,
|
|
169
|
+
props.tabindex,
|
|
170
|
+
props.theme,
|
|
171
|
+
setToken
|
|
172
|
+
]);
|
|
173
|
+
function reset() {
|
|
174
|
+
if (widgetId.current != null && api.current && typeof api.current.reset === "function") api.current.reset(widgetId.current);
|
|
175
|
+
setToken("");
|
|
176
|
+
}
|
|
177
|
+
function execute() {
|
|
178
|
+
if (widgetId.current != null && api.current && typeof api.current.execute === "function") api.current.execute(widgetId.current);
|
|
179
|
+
}
|
|
180
|
+
function getResponse() {
|
|
181
|
+
return widgetId.current != null && api.current && typeof api.current.getResponse === "function" ? api.current.getResponse(widgetId.current) : "";
|
|
182
|
+
}
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
disposed.current = false;
|
|
185
|
+
loadCaptchaApi(props.provider).then((a) => {
|
|
186
|
+
if (disposed.current) return;
|
|
187
|
+
api.current = a;
|
|
188
|
+
widgetId.current = api.current.render(widgetEl.current, buildConfig());
|
|
189
|
+
}).catch((err) => {
|
|
190
|
+
props.onError && props.onError({
|
|
191
|
+
provider: props.provider,
|
|
192
|
+
error: err
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
return () => {
|
|
196
|
+
disposed.current = true;
|
|
197
|
+
if (widgetId.current == null || !api.current) return;
|
|
198
|
+
if (typeof api.current.remove === "function") api.current.remove(widgetId.current);
|
|
199
|
+
else if (typeof api.current.reset === "function") api.current.reset(widgetId.current);
|
|
200
|
+
};
|
|
201
|
+
}, []);
|
|
202
|
+
const _rozieExposeRef = useRef({
|
|
203
|
+
reset,
|
|
204
|
+
execute,
|
|
205
|
+
getResponse
|
|
206
|
+
});
|
|
207
|
+
_rozieExposeRef.current = {
|
|
208
|
+
reset,
|
|
209
|
+
execute,
|
|
210
|
+
getResponse
|
|
211
|
+
};
|
|
212
|
+
useImperativeHandle(ref, () => ({
|
|
213
|
+
reset: (...args) => _rozieExposeRef.current.reset(...args),
|
|
214
|
+
execute: (...args) => _rozieExposeRef.current.execute(...args),
|
|
215
|
+
getResponse: (...args) => _rozieExposeRef.current.getResponse(...args)
|
|
216
|
+
}), []);
|
|
217
|
+
return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx("div", {
|
|
218
|
+
ref: widgetEl,
|
|
219
|
+
...attrs,
|
|
220
|
+
className: clsx("rozie-captcha", attrs.className),
|
|
221
|
+
"data-rozie-s-9c7749d4": ""
|
|
222
|
+
}) });
|
|
223
|
+
});
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/internal/loadRecaptchaV3.ts
|
|
226
|
+
/** The v3 api.js base URL. The sitekey is appended (`render=SITEKEY`) per call. */
|
|
227
|
+
const RECAPTCHA_V3_SRC = "https://www.google.com/recaptcha/api.js?render=";
|
|
228
|
+
const getGrecaptcha = () => globalThis.grecaptcha;
|
|
229
|
+
/**
|
|
230
|
+
* Load reCAPTCHA v3's api.js ONCE per sitekey across the whole document, shared
|
|
231
|
+
* by every `<RecaptchaV3>` instance via a `globalThis` singleton keyed on the
|
|
232
|
+
* sitekey. Resolves with the `grecaptcha` global once `grecaptcha.ready(cb)` has
|
|
233
|
+
* fired; rejects on a script load error or a `LOAD_TIMEOUT_MS` timeout.
|
|
234
|
+
*
|
|
235
|
+
* `srcBase` is injectable for tests (pass `''` so happy-dom does not attempt a
|
|
236
|
+
* real network fetch — mirrors loadCaptchaApi's injectable `providers` seam).
|
|
237
|
+
*/
|
|
238
|
+
function loadRecaptchaV3(sitekey, srcBase = RECAPTCHA_V3_SRC) {
|
|
239
|
+
const root = globalThis;
|
|
240
|
+
const cache = root.__rozieRecaptchaV3Loaders ||= {};
|
|
241
|
+
if (cache[sitekey]) return cache[sitekey];
|
|
242
|
+
const p = new Promise((resolve, reject) => {
|
|
243
|
+
let settled = false;
|
|
244
|
+
const ready = (g) => {
|
|
245
|
+
g.ready(() => {
|
|
246
|
+
if (settled) return;
|
|
247
|
+
settled = true;
|
|
248
|
+
resolve(g);
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
const present = getGrecaptcha();
|
|
252
|
+
if (present && typeof present.ready === "function") {
|
|
253
|
+
ready(present);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (!document.querySelector("script[data-rozie-recaptcha-v3=\"" + sitekey + "\"]")) {
|
|
257
|
+
const el = document.createElement("script");
|
|
258
|
+
el.src = srcBase + encodeURIComponent(sitekey);
|
|
259
|
+
el.async = true;
|
|
260
|
+
el.defer = true;
|
|
261
|
+
el.setAttribute("data-rozie-recaptcha-v3", sitekey);
|
|
262
|
+
el.addEventListener("error", () => {
|
|
263
|
+
if (settled) return;
|
|
264
|
+
settled = true;
|
|
265
|
+
reject(/* @__PURE__ */ new Error("Failed to load reCAPTCHA v3 script for sitekey: " + sitekey));
|
|
266
|
+
});
|
|
267
|
+
document.head.appendChild(el);
|
|
268
|
+
}
|
|
269
|
+
const started = Date.now();
|
|
270
|
+
const poll = setInterval(() => {
|
|
271
|
+
const g = getGrecaptcha();
|
|
272
|
+
if (g && typeof g.ready === "function") {
|
|
273
|
+
clearInterval(poll);
|
|
274
|
+
ready(g);
|
|
275
|
+
} else if (Date.now() - started > 2e4) {
|
|
276
|
+
clearInterval(poll);
|
|
277
|
+
if (settled) return;
|
|
278
|
+
settled = true;
|
|
279
|
+
reject(/* @__PURE__ */ new Error("reCAPTCHA v3 script load timeout for sitekey: " + sitekey));
|
|
280
|
+
}
|
|
281
|
+
}, 50);
|
|
282
|
+
});
|
|
283
|
+
cache[sitekey] = p;
|
|
284
|
+
return p;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Run a v3 challenge for `sitekey` + `action`, returning a fresh token.
|
|
288
|
+
* Delegates to `grecaptcha.execute(sitekey, { action })`. Call AFTER
|
|
289
|
+
* `loadRecaptchaV3(sitekey)` has resolved (the component threads that order).
|
|
290
|
+
*/
|
|
291
|
+
function execute(sitekey, opts) {
|
|
292
|
+
const g = getGrecaptcha();
|
|
293
|
+
if (!g || typeof g.execute !== "function") return Promise.reject(/* @__PURE__ */ new Error("reCAPTCHA v3 not loaded for sitekey: " + sitekey));
|
|
294
|
+
return g.execute(sitekey, opts);
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/RecaptchaV3.tsx
|
|
298
|
+
const RecaptchaV3 = forwardRef(function RecaptchaV3(_props, ref) {
|
|
299
|
+
const props = {
|
|
300
|
+
..._props,
|
|
301
|
+
action: _props.action ?? "submit",
|
|
302
|
+
executeOnMount: _props.executeOnMount ?? false
|
|
303
|
+
};
|
|
304
|
+
const attrs = (() => {
|
|
305
|
+
const { sitekey, action, token, executeOnMount, defaultValue, onTokenChange, defaultToken, ...rest } = _props;
|
|
306
|
+
return rest;
|
|
307
|
+
})();
|
|
308
|
+
const disposed = useRef(false);
|
|
309
|
+
const [token, setToken] = useControllableState({
|
|
310
|
+
value: props.token,
|
|
311
|
+
defaultValue: props.defaultToken ?? "",
|
|
312
|
+
onValueChange: props.onTokenChange
|
|
313
|
+
});
|
|
314
|
+
function execute$1(action = null) {
|
|
315
|
+
const a = action != null ? action : props.action;
|
|
316
|
+
return loadRecaptchaV3(props.sitekey).then(() => execute(props.sitekey, { action: a })).then((tok) => {
|
|
317
|
+
if (disposed.current) return tok;
|
|
318
|
+
setToken(tok);
|
|
319
|
+
props.onVerify && props.onVerify({
|
|
320
|
+
token: tok,
|
|
321
|
+
action: a
|
|
322
|
+
});
|
|
323
|
+
return tok;
|
|
324
|
+
}).catch((err) => {
|
|
325
|
+
if (!disposed.current) props.onError && props.onError({ error: err });
|
|
326
|
+
throw err;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
useEffect(() => {
|
|
330
|
+
disposed.current = false;
|
|
331
|
+
loadRecaptchaV3(props.sitekey).then(() => {
|
|
332
|
+
if (disposed.current || !props.executeOnMount) return;
|
|
333
|
+
execute$1();
|
|
334
|
+
}).catch((err) => {
|
|
335
|
+
if (disposed.current) return;
|
|
336
|
+
props.onError && props.onError({ error: err });
|
|
337
|
+
});
|
|
338
|
+
return () => {
|
|
339
|
+
disposed.current = true;
|
|
340
|
+
};
|
|
341
|
+
}, []);
|
|
342
|
+
const _rozieExposeRef = useRef({ execute: execute$1 });
|
|
343
|
+
_rozieExposeRef.current = { execute: execute$1 };
|
|
344
|
+
useImperativeHandle(ref, () => ({ execute: (...args) => _rozieExposeRef.current.execute(...args) }), []);
|
|
345
|
+
return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx("div", {
|
|
346
|
+
style: { display: "none" },
|
|
347
|
+
...attrs,
|
|
348
|
+
className: clsx("rozie-recaptcha-v3", attrs.className),
|
|
349
|
+
"data-rozie-s-9148a0b0": ""
|
|
350
|
+
}) });
|
|
351
|
+
});
|
|
352
|
+
//#endregion
|
|
353
|
+
export { Captcha, Captcha as default, RecaptchaV3 };
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rozie-ui/captcha-react",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"description": "Idiomatic React Captcha — one Rozie source compiled to React.",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"rozie",
|
|
10
|
+
"rozie-ui",
|
|
11
|
+
"captcha",
|
|
12
|
+
"recaptcha",
|
|
13
|
+
"hcaptcha",
|
|
14
|
+
"turnstile",
|
|
15
|
+
"bot-protection",
|
|
16
|
+
"verification",
|
|
17
|
+
"spam",
|
|
18
|
+
"headless",
|
|
19
|
+
"component",
|
|
20
|
+
"react"
|
|
21
|
+
],
|
|
22
|
+
"author": "One Learning Community (https://github.com/One-Learning-Community)",
|
|
23
|
+
"homepage": "https://github.com/One-Learning-Community/rozie.js#readme",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/One-Learning-Community/rozie.js.git",
|
|
27
|
+
"directory": "packages/ui/captcha/packages/react"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/One-Learning-Community/rozie.js/issues"
|
|
31
|
+
},
|
|
32
|
+
"main": "./dist/index.cjs",
|
|
33
|
+
"module": "./dist/index.mjs",
|
|
34
|
+
"types": "./dist/index.d.mts",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./dist/index.d.mts",
|
|
38
|
+
"import": "./dist/index.mjs",
|
|
39
|
+
"require": "./dist/index.cjs"
|
|
40
|
+
},
|
|
41
|
+
"./source": "./src/Captcha.tsx"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"src"
|
|
46
|
+
],
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@rozie/runtime-react": "0.1.1"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"react": "^18.2 || ^19",
|
|
52
|
+
"react-dom": "^18.2 || ^19"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"react": {
|
|
56
|
+
"optional": false
|
|
57
|
+
},
|
|
58
|
+
"react-dom": {
|
|
59
|
+
"optional": false
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/react": "^18",
|
|
64
|
+
"@types/react-dom": "^18"
|
|
65
|
+
},
|
|
66
|
+
"publishConfig": {
|
|
67
|
+
"access": "public"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "tsdown",
|
|
71
|
+
"typecheck": "tsc --noEmit"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/Captcha.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ForwardRefExoticComponent, RefAttributes } from 'react';
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
|
|
5
|
+
export interface CaptchaProps {
|
|
6
|
+
provider?: string;
|
|
7
|
+
sitekey: string;
|
|
8
|
+
token?: string;
|
|
9
|
+
defaultToken?: string;
|
|
10
|
+
onTokenChange?: (next: string) => void;
|
|
11
|
+
theme?: string;
|
|
12
|
+
size?: string;
|
|
13
|
+
tabindex?: (number) | null;
|
|
14
|
+
options?: Record<string, unknown>;
|
|
15
|
+
onVerify?: (...args: unknown[]) => void;
|
|
16
|
+
onExpire?: (...args: unknown[]) => void;
|
|
17
|
+
onError?: (...args: unknown[]) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CaptchaHandle {
|
|
21
|
+
reset: (...args: any[]) => any;
|
|
22
|
+
execute: (...args: any[]) => any;
|
|
23
|
+
getResponse: (...args: any[]) => any;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare const Captcha: React.ForwardRefExoticComponent<CaptchaProps & React.RefAttributes<CaptchaHandle>>;
|
|
27
|
+
export default Captcha;
|
package/src/Captcha.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
2
|
+
import { clsx, useControllableState } from '@rozie/runtime-react';
|
|
3
|
+
// The provider api.js loader (inject-once singleton + poll/timeout/error) lives
|
|
4
|
+
// in a vendored internal module so its branchy logic is unit-tested independent
|
|
5
|
+
// of any framework (see internal/loadCaptchaApi.test.ts). codegen copies
|
|
6
|
+
// src/internal/ into every leaf, so this relative import resolves verbatim ×6.
|
|
7
|
+
import { loadCaptchaApi } from './internal/loadCaptchaApi';
|
|
8
|
+
|
|
9
|
+
// Live widget handle. Top-level lets → React hoists to useRef (setup-once).
|
|
10
|
+
// `disposed` MUST be top-level (not declared inside $onMount): the Solid emitter
|
|
11
|
+
// extracts the teardown into a separate onCleanup() whose scope can't see a
|
|
12
|
+
// mount-body local, so a `let disposed` inside $onMount is out of scope in the
|
|
13
|
+
// teardown (TS2304). Top-level — like api/widgetId — is visible to both.
|
|
14
|
+
|
|
15
|
+
interface CaptchaProps {
|
|
16
|
+
provider?: string;
|
|
17
|
+
sitekey: string;
|
|
18
|
+
token?: string;
|
|
19
|
+
defaultToken?: string;
|
|
20
|
+
onTokenChange?: (token: string) => void;
|
|
21
|
+
theme?: string;
|
|
22
|
+
size?: string;
|
|
23
|
+
tabindex?: (number) | null;
|
|
24
|
+
options?: Record<string, any>;
|
|
25
|
+
onVerify?: (...args: any[]) => void;
|
|
26
|
+
onExpire?: (...args: any[]) => void;
|
|
27
|
+
onError?: (...args: any[]) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CaptchaHandle {
|
|
31
|
+
reset: (...args: any[]) => any;
|
|
32
|
+
execute: (...args: any[]) => any;
|
|
33
|
+
getResponse: (...args: any[]) => any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const Captcha = forwardRef<CaptchaHandle, CaptchaProps>(function Captcha(_props: CaptchaProps, ref): JSX.Element {
|
|
37
|
+
const __defaultOptions = useState(() => (() => ({}))())[0];
|
|
38
|
+
const props: Omit<CaptchaProps, 'provider' | 'theme' | 'size' | 'tabindex' | 'options'> & { provider: string; theme: string; size: string; tabindex: (number) | null; options: Record<string, any> } = {
|
|
39
|
+
..._props,
|
|
40
|
+
provider: _props.provider ?? 'recaptcha',
|
|
41
|
+
theme: _props.theme ?? 'light',
|
|
42
|
+
size: _props.size ?? 'normal',
|
|
43
|
+
tabindex: _props.tabindex ?? null,
|
|
44
|
+
options: _props.options ?? __defaultOptions,
|
|
45
|
+
};
|
|
46
|
+
const attrs: Record<string, unknown> = (() => {
|
|
47
|
+
const { provider, sitekey, token, theme, size, tabindex, options, defaultValue, onTokenChange, defaultToken, ...rest } = _props as CaptchaProps & Record<string, unknown>;
|
|
48
|
+
void provider; void sitekey; void token; void theme; void size; void tabindex; void options; void defaultValue; void onTokenChange; void defaultToken;
|
|
49
|
+
return rest;
|
|
50
|
+
})();
|
|
51
|
+
const disposed = useRef(false);
|
|
52
|
+
const api = useRef<any>(null);
|
|
53
|
+
const widgetId = useRef<any>(null);
|
|
54
|
+
const [token, setToken] = useControllableState({
|
|
55
|
+
value: props.token,
|
|
56
|
+
defaultValue: props.defaultToken ?? '',
|
|
57
|
+
onValueChange: props.onTokenChange,
|
|
58
|
+
});
|
|
59
|
+
const widgetEl = useRef<HTMLDivElement | null>(null);
|
|
60
|
+
|
|
61
|
+
const { onError: _rozieProp_onError, onExpire: _rozieProp_onExpire, onVerify: _rozieProp_onVerify } = props;
|
|
62
|
+
const buildConfig = useCallback(() => ({
|
|
63
|
+
sitekey: props.sitekey,
|
|
64
|
+
theme: props.theme,
|
|
65
|
+
size: props.size,
|
|
66
|
+
...(props.tabindex != null ? {
|
|
67
|
+
tabindex: props.tabindex
|
|
68
|
+
} : {}),
|
|
69
|
+
// NB: the param must NOT be named `token` — on Vue, $model.token lowers to a
|
|
70
|
+
// `defineModel('token')` ref named `token`, and a same-named param shadows it
|
|
71
|
+
// (`token.value = token` would write the param, not the model → v-model:token
|
|
72
|
+
// never populates). Vue-only footgun (React/Solid lower to a setToken call).
|
|
73
|
+
callback: (response: any) => {
|
|
74
|
+
setToken(response);
|
|
75
|
+
_rozieProp_onVerify && _rozieProp_onVerify({
|
|
76
|
+
token: response,
|
|
77
|
+
provider: props.provider
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
'expired-callback': () => {
|
|
81
|
+
setToken('');
|
|
82
|
+
_rozieProp_onExpire && _rozieProp_onExpire({
|
|
83
|
+
provider: props.provider
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
'error-callback': () => {
|
|
87
|
+
setToken('');
|
|
88
|
+
_rozieProp_onError && _rozieProp_onError({
|
|
89
|
+
provider: props.provider
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
...props.options
|
|
93
|
+
}), [_rozieProp_onError, _rozieProp_onExpire, _rozieProp_onVerify, props.options, props.provider, props.sitekey, props.size, props.tabindex, props.theme, setToken]);
|
|
94
|
+
// Imperative handle. Each guards on a live widget (null before render / after
|
|
95
|
+
// teardown). reset clears the two-way token to match the cleared widget.
|
|
96
|
+
function reset() {
|
|
97
|
+
if (widgetId.current != null && api.current && typeof api.current.reset === 'function') api.current.reset(widgetId.current);
|
|
98
|
+
setToken('');
|
|
99
|
+
}
|
|
100
|
+
// Invisible / programmatic challenge (size="invisible"). No-op until rendered.
|
|
101
|
+
// Invisible / programmatic challenge (size="invisible"). No-op until rendered.
|
|
102
|
+
function execute() {
|
|
103
|
+
if (widgetId.current != null && api.current && typeof api.current.execute === 'function') api.current.execute(widgetId.current);
|
|
104
|
+
}
|
|
105
|
+
// Read the current response token on demand (e.g. just before form submit).
|
|
106
|
+
// Read the current response token on demand (e.g. just before form submit).
|
|
107
|
+
function getResponse() {
|
|
108
|
+
return widgetId.current != null && api.current && typeof api.current.getResponse === 'function' ? api.current.getResponse(widgetId.current) : '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
disposed.current = false;
|
|
113
|
+
loadCaptchaApi(props.provider).then((a: any) => {
|
|
114
|
+
if (disposed.current) return;
|
|
115
|
+
api.current = a;
|
|
116
|
+
widgetId.current = api.current.render(widgetEl.current!, buildConfig());
|
|
117
|
+
}).catch((err: any) => {
|
|
118
|
+
props.onError && props.onError({
|
|
119
|
+
provider: props.provider,
|
|
120
|
+
error: err
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
return () => {
|
|
124
|
+
disposed.current = true;
|
|
125
|
+
if (widgetId.current == null || !api.current) return;
|
|
126
|
+
// Turnstile fully removes a widget; reCAPTCHA/hCaptcha only reset.
|
|
127
|
+
if (typeof api.current.remove === 'function') api.current.remove(widgetId.current);else if (typeof api.current.reset === 'function') api.current.reset(widgetId.current);
|
|
128
|
+
};
|
|
129
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
130
|
+
|
|
131
|
+
const _rozieExposeRef = useRef({ reset, execute, getResponse });
|
|
132
|
+
_rozieExposeRef.current = { reset, execute, getResponse };
|
|
133
|
+
useImperativeHandle(ref, () => ({ reset: (...args: Parameters<typeof reset>): ReturnType<typeof reset> => _rozieExposeRef.current.reset(...args), execute: (...args: Parameters<typeof execute>): ReturnType<typeof execute> => _rozieExposeRef.current.execute(...args), getResponse: (...args: Parameters<typeof getResponse>): ReturnType<typeof getResponse> => _rozieExposeRef.current.getResponse(...args) }), []);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<>
|
|
137
|
+
<div ref={widgetEl} {...attrs} className={clsx("rozie-captcha", (attrs.className as string | undefined))} data-rozie-s-9c7749d4="" />
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
export default Captcha;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ForwardRefExoticComponent, RefAttributes } from 'react';
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
|
|
5
|
+
export interface RecaptchaV3Props {
|
|
6
|
+
sitekey: string;
|
|
7
|
+
action?: string;
|
|
8
|
+
token?: string;
|
|
9
|
+
defaultToken?: string;
|
|
10
|
+
onTokenChange?: (next: string) => void;
|
|
11
|
+
executeOnMount?: boolean;
|
|
12
|
+
onError?: (...args: unknown[]) => void;
|
|
13
|
+
onVerify?: (...args: unknown[]) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RecaptchaV3Handle {
|
|
17
|
+
execute: (...args: any[]) => any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare const RecaptchaV3: React.ForwardRefExoticComponent<RecaptchaV3Props & React.RefAttributes<RecaptchaV3Handle>>;
|
|
21
|
+
export default RecaptchaV3;
|