@snutig/react 0.1.0
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 +13 -0
- package/README.md +200 -0
- package/dist/SnutigCaptcha.d.ts +99 -0
- package/dist/SnutigCaptcha.d.ts.map +1 -0
- package/dist/index.cjs +375 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +361 -0
- package/dist/index.js.map +7 -0
- package/dist/labels.d.ts +32 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/loader.d.ts +35 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/theme.css +59 -0
- package/dist/theme.d.ts +15 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +65 -0
- package/src/SnutigCaptcha.tsx +325 -0
- package/src/css.d.ts +5 -0
- package/src/index.ts +27 -0
- package/src/labels.ts +66 -0
- package/src/loader.ts +116 -0
- package/src/theme.css +59 -0
- package/src/theme.ts +33 -0
- package/src/types.ts +72 -0
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@snutig/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React bindings for the snutig CAPTCHA widget (<snutig-captcha>), pre-themed to match the snutig WordPress/Elementor plugin.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://snutig.de",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/snutiggmbh/snutig-captcha.git",
|
|
10
|
+
"directory": "packages/react"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/snutiggmbh/snutig-captcha/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"captcha",
|
|
17
|
+
"proof-of-work",
|
|
18
|
+
"bot-protection",
|
|
19
|
+
"react",
|
|
20
|
+
"nextjs",
|
|
21
|
+
"web-components",
|
|
22
|
+
"privacy",
|
|
23
|
+
"gdpr",
|
|
24
|
+
"snutig"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.cjs",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"sideEffects": [
|
|
31
|
+
"./dist/theme.css"
|
|
32
|
+
],
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"import": "./dist/index.js",
|
|
37
|
+
"require": "./dist/index.cjs"
|
|
38
|
+
},
|
|
39
|
+
"./theme.css": "./dist/theme.css"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"src",
|
|
44
|
+
"LICENSE",
|
|
45
|
+
"README.md"
|
|
46
|
+
],
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"react": ">=18"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/react": "^19.0.0",
|
|
52
|
+
"esbuild": "^0.28.0",
|
|
53
|
+
"react": "^19.0.0",
|
|
54
|
+
"react-dom": "^19.0.0",
|
|
55
|
+
"typescript": "^5.9.3"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "node build.mjs && tsc -p tsconfig.json",
|
|
62
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
63
|
+
"test": "node ssr-smoke.mjs"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createElement,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
useInsertionEffect,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
} from "react";
|
|
11
|
+
import type { CSSProperties, Ref } from "react";
|
|
12
|
+
import { DEFAULT_API_URL, challengeEndpoint, loadSnutigWidget } from "./loader.js";
|
|
13
|
+
import { LABELS, LABEL_ATTRIBUTES } from "./labels.js";
|
|
14
|
+
import type { SnutigLabels, SnutigLocale } from "./labels.js";
|
|
15
|
+
import { injectSnutigTheme } from "./theme.js";
|
|
16
|
+
import type {
|
|
17
|
+
SnutigCaptchaElement,
|
|
18
|
+
SnutigErrorDetail,
|
|
19
|
+
SolveResult,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Inline properties that hidden mode touches — ours (`display`) plus the ones the
|
|
24
|
+
* widget writes when it degrades an unlicensed invisible widget into a floating
|
|
25
|
+
* badge. Cleared together so `hidden` can be toggled off from either state.
|
|
26
|
+
*/
|
|
27
|
+
const HIDDEN_STYLE_PROPERTIES = [
|
|
28
|
+
"display",
|
|
29
|
+
"visibility",
|
|
30
|
+
"opacity",
|
|
31
|
+
"position",
|
|
32
|
+
"inset-inline-end",
|
|
33
|
+
"inset-block-end",
|
|
34
|
+
"z-index",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hiding has to happen before the first paint, or an invisible widget flashes
|
|
39
|
+
* visible for a frame — but `useLayoutEffect` warns when React renders this
|
|
40
|
+
* component on a server. Nothing to hide there, so fall back to `useEffect`,
|
|
41
|
+
* which is never reached during SSR either.
|
|
42
|
+
*/
|
|
43
|
+
const useBeforePaintEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
|
44
|
+
|
|
45
|
+
export interface SnutigCaptchaProps {
|
|
46
|
+
/** Website key from the dashboard (Websites → Keys). */
|
|
47
|
+
siteKey: string;
|
|
48
|
+
/**
|
|
49
|
+
* snutig captcha API base URL. Defaults to the managed service
|
|
50
|
+
* (`https://api.captcha.snutig.dev`) — set it only for a staging deployment.
|
|
51
|
+
*/
|
|
52
|
+
apiUrl?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Full challenge endpoint base, overriding `apiUrl` + `siteKey`
|
|
55
|
+
* (`<apiUrl>/<siteKey>/`). Rarely needed — for a reverse-proxied path.
|
|
56
|
+
*/
|
|
57
|
+
endpoint?: string;
|
|
58
|
+
/**
|
|
59
|
+
* `name` of the hidden input the token is written to, for plain form posts.
|
|
60
|
+
* Defaults to `snutig-token`.
|
|
61
|
+
*/
|
|
62
|
+
fieldName?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Blocks native form submission until solved, via constraint validation
|
|
65
|
+
* (the widget participates as a form-associated element).
|
|
66
|
+
*/
|
|
67
|
+
required?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Invisible mode: no visible widget — nothing for the user to click, so the
|
|
70
|
+
* solve has to be started from the ref (`solve()`, typically in `onSubmit`).
|
|
71
|
+
*
|
|
72
|
+
* Plan-gated (`hiddenMode`). A site that is not licensed for invisible solving
|
|
73
|
+
* gets `widget.hiddenMode: false` on its challenge, and the widget then
|
|
74
|
+
* degrades to a visible badge in the bottom-right corner rather than letting a
|
|
75
|
+
* form ship without a check — so this never *breaks*, it only stops being
|
|
76
|
+
* invisible.
|
|
77
|
+
*
|
|
78
|
+
* `required` does not apply: constraint validation cannot report a problem on
|
|
79
|
+
* an element the user cannot see (the browser refuses to submit and shows
|
|
80
|
+
* nothing), so it is ignored here.
|
|
81
|
+
*/
|
|
82
|
+
hidden?: boolean;
|
|
83
|
+
/** Solver worker count. Defaults to `navigator.hardwareConcurrency`. */
|
|
84
|
+
workers?: number;
|
|
85
|
+
/** Label set. Defaults to `"de"` — the wording the WordPress plugin ships. */
|
|
86
|
+
locale?: SnutigLocale;
|
|
87
|
+
/** Per-label overrides, merged over the locale's set. */
|
|
88
|
+
labels?: Partial<SnutigLabels>;
|
|
89
|
+
/**
|
|
90
|
+
* `"snutig"` (default) injects the brand theme and marks the wrapper for it.
|
|
91
|
+
* `"external"` marks the wrapper but injects nothing — for importing
|
|
92
|
+
* `@snutig/react/theme.css` through your own pipeline (or under a strict CSP).
|
|
93
|
+
* `"none"` leaves the widget unstyled even when another instance on the page is
|
|
94
|
+
* themed: the brand CSS targets the wrapper class, so an unthemed instance has to
|
|
95
|
+
* not carry it.
|
|
96
|
+
*/
|
|
97
|
+
theme?: "snutig" | "external" | "none";
|
|
98
|
+
/** Help link, shown only after a failure. */
|
|
99
|
+
troubleshootingUrl?: string;
|
|
100
|
+
/** CSP nonce for the injected script + styles. */
|
|
101
|
+
nonce?: string;
|
|
102
|
+
className?: string;
|
|
103
|
+
style?: CSSProperties;
|
|
104
|
+
/** Solved — `token` goes to your server's `POST /:siteKey/siteverify`. */
|
|
105
|
+
onSolve?: (token: string) => void;
|
|
106
|
+
onError?: (error: SnutigErrorDetail) => void;
|
|
107
|
+
/** Solve progress, 0–100. */
|
|
108
|
+
onProgress?: (progress: number) => void;
|
|
109
|
+
/** Token cleared — on expiry, or via `reset()`. */
|
|
110
|
+
onReset?: () => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface SnutigCaptchaHandle {
|
|
114
|
+
/** Starts a solve (the widget also does this on click). */
|
|
115
|
+
solve(): Promise<SolveResult>;
|
|
116
|
+
/** Clears the token and the hidden field — call after a failed submit. */
|
|
117
|
+
reset(): void;
|
|
118
|
+
/** Current token, or `null` when unsolved. */
|
|
119
|
+
readonly token: string | null;
|
|
120
|
+
/** The underlying custom element, once mounted. */
|
|
121
|
+
readonly element: SnutigCaptchaElement | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Latest-callback ref, so listeners attach once instead of on every render. */
|
|
125
|
+
function useLatest<T>(value: T) {
|
|
126
|
+
const ref = useRef(value);
|
|
127
|
+
useInsertionEffect(() => {
|
|
128
|
+
ref.current = value;
|
|
129
|
+
});
|
|
130
|
+
return ref;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The snutig CAPTCHA widget as a React component.
|
|
135
|
+
*
|
|
136
|
+
* Wraps the `<snutig-captcha>` custom element served by the captcha server: this
|
|
137
|
+
* package loads that bundle, maps props to its attributes, and turns its
|
|
138
|
+
* `solve`/`error`/`progress`/`reset` DOM events into props. The widget renders
|
|
139
|
+
* itself into a shadow root and writes the token into a hidden input, so a plain
|
|
140
|
+
* `<form>` post carries it with no extra wiring.
|
|
141
|
+
*
|
|
142
|
+
* With `hidden` it renders nothing and solves only when `solve()` is called from
|
|
143
|
+
* the ref — the invisible mode, which is a plan entitlement the server asserts on
|
|
144
|
+
* every challenge.
|
|
145
|
+
*
|
|
146
|
+
* A token only proves the check ran in the browser — always verify it
|
|
147
|
+
* server-side with `POST <apiUrl>/<siteKey>/siteverify` before trusting the
|
|
148
|
+
* submission.
|
|
149
|
+
*/
|
|
150
|
+
export const SnutigCaptcha = forwardRef(function SnutigCaptcha(
|
|
151
|
+
props: SnutigCaptchaProps,
|
|
152
|
+
ref: Ref<SnutigCaptchaHandle>,
|
|
153
|
+
) {
|
|
154
|
+
const {
|
|
155
|
+
siteKey,
|
|
156
|
+
apiUrl = DEFAULT_API_URL,
|
|
157
|
+
endpoint,
|
|
158
|
+
fieldName = "snutig-token",
|
|
159
|
+
required = false,
|
|
160
|
+
hidden = false,
|
|
161
|
+
workers,
|
|
162
|
+
locale = "de",
|
|
163
|
+
labels,
|
|
164
|
+
theme = "snutig",
|
|
165
|
+
troubleshootingUrl,
|
|
166
|
+
nonce,
|
|
167
|
+
className,
|
|
168
|
+
style,
|
|
169
|
+
onSolve,
|
|
170
|
+
onError,
|
|
171
|
+
onProgress,
|
|
172
|
+
onReset,
|
|
173
|
+
} = props;
|
|
174
|
+
|
|
175
|
+
const elementRef = useRef<SnutigCaptchaElement | null>(null);
|
|
176
|
+
const handlers = useLatest({ onSolve, onError, onProgress, onReset });
|
|
177
|
+
|
|
178
|
+
if (theme === "snutig") injectSnutigTheme(nonce);
|
|
179
|
+
// The theme is a document-wide stylesheet keyed on this class, so opting out has to
|
|
180
|
+
// happen here: every wrapper used to get `snutig-captcha` regardless, and one themed
|
|
181
|
+
// instance therefore themed every "unstyled" one on the page too.
|
|
182
|
+
const wrapperClass = theme === "none" ? "snutig-captcha-unstyled" : "snutig-captcha";
|
|
183
|
+
|
|
184
|
+
// The themed wrapper is `display: inline-block`, so leaving it in flow around a
|
|
185
|
+
// display:none widget still contributes a line box — a stray gap of one
|
|
186
|
+
// line-height where the widget should be invisible. `contents` makes it generate
|
|
187
|
+
// no box at all while still inheriting the theme's custom properties down, and
|
|
188
|
+
// leaves the degraded badge's `position: fixed` resolving against the viewport
|
|
189
|
+
// (a `display: none` wrapper would instead suppress the badge entirely).
|
|
190
|
+
const wrapperStyle = hidden ? { ...style, display: "contents" } : style;
|
|
191
|
+
|
|
192
|
+
const resolvedEndpoint = endpoint ?? challengeEndpoint(siteKey, apiUrl);
|
|
193
|
+
|
|
194
|
+
const reportError = useCallback(
|
|
195
|
+
(code: SnutigErrorDetail["code"], message: string) => {
|
|
196
|
+
handlers.current.onError?.({ isSnutig: true, code, message });
|
|
197
|
+
},
|
|
198
|
+
[handlers],
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
let cancelled = false;
|
|
203
|
+
loadSnutigWidget({ apiUrl, nonce }).catch((err: unknown) => {
|
|
204
|
+
if (cancelled) return;
|
|
205
|
+
reportError("script_load_failed", err instanceof Error ? err.message : String(err));
|
|
206
|
+
});
|
|
207
|
+
return () => {
|
|
208
|
+
cancelled = true;
|
|
209
|
+
};
|
|
210
|
+
}, [apiUrl, nonce, reportError]);
|
|
211
|
+
|
|
212
|
+
// Hiding is applied imperatively rather than through the `style` prop because the
|
|
213
|
+
// widget writes to these same properties to un-hide itself when the site is not
|
|
214
|
+
// licensed for invisible solving. React must not own them: a re-render would
|
|
215
|
+
// reapply `display: none` and fight the degrade back into invisibility.
|
|
216
|
+
useBeforePaintEffect(() => {
|
|
217
|
+
const el = elementRef.current;
|
|
218
|
+
if (!el || !hidden) return;
|
|
219
|
+
el.style.display = "none";
|
|
220
|
+
return () => {
|
|
221
|
+
// Clear everything either side may have set, so toggling `hidden` off after a
|
|
222
|
+
// degrade has fired does not leave the element pinned as a fixed badge.
|
|
223
|
+
for (const property of HIDDEN_STYLE_PROPERTIES) el.style.removeProperty(property);
|
|
224
|
+
};
|
|
225
|
+
}, [hidden]);
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (!hidden || !required) return;
|
|
229
|
+
console.warn(
|
|
230
|
+
"[@snutig/react] `required` is ignored on a hidden <SnutigCaptcha>: the browser " +
|
|
231
|
+
"cannot report a validation error on an invisible control and blocks submission " +
|
|
232
|
+
"silently. Start the solve from the ref in onSubmit instead.",
|
|
233
|
+
);
|
|
234
|
+
}, [hidden, required]);
|
|
235
|
+
|
|
236
|
+
// Attached to the element directly: these are CustomEvents, which React's
|
|
237
|
+
// synthetic event system does not surface as props. Listeners survive the
|
|
238
|
+
// element's upgrade, so attaching before the bundle arrives is fine.
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
const el = elementRef.current;
|
|
241
|
+
if (!el) return;
|
|
242
|
+
|
|
243
|
+
const onSolveEvent = (event: Event) => {
|
|
244
|
+
const { token } = (event as CustomEvent<{ token: string }>).detail;
|
|
245
|
+
handlers.current.onSolve?.(token);
|
|
246
|
+
};
|
|
247
|
+
const onErrorEvent = (event: Event) => {
|
|
248
|
+
handlers.current.onError?.((event as CustomEvent<SnutigErrorDetail>).detail);
|
|
249
|
+
};
|
|
250
|
+
const onProgressEvent = (event: Event) => {
|
|
251
|
+
const { progress } = (event as CustomEvent<{ progress: number }>).detail;
|
|
252
|
+
handlers.current.onProgress?.(progress);
|
|
253
|
+
};
|
|
254
|
+
const onResetEvent = () => handlers.current.onReset?.();
|
|
255
|
+
|
|
256
|
+
el.addEventListener("solve", onSolveEvent);
|
|
257
|
+
el.addEventListener("error", onErrorEvent);
|
|
258
|
+
el.addEventListener("progress", onProgressEvent);
|
|
259
|
+
el.addEventListener("reset", onResetEvent);
|
|
260
|
+
|
|
261
|
+
return () => {
|
|
262
|
+
el.removeEventListener("solve", onSolveEvent);
|
|
263
|
+
el.removeEventListener("error", onErrorEvent);
|
|
264
|
+
el.removeEventListener("progress", onProgressEvent);
|
|
265
|
+
el.removeEventListener("reset", onResetEvent);
|
|
266
|
+
};
|
|
267
|
+
}, [handlers, resolvedEndpoint, fieldName]);
|
|
268
|
+
|
|
269
|
+
useImperativeHandle(
|
|
270
|
+
ref,
|
|
271
|
+
() => ({
|
|
272
|
+
solve() {
|
|
273
|
+
const el = elementRef.current;
|
|
274
|
+
if (!el?.solve) {
|
|
275
|
+
return Promise.reject(new Error("The snutig captcha widget is not ready yet"));
|
|
276
|
+
}
|
|
277
|
+
return el.solve();
|
|
278
|
+
},
|
|
279
|
+
reset() {
|
|
280
|
+
elementRef.current?.reset?.();
|
|
281
|
+
},
|
|
282
|
+
get token() {
|
|
283
|
+
return elementRef.current?.token ?? null;
|
|
284
|
+
},
|
|
285
|
+
get element() {
|
|
286
|
+
return elementRef.current;
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
[],
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const i18nAttributes: Record<string, string> = {};
|
|
293
|
+
const resolved = { ...LABELS[locale], ...labels };
|
|
294
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
295
|
+
if (typeof value !== "string" || value === "") continue;
|
|
296
|
+
const attribute = LABEL_ATTRIBUTES[key as keyof SnutigLabels];
|
|
297
|
+
if (attribute) i18nAttributes[attribute] = value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const element = createElement("snutig-captcha", {
|
|
301
|
+
ref: elementRef,
|
|
302
|
+
// `data-snutig-hidden-field-name` is only read in connectedCallback, so a
|
|
303
|
+
// change has to remount the element rather than update it in place.
|
|
304
|
+
key: `${resolvedEndpoint}|${fieldName}`,
|
|
305
|
+
"data-snutig-api-endpoint": resolvedEndpoint,
|
|
306
|
+
"data-snutig-hidden-field-name": fieldName,
|
|
307
|
+
...(workers ? { "data-snutig-worker-count": String(workers) } : {}),
|
|
308
|
+
...(troubleshootingUrl ? { "data-snutig-troubleshooting-url": troubleshootingUrl } : {}),
|
|
309
|
+
// Read by the widget only at solve time, to pick the degrade shape: an element
|
|
310
|
+
// marked invisible has no layout slot to reveal, so it floats as a badge.
|
|
311
|
+
...(hidden ? { "data-snutig-invisible": "" } : {}),
|
|
312
|
+
// The widget checks `hasAttribute("required")`; "" renders a bare attribute.
|
|
313
|
+
...(required && !hidden ? { required: "" } : {}),
|
|
314
|
+
...i18nAttributes,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return createElement(
|
|
318
|
+
"div",
|
|
319
|
+
{
|
|
320
|
+
className: className ? `${wrapperClass} ${className}` : wrapperClass,
|
|
321
|
+
style: wrapperStyle,
|
|
322
|
+
},
|
|
323
|
+
element,
|
|
324
|
+
);
|
|
325
|
+
});
|
package/src/css.d.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { SnutigCaptcha } from "./SnutigCaptcha.js";
|
|
2
|
+
export type { SnutigCaptchaHandle, SnutigCaptchaProps } from "./SnutigCaptcha.js";
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
DEFAULT_API_URL,
|
|
6
|
+
challengeEndpoint,
|
|
7
|
+
loadSnutigWidget,
|
|
8
|
+
stripTrailingSlash,
|
|
9
|
+
widgetScriptUrl,
|
|
10
|
+
} from "./loader.js";
|
|
11
|
+
export type { LoadWidgetOptions } from "./loader.js";
|
|
12
|
+
|
|
13
|
+
export { LABEL_ATTRIBUTES, LABELS } from "./labels.js";
|
|
14
|
+
export type { SnutigLabels, SnutigLocale } from "./labels.js";
|
|
15
|
+
|
|
16
|
+
export { injectSnutigTheme, THEME_CSS } from "./theme.js";
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
SnutigCaptchaElement,
|
|
20
|
+
SnutigErrorCode,
|
|
21
|
+
SnutigErrorDetail,
|
|
22
|
+
SnutigProgressDetail,
|
|
23
|
+
SnutigSolveDetail,
|
|
24
|
+
SnutigWidgetErrorCode,
|
|
25
|
+
SnutigWindow,
|
|
26
|
+
SolveResult,
|
|
27
|
+
} from "./types.js";
|
package/src/labels.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget i18n. The widget reads each label from a `data-snutig-i18n-<key>`
|
|
3
|
+
* attribute and falls back to its own English string when the attribute is
|
|
4
|
+
* absent — so `locale: "en"` sets no attributes at all.
|
|
5
|
+
*
|
|
6
|
+
* The German set is the WordPress/Elementor plugin's wording verbatim (the three
|
|
7
|
+
* labels that plugin does not set are filled in here) so both integrations read
|
|
8
|
+
* identically to a customer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SnutigLabels {
|
|
12
|
+
/** Idle label, e.g. "Ich bin ein Mensch". */
|
|
13
|
+
initialState: string;
|
|
14
|
+
verifyingLabel: string;
|
|
15
|
+
solvedLabel: string;
|
|
16
|
+
errorLabel: string;
|
|
17
|
+
/** Constraint-validation message when `required` and unsolved. */
|
|
18
|
+
requiredLabel: string;
|
|
19
|
+
groupAriaLabel: string;
|
|
20
|
+
verifyAriaLabel: string;
|
|
21
|
+
verifyingAriaLabel: string;
|
|
22
|
+
verifiedAriaLabel: string;
|
|
23
|
+
errorAriaLabel: string;
|
|
24
|
+
/** Banner shown when WebAssembly is unavailable (JS fallback is much slower). */
|
|
25
|
+
wasmDisabled: string;
|
|
26
|
+
/** Link text, rendered only when `troubleshootingUrl` is set. */
|
|
27
|
+
troubleshootingLabel: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SnutigLocale = "de" | "en";
|
|
31
|
+
|
|
32
|
+
/** `keyof SnutigLabels` → the attribute the widget actually reads. */
|
|
33
|
+
export const LABEL_ATTRIBUTES: Record<keyof SnutigLabels, string> = {
|
|
34
|
+
initialState: "data-snutig-i18n-initial-state",
|
|
35
|
+
verifyingLabel: "data-snutig-i18n-verifying-label",
|
|
36
|
+
solvedLabel: "data-snutig-i18n-solved-label",
|
|
37
|
+
errorLabel: "data-snutig-i18n-error-label",
|
|
38
|
+
requiredLabel: "data-snutig-i18n-required-label",
|
|
39
|
+
groupAriaLabel: "data-snutig-i18n-group-aria-label",
|
|
40
|
+
verifyAriaLabel: "data-snutig-i18n-verify-aria-label",
|
|
41
|
+
verifyingAriaLabel: "data-snutig-i18n-verifying-aria-label",
|
|
42
|
+
verifiedAriaLabel: "data-snutig-i18n-verified-aria-label",
|
|
43
|
+
errorAriaLabel: "data-snutig-i18n-error-aria-label",
|
|
44
|
+
wasmDisabled: "data-snutig-i18n-wasm-disabled",
|
|
45
|
+
troubleshootingLabel: "data-snutig-i18n-troubleshooting-label",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const LABELS: Record<SnutigLocale, Partial<SnutigLabels>> = {
|
|
49
|
+
de: {
|
|
50
|
+
initialState: "Ich bin ein Mensch",
|
|
51
|
+
verifyingLabel: "Wird überprüft …",
|
|
52
|
+
solvedLabel: "Verifiziert",
|
|
53
|
+
errorLabel: "Fehler – bitte erneut versuchen",
|
|
54
|
+
requiredLabel: "Bitte bestätigen Sie, dass Sie ein Mensch sind",
|
|
55
|
+
groupAriaLabel: "CAPTCHA-Überprüfung",
|
|
56
|
+
verifyAriaLabel: "Klicken, um zu bestätigen, dass Sie ein Mensch sind",
|
|
57
|
+
verifyingAriaLabel: "Wird überprüft, bitte warten",
|
|
58
|
+
verifiedAriaLabel:
|
|
59
|
+
"Wir haben bestätigt, dass Sie ein Mensch sind. Sie können nun fortfahren.",
|
|
60
|
+
errorAriaLabel: "Ein Fehler ist aufgetreten, bitte erneut versuchen",
|
|
61
|
+
wasmDisabled: "WASM aktivieren für deutlich schnellere Überprüfung",
|
|
62
|
+
troubleshootingLabel: "Hilfe",
|
|
63
|
+
},
|
|
64
|
+
// The widget's built-in defaults are English; setting nothing keeps them.
|
|
65
|
+
en: {},
|
|
66
|
+
};
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { SnutigWindow } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The snutig captcha API. snutig runs this as a managed service, so there is one
|
|
5
|
+
* of these and consumers do not host it — which is why `apiUrl` is optional
|
|
6
|
+
* everywhere it appears. Override it only to point at a staging deployment or a
|
|
7
|
+
* reverse proxy.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_API_URL = "https://api.captcha.snutig.dev";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Loads the widget bundle from the snutig captcha API that will also answer its
|
|
13
|
+
* challenges — the same origin rule the WordPress plugin follows
|
|
14
|
+
* (`/widget/v1/widget.js`, `access-control-allow-origin: *`). There is no CDN
|
|
15
|
+
* copy on purpose: the wire protocol is versioned with the server, and the bundle
|
|
16
|
+
* fetches `solver.wasm` relative to its own URL.
|
|
17
|
+
*/
|
|
18
|
+
export interface LoadWidgetOptions {
|
|
19
|
+
/** API base URL. Defaults to {@link DEFAULT_API_URL}. */
|
|
20
|
+
apiUrl?: string;
|
|
21
|
+
/** Full script URL — overrides `apiUrl`. */
|
|
22
|
+
scriptUrl?: string;
|
|
23
|
+
/** CSP nonce, applied to the script tag and the widget's own injected CSS/worker. */
|
|
24
|
+
nonce?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ELEMENT_NAME = "snutig-captcha";
|
|
28
|
+
const SCRIPT_MARKER = "data-snutig-widget";
|
|
29
|
+
|
|
30
|
+
/** In-flight/settled loads, keyed by resolved script URL. */
|
|
31
|
+
const loads = new Map<string, Promise<void>>();
|
|
32
|
+
|
|
33
|
+
export function stripTrailingSlash(url: string): string {
|
|
34
|
+
return url.replace(/\/+$/, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `https://api.captcha.snutig.dev` → `…/widget/v1/widget.js` */
|
|
38
|
+
export function widgetScriptUrl(apiUrl: string = DEFAULT_API_URL): string {
|
|
39
|
+
return `${stripTrailingSlash(apiUrl)}/widget/v1/widget.js`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `(https://api.captcha.snutig.dev, abc123)` → `https://api.captcha.snutig.dev/abc123/` */
|
|
43
|
+
export function challengeEndpoint(siteKey: string, apiUrl: string = DEFAULT_API_URL): string {
|
|
44
|
+
return `${stripTrailingSlash(apiUrl)}/${siteKey}/`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Loads the widget bundle once per URL and resolves when `<snutig-captcha>` is
|
|
49
|
+
* defined. Safe to call from every component instance: concurrent calls share
|
|
50
|
+
* one promise, and an already-defined element (another instance, a
|
|
51
|
+
* server-rendered tag, a WordPress page) resolves immediately.
|
|
52
|
+
*/
|
|
53
|
+
export function loadSnutigWidget(options: LoadWidgetOptions = {}): Promise<void> {
|
|
54
|
+
if (typeof document === "undefined") {
|
|
55
|
+
return Promise.reject(
|
|
56
|
+
new Error(
|
|
57
|
+
"loadSnutigWidget() needs a DOM — call it from an effect, not during server rendering",
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (customElements.get(ELEMENT_NAME)) return Promise.resolve();
|
|
63
|
+
|
|
64
|
+
const url = options.scriptUrl ?? widgetScriptUrl(options.apiUrl);
|
|
65
|
+
|
|
66
|
+
const cached = loads.get(url);
|
|
67
|
+
if (cached) return cached;
|
|
68
|
+
|
|
69
|
+
const win = window as unknown as SnutigWindow;
|
|
70
|
+
// Must be set before the bundle evaluates: it stamps the CSS nonce onto the
|
|
71
|
+
// style it injects into its shadow root.
|
|
72
|
+
if (options.nonce) {
|
|
73
|
+
win.SNUTIG_CSS_NONCE = options.nonce;
|
|
74
|
+
win.SNUTIG_SCRIPT_NONCE = options.nonce;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const load = new Promise<void>((resolve, reject) => {
|
|
78
|
+
const settle = () => customElements.whenDefined(ELEMENT_NAME).then(() => resolve());
|
|
79
|
+
const fail = (tag: HTMLScriptElement) => {
|
|
80
|
+
// Drop the rejected promise so a later mount (or a recovered network) retries
|
|
81
|
+
// instead of replaying the failure forever — and drop the dead <script> with it.
|
|
82
|
+
// A script element that already fired `error` never fires it again and never
|
|
83
|
+
// re-fetches, so leaving it in the DOM would make the retry below adopt it and
|
|
84
|
+
// wait on events that can no longer arrive (a promise that never settles).
|
|
85
|
+
loads.delete(url);
|
|
86
|
+
tag.remove();
|
|
87
|
+
reject(new Error(`Failed to load the snutig captcha widget from ${url}`));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Compare resolved `.src` rather than building an attribute selector: the tag
|
|
91
|
+
// may carry a relative URL, and URLs need no escaping this way.
|
|
92
|
+
const href = new URL(url, document.baseURI).href;
|
|
93
|
+
const existing = Array.from(
|
|
94
|
+
document.querySelectorAll<HTMLScriptElement>(`script[${SCRIPT_MARKER}]`),
|
|
95
|
+
).find((tag) => tag.src === href);
|
|
96
|
+
if (existing) {
|
|
97
|
+
existing.addEventListener("load", settle, { once: true });
|
|
98
|
+
existing.addEventListener("error", () => fail(existing), { once: true });
|
|
99
|
+
// It may have finished before we got here.
|
|
100
|
+
if (customElements.get(ELEMENT_NAME)) resolve();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const script = document.createElement("script");
|
|
105
|
+
script.src = url;
|
|
106
|
+
script.async = true;
|
|
107
|
+
script.setAttribute(SCRIPT_MARKER, "");
|
|
108
|
+
if (options.nonce) script.nonce = options.nonce;
|
|
109
|
+
script.addEventListener("load", settle, { once: true });
|
|
110
|
+
script.addEventListener("error", () => fail(script), { once: true });
|
|
111
|
+
document.head.appendChild(script);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
loads.set(url, load);
|
|
115
|
+
return load;
|
|
116
|
+
}
|
package/src/theme.css
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snutig brand theme for the widget.
|
|
3
|
+
*
|
|
4
|
+
* Kept byte-for-byte in sync with the WordPress/Elementor plugin's
|
|
5
|
+
* `assets/snutig-captcha.css` (snutiggmbh/snutig-captcha-wp) so a React form and
|
|
6
|
+
* an Elementor form render the identical widget. Only the Elementor-specific
|
|
7
|
+
* field-wrapper reset is omitted here.
|
|
8
|
+
*
|
|
9
|
+
* Brand:
|
|
10
|
+
* primary #99b596 (sage green)
|
|
11
|
+
* surface #000000 (black)
|
|
12
|
+
* text #ffffff (white)
|
|
13
|
+
*/
|
|
14
|
+
.snutig-captcha {
|
|
15
|
+
display: inline-block;
|
|
16
|
+
max-width: 100%;
|
|
17
|
+
|
|
18
|
+
/* Brand tokens — reuse these if you embed the widget elsewhere. */
|
|
19
|
+
--snutig-primary: #99b596;
|
|
20
|
+
--snutig-primary-soft: rgba(153, 181, 150, 0.18);
|
|
21
|
+
--snutig-bg: #000000;
|
|
22
|
+
--snutig-fg: #ffffff;
|
|
23
|
+
--snutig-fg-dim: rgba(255, 255, 255, 0.55);
|
|
24
|
+
--snutig-border: rgba(255, 255, 255, 0.12);
|
|
25
|
+
--snutig-error: #d97171;
|
|
26
|
+
--snutig-error-soft: rgba(217, 113, 113, 0.3);
|
|
27
|
+
|
|
28
|
+
/* Widget surfaces (@snutig/widget custom properties) */
|
|
29
|
+
--snutig-background: var(--snutig-bg);
|
|
30
|
+
--snutig-color: var(--snutig-fg);
|
|
31
|
+
--snutig-border-color: var(--snutig-border);
|
|
32
|
+
--snutig-border-radius: 12px;
|
|
33
|
+
--snutig-widget-padding: 14px 18px;
|
|
34
|
+
--snutig-gap: 12px;
|
|
35
|
+
--snutig-font: inherit;
|
|
36
|
+
|
|
37
|
+
/* Checkbox */
|
|
38
|
+
--snutig-checkbox-background: transparent;
|
|
39
|
+
--snutig-checkbox-border: 1.5px solid var(--snutig-primary);
|
|
40
|
+
--snutig-checkbox-border-radius: 4px;
|
|
41
|
+
--snutig-checkbox-size: 20px;
|
|
42
|
+
|
|
43
|
+
/* Checkmark + cross drawn in brand colors (background sits over the dark widget). */
|
|
44
|
+
--snutig-checkmark: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2399b596' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 12 10 18 20 6'/></svg>");
|
|
45
|
+
--snutig-error-cross: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23d97171' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><line x1='6' y1='6' x2='18' y2='18'/><line x1='6' y1='18' x2='18' y2='6'/></svg>");
|
|
46
|
+
|
|
47
|
+
/* Spinner during solving */
|
|
48
|
+
--snutig-spinner-color: var(--snutig-primary);
|
|
49
|
+
--snutig-spinner-background-color: var(--snutig-primary-soft);
|
|
50
|
+
--snutig-spinner-thickness: 2px;
|
|
51
|
+
|
|
52
|
+
/* Focus + auxiliary */
|
|
53
|
+
--snutig-focus-ring: var(--snutig-primary);
|
|
54
|
+
--snutig-troubleshoot-color: var(--snutig-fg-dim);
|
|
55
|
+
|
|
56
|
+
/* Invalid state */
|
|
57
|
+
--snutig-invalid-border-color: var(--snutig-error);
|
|
58
|
+
--snutig-invalid-ring-color: var(--snutig-error-soft);
|
|
59
|
+
}
|