@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Krieger and Rozie.js contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @rozie-ui/captcha-react
|
|
2
|
+
|
|
3
|
+
Idiomatic **react** `Captcha` — Cross-framework CAPTCHA / bot-protection widget wrapping Google reCAPTCHA v2, hCaptcha, and Cloudflare Turnstile. Compiled from one [Rozie](https://github.com/One-Learning-Community/rozie.js) source. This package is generated; do not edit `src/` by hand.
|
|
4
|
+
|
|
5
|
+
This package ships `Captcha` (the default export) alongside `RecaptchaV3` (named export).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i @rozie-ui/captcha-react
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies: `react + react-dom`.
|
|
14
|
+
|
|
15
|
+
## Captcha
|
|
16
|
+
|
|
17
|
+
### Usage
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { useState } from 'react';
|
|
21
|
+
import { Captcha } from '@rozie-ui/captcha-react';
|
|
22
|
+
|
|
23
|
+
export function Demo() {
|
|
24
|
+
const [token, setToken] = useState('');
|
|
25
|
+
return (
|
|
26
|
+
<Captcha
|
|
27
|
+
provider="recaptcha"
|
|
28
|
+
sitekey="your-site-key"
|
|
29
|
+
token={token}
|
|
30
|
+
onTokenChange={setToken}
|
|
31
|
+
onVerify={(e) => console.log('verified', e.token)}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Props
|
|
38
|
+
|
|
39
|
+
| Name | Type | Default | Two-way (model) | Required |
|
|
40
|
+
| --- | --- | --- | :---: | :---: |
|
|
41
|
+
| `provider` | `String` | `"recaptcha"` | | |
|
|
42
|
+
| `sitekey` | `String` | `—` | | ✓ |
|
|
43
|
+
| `token` | `String` | `""` | ✓ | |
|
|
44
|
+
| `theme` | `String` | `"light"` | | |
|
|
45
|
+
| `size` | `String` | `"normal"` | | |
|
|
46
|
+
| `tabindex` | `Number` | `null` | | |
|
|
47
|
+
| `options` | `Object` | `{}` | | |
|
|
48
|
+
|
|
49
|
+
### Events
|
|
50
|
+
|
|
51
|
+
| Event | Description |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `verify` | Fired when the user completes the challenge. Payload `{ token, provider }`. |
|
|
54
|
+
| `expire` | Fired when the verified token expires. Payload `{ provider }`. |
|
|
55
|
+
| `error` | Fired on a challenge or script-load failure. Payload `{ provider, error? }`. |
|
|
56
|
+
|
|
57
|
+
### Imperative handle
|
|
58
|
+
|
|
59
|
+
This component exposes imperative methods (declared once in the Rozie source via `$expose`). Grab a handle with the native ref mechanism and call them directly:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { useRef } from 'react';
|
|
63
|
+
import { Captcha, type CaptchaHandle } from '@rozie-ui/captcha-react';
|
|
64
|
+
|
|
65
|
+
const handle = useRef<CaptchaHandle>(null);
|
|
66
|
+
// <Captcha ref={handle} provider="recaptcha" sitekey="your-site-key" />
|
|
67
|
+
// handle.current?.reset(); // clear + reset the widget
|
|
68
|
+
// handle.current?.execute(); // invisible / programmatic challenge
|
|
69
|
+
// handle.current?.getResponse(); // read the current token
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| Method | Description |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `reset` | Reset the widget to its un-challenged state and clear the two-way `token`. |
|
|
75
|
+
| `execute` | Programmatically run the challenge — drives invisible widgets (`size="invisible"`). |
|
|
76
|
+
| `getResponse` | Return the current response token on demand (e.g. just before form submit). |
|
|
77
|
+
|
|
78
|
+
## RecaptchaV3
|
|
79
|
+
|
|
80
|
+
### Usage
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import { useRef } from 'react';
|
|
84
|
+
import { RecaptchaV3, type RecaptchaV3Handle } from '@rozie-ui/captcha-react';
|
|
85
|
+
|
|
86
|
+
export function SignupForm() {
|
|
87
|
+
const captcha = useRef<RecaptchaV3Handle>(null);
|
|
88
|
+
const onSubmit = async (e: React.FormEvent) => {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
const token = await captcha.current?.execute('signup'); // fresh token for THIS action
|
|
91
|
+
await fetch('/signup', { method: 'POST', body: JSON.stringify({ token }) });
|
|
92
|
+
};
|
|
93
|
+
return (
|
|
94
|
+
<form onSubmit={onSubmit}>
|
|
95
|
+
{/* … fields … */}
|
|
96
|
+
<RecaptchaV3 ref={captcha} sitekey="your-site-key" action="signup" />
|
|
97
|
+
<button type="submit">Sign up</button>
|
|
98
|
+
</form>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Props
|
|
104
|
+
|
|
105
|
+
| Name | Type | Default | Two-way (model) | Required |
|
|
106
|
+
| --- | --- | --- | :---: | :---: |
|
|
107
|
+
| `sitekey` | `String` | `—` | | ✓ |
|
|
108
|
+
| `action` | `String` | `"submit"` | | |
|
|
109
|
+
| `token` | `String` | `""` | ✓ | |
|
|
110
|
+
| `executeOnMount` | `Boolean` | `false` | | |
|
|
111
|
+
|
|
112
|
+
### Events
|
|
113
|
+
|
|
114
|
+
| Event | Description |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `error` | Fired on a load timeout, script error, or a rejected `execute()`. Payload `{ error? }`. |
|
|
117
|
+
| `verify` | Fired on a successful `execute()`. Payload `{ token, action }`. |
|
|
118
|
+
|
|
119
|
+
### Imperative handle
|
|
120
|
+
|
|
121
|
+
This component exposes imperative methods (declared once in the Rozie source via `$expose`). Grab a handle with the native ref mechanism and call them directly:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { useRef } from 'react';
|
|
125
|
+
import { RecaptchaV3, type RecaptchaV3Handle } from '@rozie-ui/captcha-react';
|
|
126
|
+
|
|
127
|
+
const handle = useRef<RecaptchaV3Handle>(null);
|
|
128
|
+
// <RecaptchaV3 ref={handle} sitekey="your-site-key" action="submit" />
|
|
129
|
+
// const token = await handle.current?.execute(); // uses the action prop
|
|
130
|
+
// const token = await handle.current?.execute('login'); // override for this call
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
| Method | Description |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `execute` | Run a v3 challenge for the optional `action` (defaults to the `action` prop) and resolve with a fresh token; also writes the two-way `token` and emits `@verify`. |
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
5
|
+
let react = require("react");
|
|
6
|
+
let _rozie_runtime_react = require("@rozie/runtime-react");
|
|
7
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
8
|
+
//#region src/internal/loadCaptchaApi.ts
|
|
9
|
+
/**
|
|
10
|
+
* Bridge Friendly Captcha's `createWidget` SDK (the `@friendlycaptcha/sdk`
|
|
11
|
+
* compat build, loaded from the CDN — NO npm peer dependency) onto `CaptchaApi`.
|
|
12
|
+
*
|
|
13
|
+
* FC has no explicit-`render`/widget-id model: `createWidget({ element, ... })`
|
|
14
|
+
* returns an event-emitter handle (`frc:widget.complete|expire|error`) with
|
|
15
|
+
* `reset()` / `start()` / `getResponse()` / `destroy()`. We treat that handle AS
|
|
16
|
+
* the opaque widget id the rest of the family already threads around. FC also
|
|
17
|
+
* has no `size` concept — `startMode` is the closest analog and rides through
|
|
18
|
+
* the `options` escape hatch (or a `startMode` config key if a consumer passes
|
|
19
|
+
* one directly).
|
|
20
|
+
*/
|
|
21
|
+
const adaptFriendly = (g) => {
|
|
22
|
+
const fc = g;
|
|
23
|
+
return {
|
|
24
|
+
render(el, cfg) {
|
|
25
|
+
const h = fc.createWidget({
|
|
26
|
+
element: el,
|
|
27
|
+
sitekey: cfg.sitekey,
|
|
28
|
+
theme: cfg.theme,
|
|
29
|
+
startMode: cfg.startMode
|
|
30
|
+
});
|
|
31
|
+
h.addEventListener("frc:widget.complete", (e) => {
|
|
32
|
+
const cb = cfg.callback;
|
|
33
|
+
if (typeof cb === "function") cb(e.detail?.response ?? "");
|
|
34
|
+
});
|
|
35
|
+
h.addEventListener("frc:widget.expire", () => {
|
|
36
|
+
const cb = cfg["expired-callback"];
|
|
37
|
+
if (typeof cb === "function") cb();
|
|
38
|
+
});
|
|
39
|
+
h.addEventListener("frc:widget.error", () => {
|
|
40
|
+
const cb = cfg["error-callback"];
|
|
41
|
+
if (typeof cb === "function") cb();
|
|
42
|
+
});
|
|
43
|
+
return h;
|
|
44
|
+
},
|
|
45
|
+
reset: (id) => id.reset(),
|
|
46
|
+
execute: (id) => id.start(),
|
|
47
|
+
getResponse: (id) => id.getResponse(),
|
|
48
|
+
remove: (id) => id.destroy()
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
const CAPTCHA_PROVIDERS = {
|
|
52
|
+
recaptcha: {
|
|
53
|
+
src: "https://www.google.com/recaptcha/api.js?render=explicit",
|
|
54
|
+
global: "grecaptcha"
|
|
55
|
+
},
|
|
56
|
+
hcaptcha: {
|
|
57
|
+
src: "https://js.hcaptcha.com/1/api.js?render=explicit",
|
|
58
|
+
global: "hcaptcha"
|
|
59
|
+
},
|
|
60
|
+
turnstile: {
|
|
61
|
+
src: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
|
|
62
|
+
global: "turnstile"
|
|
63
|
+
},
|
|
64
|
+
friendly: {
|
|
65
|
+
src: "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@1/site.compat.min.js",
|
|
66
|
+
global: "frcaptcha",
|
|
67
|
+
adapt: adaptFriendly
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Load a provider's api.js ONCE across the whole document, shared by every
|
|
72
|
+
* `<Captcha>` instance via a `globalThis` singleton (a per-instance cache would
|
|
73
|
+
* re-inject per component). Resolves with the provider global once its
|
|
74
|
+
* `render()` is callable; rejects on an unknown provider, a script load error,
|
|
75
|
+
* or a `LOAD_TIMEOUT_MS` timeout. `providers` is injectable for tests.
|
|
76
|
+
*/
|
|
77
|
+
function loadCaptchaApi(provider, providers = CAPTCHA_PROVIDERS) {
|
|
78
|
+
const cfg = providers[provider];
|
|
79
|
+
if (!cfg) return Promise.reject(/* @__PURE__ */ new Error("Unknown captcha provider: " + provider));
|
|
80
|
+
const root = globalThis;
|
|
81
|
+
const cache = root.__rozieCaptchaLoaders ||= {};
|
|
82
|
+
if (cache[provider]) return cache[provider];
|
|
83
|
+
const win = globalThis;
|
|
84
|
+
const ready = (g) => {
|
|
85
|
+
if (!g) return false;
|
|
86
|
+
const o = g;
|
|
87
|
+
return typeof o.render === "function" || typeof o.createWidget === "function";
|
|
88
|
+
};
|
|
89
|
+
const resolveApi = (g) => cfg.adapt ? cfg.adapt(g) : g;
|
|
90
|
+
const p = new Promise((resolve, reject) => {
|
|
91
|
+
if (ready(win[cfg.global])) {
|
|
92
|
+
resolve(resolveApi(win[cfg.global]));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!document.querySelector("script[data-rozie-captcha=\"" + provider + "\"]")) {
|
|
96
|
+
const el = document.createElement("script");
|
|
97
|
+
el.src = cfg.src;
|
|
98
|
+
el.async = true;
|
|
99
|
+
el.defer = true;
|
|
100
|
+
el.setAttribute("data-rozie-captcha", provider);
|
|
101
|
+
el.addEventListener("error", () => reject(/* @__PURE__ */ new Error("Failed to load " + provider + " script")));
|
|
102
|
+
document.head.appendChild(el);
|
|
103
|
+
}
|
|
104
|
+
const started = Date.now();
|
|
105
|
+
const poll = setInterval(() => {
|
|
106
|
+
if (ready(win[cfg.global])) {
|
|
107
|
+
clearInterval(poll);
|
|
108
|
+
resolve(resolveApi(win[cfg.global]));
|
|
109
|
+
} else if (Date.now() - started > 2e4) {
|
|
110
|
+
clearInterval(poll);
|
|
111
|
+
reject(/* @__PURE__ */ new Error(provider + " script load timeout"));
|
|
112
|
+
}
|
|
113
|
+
}, 50);
|
|
114
|
+
});
|
|
115
|
+
cache[provider] = p;
|
|
116
|
+
return p;
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/Captcha.tsx
|
|
120
|
+
const Captcha = (0, react.forwardRef)(function Captcha(_props, ref) {
|
|
121
|
+
const __defaultOptions = (0, react.useState)(() => ({}))[0];
|
|
122
|
+
const props = {
|
|
123
|
+
..._props,
|
|
124
|
+
provider: _props.provider ?? "recaptcha",
|
|
125
|
+
theme: _props.theme ?? "light",
|
|
126
|
+
size: _props.size ?? "normal",
|
|
127
|
+
tabindex: _props.tabindex ?? null,
|
|
128
|
+
options: _props.options ?? __defaultOptions
|
|
129
|
+
};
|
|
130
|
+
const attrs = (() => {
|
|
131
|
+
const { provider, sitekey, token, theme, size, tabindex, options, defaultValue, onTokenChange, defaultToken, ...rest } = _props;
|
|
132
|
+
return rest;
|
|
133
|
+
})();
|
|
134
|
+
const disposed = (0, react.useRef)(false);
|
|
135
|
+
const api = (0, react.useRef)(null);
|
|
136
|
+
const widgetId = (0, react.useRef)(null);
|
|
137
|
+
const [token, setToken] = (0, _rozie_runtime_react.useControllableState)({
|
|
138
|
+
value: props.token,
|
|
139
|
+
defaultValue: props.defaultToken ?? "",
|
|
140
|
+
onValueChange: props.onTokenChange
|
|
141
|
+
});
|
|
142
|
+
const widgetEl = (0, react.useRef)(null);
|
|
143
|
+
const { onError: _rozieProp_onError, onExpire: _rozieProp_onExpire, onVerify: _rozieProp_onVerify } = props;
|
|
144
|
+
const buildConfig = (0, react.useCallback)(() => ({
|
|
145
|
+
sitekey: props.sitekey,
|
|
146
|
+
theme: props.theme,
|
|
147
|
+
size: props.size,
|
|
148
|
+
...props.tabindex != null ? { tabindex: props.tabindex } : {},
|
|
149
|
+
callback: (response) => {
|
|
150
|
+
setToken(response);
|
|
151
|
+
_rozieProp_onVerify && _rozieProp_onVerify({
|
|
152
|
+
token: response,
|
|
153
|
+
provider: props.provider
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
"expired-callback": () => {
|
|
157
|
+
setToken("");
|
|
158
|
+
_rozieProp_onExpire && _rozieProp_onExpire({ provider: props.provider });
|
|
159
|
+
},
|
|
160
|
+
"error-callback": () => {
|
|
161
|
+
setToken("");
|
|
162
|
+
_rozieProp_onError && _rozieProp_onError({ provider: props.provider });
|
|
163
|
+
},
|
|
164
|
+
...props.options
|
|
165
|
+
}), [
|
|
166
|
+
_rozieProp_onError,
|
|
167
|
+
_rozieProp_onExpire,
|
|
168
|
+
_rozieProp_onVerify,
|
|
169
|
+
props.options,
|
|
170
|
+
props.provider,
|
|
171
|
+
props.sitekey,
|
|
172
|
+
props.size,
|
|
173
|
+
props.tabindex,
|
|
174
|
+
props.theme,
|
|
175
|
+
setToken
|
|
176
|
+
]);
|
|
177
|
+
function reset() {
|
|
178
|
+
if (widgetId.current != null && api.current && typeof api.current.reset === "function") api.current.reset(widgetId.current);
|
|
179
|
+
setToken("");
|
|
180
|
+
}
|
|
181
|
+
function execute() {
|
|
182
|
+
if (widgetId.current != null && api.current && typeof api.current.execute === "function") api.current.execute(widgetId.current);
|
|
183
|
+
}
|
|
184
|
+
function getResponse() {
|
|
185
|
+
return widgetId.current != null && api.current && typeof api.current.getResponse === "function" ? api.current.getResponse(widgetId.current) : "";
|
|
186
|
+
}
|
|
187
|
+
(0, react.useEffect)(() => {
|
|
188
|
+
disposed.current = false;
|
|
189
|
+
loadCaptchaApi(props.provider).then((a) => {
|
|
190
|
+
if (disposed.current) return;
|
|
191
|
+
api.current = a;
|
|
192
|
+
widgetId.current = api.current.render(widgetEl.current, buildConfig());
|
|
193
|
+
}).catch((err) => {
|
|
194
|
+
props.onError && props.onError({
|
|
195
|
+
provider: props.provider,
|
|
196
|
+
error: err
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
return () => {
|
|
200
|
+
disposed.current = true;
|
|
201
|
+
if (widgetId.current == null || !api.current) return;
|
|
202
|
+
if (typeof api.current.remove === "function") api.current.remove(widgetId.current);
|
|
203
|
+
else if (typeof api.current.reset === "function") api.current.reset(widgetId.current);
|
|
204
|
+
};
|
|
205
|
+
}, []);
|
|
206
|
+
const _rozieExposeRef = (0, react.useRef)({
|
|
207
|
+
reset,
|
|
208
|
+
execute,
|
|
209
|
+
getResponse
|
|
210
|
+
});
|
|
211
|
+
_rozieExposeRef.current = {
|
|
212
|
+
reset,
|
|
213
|
+
execute,
|
|
214
|
+
getResponse
|
|
215
|
+
};
|
|
216
|
+
(0, react.useImperativeHandle)(ref, () => ({
|
|
217
|
+
reset: (...args) => _rozieExposeRef.current.reset(...args),
|
|
218
|
+
execute: (...args) => _rozieExposeRef.current.execute(...args),
|
|
219
|
+
getResponse: (...args) => _rozieExposeRef.current.getResponse(...args)
|
|
220
|
+
}), []);
|
|
221
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
222
|
+
ref: widgetEl,
|
|
223
|
+
...attrs,
|
|
224
|
+
className: (0, _rozie_runtime_react.clsx)("rozie-captcha", attrs.className),
|
|
225
|
+
"data-rozie-s-9c7749d4": ""
|
|
226
|
+
}) });
|
|
227
|
+
});
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/internal/loadRecaptchaV3.ts
|
|
230
|
+
/** The v3 api.js base URL. The sitekey is appended (`render=SITEKEY`) per call. */
|
|
231
|
+
const RECAPTCHA_V3_SRC = "https://www.google.com/recaptcha/api.js?render=";
|
|
232
|
+
const getGrecaptcha = () => globalThis.grecaptcha;
|
|
233
|
+
/**
|
|
234
|
+
* Load reCAPTCHA v3's api.js ONCE per sitekey across the whole document, shared
|
|
235
|
+
* by every `<RecaptchaV3>` instance via a `globalThis` singleton keyed on the
|
|
236
|
+
* sitekey. Resolves with the `grecaptcha` global once `grecaptcha.ready(cb)` has
|
|
237
|
+
* fired; rejects on a script load error or a `LOAD_TIMEOUT_MS` timeout.
|
|
238
|
+
*
|
|
239
|
+
* `srcBase` is injectable for tests (pass `''` so happy-dom does not attempt a
|
|
240
|
+
* real network fetch — mirrors loadCaptchaApi's injectable `providers` seam).
|
|
241
|
+
*/
|
|
242
|
+
function loadRecaptchaV3(sitekey, srcBase = RECAPTCHA_V3_SRC) {
|
|
243
|
+
const root = globalThis;
|
|
244
|
+
const cache = root.__rozieRecaptchaV3Loaders ||= {};
|
|
245
|
+
if (cache[sitekey]) return cache[sitekey];
|
|
246
|
+
const p = new Promise((resolve, reject) => {
|
|
247
|
+
let settled = false;
|
|
248
|
+
const ready = (g) => {
|
|
249
|
+
g.ready(() => {
|
|
250
|
+
if (settled) return;
|
|
251
|
+
settled = true;
|
|
252
|
+
resolve(g);
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
const present = getGrecaptcha();
|
|
256
|
+
if (present && typeof present.ready === "function") {
|
|
257
|
+
ready(present);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (!document.querySelector("script[data-rozie-recaptcha-v3=\"" + sitekey + "\"]")) {
|
|
261
|
+
const el = document.createElement("script");
|
|
262
|
+
el.src = srcBase + encodeURIComponent(sitekey);
|
|
263
|
+
el.async = true;
|
|
264
|
+
el.defer = true;
|
|
265
|
+
el.setAttribute("data-rozie-recaptcha-v3", sitekey);
|
|
266
|
+
el.addEventListener("error", () => {
|
|
267
|
+
if (settled) return;
|
|
268
|
+
settled = true;
|
|
269
|
+
reject(/* @__PURE__ */ new Error("Failed to load reCAPTCHA v3 script for sitekey: " + sitekey));
|
|
270
|
+
});
|
|
271
|
+
document.head.appendChild(el);
|
|
272
|
+
}
|
|
273
|
+
const started = Date.now();
|
|
274
|
+
const poll = setInterval(() => {
|
|
275
|
+
const g = getGrecaptcha();
|
|
276
|
+
if (g && typeof g.ready === "function") {
|
|
277
|
+
clearInterval(poll);
|
|
278
|
+
ready(g);
|
|
279
|
+
} else if (Date.now() - started > 2e4) {
|
|
280
|
+
clearInterval(poll);
|
|
281
|
+
if (settled) return;
|
|
282
|
+
settled = true;
|
|
283
|
+
reject(/* @__PURE__ */ new Error("reCAPTCHA v3 script load timeout for sitekey: " + sitekey));
|
|
284
|
+
}
|
|
285
|
+
}, 50);
|
|
286
|
+
});
|
|
287
|
+
cache[sitekey] = p;
|
|
288
|
+
return p;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Run a v3 challenge for `sitekey` + `action`, returning a fresh token.
|
|
292
|
+
* Delegates to `grecaptcha.execute(sitekey, { action })`. Call AFTER
|
|
293
|
+
* `loadRecaptchaV3(sitekey)` has resolved (the component threads that order).
|
|
294
|
+
*/
|
|
295
|
+
function execute(sitekey, opts) {
|
|
296
|
+
const g = getGrecaptcha();
|
|
297
|
+
if (!g || typeof g.execute !== "function") return Promise.reject(/* @__PURE__ */ new Error("reCAPTCHA v3 not loaded for sitekey: " + sitekey));
|
|
298
|
+
return g.execute(sitekey, opts);
|
|
299
|
+
}
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region src/RecaptchaV3.tsx
|
|
302
|
+
const RecaptchaV3 = (0, react.forwardRef)(function RecaptchaV3(_props, ref) {
|
|
303
|
+
const props = {
|
|
304
|
+
..._props,
|
|
305
|
+
action: _props.action ?? "submit",
|
|
306
|
+
executeOnMount: _props.executeOnMount ?? false
|
|
307
|
+
};
|
|
308
|
+
const attrs = (() => {
|
|
309
|
+
const { sitekey, action, token, executeOnMount, defaultValue, onTokenChange, defaultToken, ...rest } = _props;
|
|
310
|
+
return rest;
|
|
311
|
+
})();
|
|
312
|
+
const disposed = (0, react.useRef)(false);
|
|
313
|
+
const [token, setToken] = (0, _rozie_runtime_react.useControllableState)({
|
|
314
|
+
value: props.token,
|
|
315
|
+
defaultValue: props.defaultToken ?? "",
|
|
316
|
+
onValueChange: props.onTokenChange
|
|
317
|
+
});
|
|
318
|
+
function execute$1(action = null) {
|
|
319
|
+
const a = action != null ? action : props.action;
|
|
320
|
+
return loadRecaptchaV3(props.sitekey).then(() => execute(props.sitekey, { action: a })).then((tok) => {
|
|
321
|
+
if (disposed.current) return tok;
|
|
322
|
+
setToken(tok);
|
|
323
|
+
props.onVerify && props.onVerify({
|
|
324
|
+
token: tok,
|
|
325
|
+
action: a
|
|
326
|
+
});
|
|
327
|
+
return tok;
|
|
328
|
+
}).catch((err) => {
|
|
329
|
+
if (!disposed.current) props.onError && props.onError({ error: err });
|
|
330
|
+
throw err;
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
(0, react.useEffect)(() => {
|
|
334
|
+
disposed.current = false;
|
|
335
|
+
loadRecaptchaV3(props.sitekey).then(() => {
|
|
336
|
+
if (disposed.current || !props.executeOnMount) return;
|
|
337
|
+
execute$1();
|
|
338
|
+
}).catch((err) => {
|
|
339
|
+
if (disposed.current) return;
|
|
340
|
+
props.onError && props.onError({ error: err });
|
|
341
|
+
});
|
|
342
|
+
return () => {
|
|
343
|
+
disposed.current = true;
|
|
344
|
+
};
|
|
345
|
+
}, []);
|
|
346
|
+
const _rozieExposeRef = (0, react.useRef)({ execute: execute$1 });
|
|
347
|
+
_rozieExposeRef.current = { execute: execute$1 };
|
|
348
|
+
(0, react.useImperativeHandle)(ref, () => ({ execute: (...args) => _rozieExposeRef.current.execute(...args) }), []);
|
|
349
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
350
|
+
style: { display: "none" },
|
|
351
|
+
...attrs,
|
|
352
|
+
className: (0, _rozie_runtime_react.clsx)("rozie-recaptcha-v3", attrs.className),
|
|
353
|
+
"data-rozie-s-9148a0b0": ""
|
|
354
|
+
}) });
|
|
355
|
+
});
|
|
356
|
+
//#endregion
|
|
357
|
+
exports.Captcha = Captcha;
|
|
358
|
+
exports.RecaptchaV3 = RecaptchaV3;
|
|
359
|
+
exports.default = Captcha;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/Captcha.d.ts
|
|
4
|
+
interface CaptchaProps {
|
|
5
|
+
provider?: string;
|
|
6
|
+
sitekey: string;
|
|
7
|
+
token?: string;
|
|
8
|
+
defaultToken?: string;
|
|
9
|
+
onTokenChange?: (next: string) => void;
|
|
10
|
+
theme?: string;
|
|
11
|
+
size?: string;
|
|
12
|
+
tabindex?: (number) | null;
|
|
13
|
+
options?: Record<string, unknown>;
|
|
14
|
+
onVerify?: (...args: unknown[]) => void;
|
|
15
|
+
onExpire?: (...args: unknown[]) => void;
|
|
16
|
+
onError?: (...args: unknown[]) => void;
|
|
17
|
+
}
|
|
18
|
+
interface CaptchaHandle {
|
|
19
|
+
reset: (...args: any[]) => any;
|
|
20
|
+
execute: (...args: any[]) => any;
|
|
21
|
+
getResponse: (...args: any[]) => any;
|
|
22
|
+
}
|
|
23
|
+
declare const Captcha: React.ForwardRefExoticComponent<CaptchaProps & React.RefAttributes<CaptchaHandle>>;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/RecaptchaV3.d.ts
|
|
26
|
+
interface RecaptchaV3Props {
|
|
27
|
+
sitekey: string;
|
|
28
|
+
action?: string;
|
|
29
|
+
token?: string;
|
|
30
|
+
defaultToken?: string;
|
|
31
|
+
onTokenChange?: (next: string) => void;
|
|
32
|
+
executeOnMount?: boolean;
|
|
33
|
+
onError?: (...args: unknown[]) => void;
|
|
34
|
+
onVerify?: (...args: unknown[]) => void;
|
|
35
|
+
}
|
|
36
|
+
interface RecaptchaV3Handle {
|
|
37
|
+
execute: (...args: any[]) => any;
|
|
38
|
+
}
|
|
39
|
+
declare const RecaptchaV3: React.ForwardRefExoticComponent<RecaptchaV3Props & React.RefAttributes<RecaptchaV3Handle>>;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { Captcha, Captcha as default, type CaptchaHandle, RecaptchaV3, type RecaptchaV3Handle };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as _$react from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/Captcha.d.ts
|
|
4
|
+
interface CaptchaProps {
|
|
5
|
+
provider?: string;
|
|
6
|
+
sitekey: string;
|
|
7
|
+
token?: string;
|
|
8
|
+
defaultToken?: string;
|
|
9
|
+
onTokenChange?: (token: string) => void;
|
|
10
|
+
theme?: string;
|
|
11
|
+
size?: string;
|
|
12
|
+
tabindex?: (number) | null;
|
|
13
|
+
options?: Record<string, any>;
|
|
14
|
+
onVerify?: (...args: any[]) => void;
|
|
15
|
+
onExpire?: (...args: any[]) => void;
|
|
16
|
+
onError?: (...args: any[]) => void;
|
|
17
|
+
}
|
|
18
|
+
interface CaptchaHandle {
|
|
19
|
+
reset: (...args: any[]) => any;
|
|
20
|
+
execute: (...args: any[]) => any;
|
|
21
|
+
getResponse: (...args: any[]) => any;
|
|
22
|
+
}
|
|
23
|
+
declare const Captcha: _$react.ForwardRefExoticComponent<CaptchaProps & _$react.RefAttributes<CaptchaHandle>>;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/RecaptchaV3.d.ts
|
|
26
|
+
interface RecaptchaV3Props {
|
|
27
|
+
sitekey: string;
|
|
28
|
+
action?: string;
|
|
29
|
+
token?: string;
|
|
30
|
+
defaultToken?: string;
|
|
31
|
+
onTokenChange?: (token: string) => void;
|
|
32
|
+
executeOnMount?: boolean;
|
|
33
|
+
onError?: (...args: any[]) => void;
|
|
34
|
+
onVerify?: (...args: any[]) => void;
|
|
35
|
+
}
|
|
36
|
+
interface RecaptchaV3Handle {
|
|
37
|
+
execute: (...args: any[]) => any;
|
|
38
|
+
}
|
|
39
|
+
declare const RecaptchaV3: _$react.ForwardRefExoticComponent<RecaptchaV3Props & _$react.RefAttributes<RecaptchaV3Handle>>;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { Captcha, Captcha as default, type CaptchaHandle, RecaptchaV3, type RecaptchaV3Handle };
|