@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/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2025 snutig GmbH
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# @snutig/react
|
|
2
|
+
|
|
3
|
+
React bindings for the snutig CAPTCHA widget
|
|
4
|
+
|
|
5
|
+
Proof-of-work only: no puzzles, no images, nothing for a visitor to solve by hand.
|
|
6
|
+
snutig runs the service; you need an account and a site key — [snutig.de](https://snutig.de).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add @snutig/react # npm i / yarn add work the same
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Peer dependency: `react >= 18` (works with React 19 / Next.js App Router).
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
"use client"; // Next.js App Router: this component is browser-only
|
|
20
|
+
|
|
21
|
+
import { SnutigCaptcha } from "@snutig/react";
|
|
22
|
+
import { useState } from "react";
|
|
23
|
+
|
|
24
|
+
export function ContactForm() {
|
|
25
|
+
const [token, setToken] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<form action="/api/contact" method="post">
|
|
29
|
+
<input name="email" type="email" required />
|
|
30
|
+
|
|
31
|
+
<SnutigCaptcha
|
|
32
|
+
siteKey="YOUR_SITE_KEY"
|
|
33
|
+
required
|
|
34
|
+
onSolve={setToken}
|
|
35
|
+
onReset={() => setToken(null)}
|
|
36
|
+
/>
|
|
37
|
+
|
|
38
|
+
<button type="submit" disabled={!token}>
|
|
39
|
+
Absenden
|
|
40
|
+
</button>
|
|
41
|
+
</form>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The widget writes its token into a hidden `<input name="snutig-token">` it manages
|
|
47
|
+
itself, so a plain form post carries it with no extra wiring — `onSolve` is only
|
|
48
|
+
needed if you also want it in React state (to gate the submit button, for
|
|
49
|
+
instance). With `required`, native form validation blocks submission until solved.
|
|
50
|
+
|
|
51
|
+
## Invisible mode
|
|
52
|
+
|
|
53
|
+
`hidden` renders no visible widget: there is nothing to click, so you start the
|
|
54
|
+
solve yourself and submit once it resolves.
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
const captcha = useRef<SnutigCaptchaHandle>(null);
|
|
58
|
+
const form = useRef<HTMLFormElement>(null);
|
|
59
|
+
|
|
60
|
+
async function onSubmit(e: React.FormEvent) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
const { success } = (await captcha.current?.solve()) ?? { success: false };
|
|
63
|
+
if (!success) return; // onError already fired with the reason
|
|
64
|
+
form.current?.submit(); // the token is in the managed hidden input
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
<form ref={form} action="/api/contact" method="post" onSubmit={onSubmit}>
|
|
68
|
+
<input name="email" type="email" required />
|
|
69
|
+
<SnutigCaptcha
|
|
70
|
+
ref={captcha}
|
|
71
|
+
siteKey={siteKey}
|
|
72
|
+
hidden
|
|
73
|
+
onError={setError}
|
|
74
|
+
/>
|
|
75
|
+
<button type="submit">Absenden</button>
|
|
76
|
+
</form>;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Three things to know:
|
|
80
|
+
|
|
81
|
+
- **It is plan-gated.** Invisible solving is the `hiddenMode` entitlement (Pro and
|
|
82
|
+
up). Visibility is not something a server can check, so the server instead
|
|
83
|
+
reports the entitlement on every challenge, and an unlicensed widget **degrades
|
|
84
|
+
to a visible badge** in the bottom-right corner on its first solve rather than
|
|
85
|
+
letting the form through unchecked. Your form keeps working either way — it just
|
|
86
|
+
stops being invisible, the same spirit as the quota grace period. Check the
|
|
87
|
+
entitlement in the dashboard (Websites → Integration) if a badge appears.
|
|
88
|
+
- **`required` does not apply.** A browser will not report a validation error on a
|
|
89
|
+
control the user cannot see; it refuses to submit and shows nothing. So `hidden`
|
|
90
|
+
ignores `required` and warns on the console. Gate the submit yourself, as above.
|
|
91
|
+
- **No speculative pre-solve.** The widget normally warms a solve on the first
|
|
92
|
+
mouse/keyboard interaction, which it skips while it is invisible — so an
|
|
93
|
+
invisible solve starts cold and takes proportionally longer. Call `solve()`
|
|
94
|
+
before you strictly need the token (on first field blur, say) if that matters.
|
|
95
|
+
|
|
96
|
+
## Verify on your server
|
|
97
|
+
|
|
98
|
+
A token proves the check ran in a browser. It is **not** trustworthy until your
|
|
99
|
+
server verifies it — always do this before accepting the submission.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const res = await fetch(`https://api.captcha.snutig.dev/${siteKey}/siteverify`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "content-type": "application/json" },
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
secret: process.env.SNUTIG_SECRET_KEY, // secret key — server-side only
|
|
107
|
+
response: token, // the "snutig-token" form field
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const { success } = await res.json();
|
|
112
|
+
if (!success)
|
|
113
|
+
return new Response("CAPTCHA-Überprüfung fehlgeschlagen.", { status: 400 });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Tokens are single-use: verify once, and call `reset()` before letting the user
|
|
117
|
+
resubmit after a failure.
|
|
118
|
+
|
|
119
|
+
## Imperative control
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
const captcha = useRef<SnutigCaptchaHandle>(null);
|
|
123
|
+
|
|
124
|
+
await captcha.current?.solve(); // start a solve without a click
|
|
125
|
+
captcha.current?.reset(); // clear the token + hidden field
|
|
126
|
+
captcha.current?.token; // current token, or null
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Props
|
|
130
|
+
|
|
131
|
+
| Prop | Default | What it does |
|
|
132
|
+
| --------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
133
|
+
| `siteKey` | — | Website key from the dashboard (Websites → Keys). Public. |
|
|
134
|
+
| `apiUrl` | the snutig API | `https://api.captcha.snutig.dev`. Override only for a staging deployment. |
|
|
135
|
+
| `endpoint` | `<apiUrl>/<siteKey>/` | Full challenge base — only for a reverse-proxied path. |
|
|
136
|
+
| `fieldName` | `"snutig-token"` | `name` of the managed hidden input. |
|
|
137
|
+
| `required` | `false` | Blocks native submission until solved. Ignored with `hidden`. |
|
|
138
|
+
| `hidden` | `false` | Invisible mode — no visible widget, solve from the ref. Needs the `hiddenMode` plan entitlement. |
|
|
139
|
+
| `workers` | `hardwareConcurrency` | Solver worker count. |
|
|
140
|
+
| `locale` | `"de"` | `"de"` = the WordPress plugin's wording; `"en"` = the widget's own defaults. |
|
|
141
|
+
| `labels` | — | Per-label overrides, merged over the locale's set. |
|
|
142
|
+
| `theme` | `"snutig"` | `"external"` to use the imported stylesheet instead of the injected one, `"none"` to style it yourself. |
|
|
143
|
+
| `troubleshootingUrl` | — | Help link, shown only after a failure. |
|
|
144
|
+
| `nonce` | — | CSP nonce for the injected script and styles. |
|
|
145
|
+
| `className` / `style` | — | Applied to the wrapper — `<div class="snutig-captcha">`, or `snutig-captcha-unstyled` with `theme="none"`. |
|
|
146
|
+
| `onSolve(token)` | — | Solved. |
|
|
147
|
+
| `onError(error)` | — | Failed — `{ code, message }`; see `SnutigErrorCode`. |
|
|
148
|
+
| `onProgress(pct)` | — | Solve progress, 0–100. |
|
|
149
|
+
| `onReset()` | — | Token cleared (expiry or `reset()`). |
|
|
150
|
+
|
|
151
|
+
## Theming
|
|
152
|
+
|
|
153
|
+
The widget renders into a shadow root, so it is themed through CSS custom
|
|
154
|
+
properties, which inherit across the shadow boundary. By default this package
|
|
155
|
+
injects one `<style>` with the snutig brand tokens — sage `#99b596` on black,
|
|
156
|
+
12px radius — kept in sync with the plugin's `assets/snutig-captcha.css`.
|
|
157
|
+
|
|
158
|
+
Override the tokens on the wrapper:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
<SnutigCaptcha
|
|
162
|
+
{...props}
|
|
163
|
+
style={
|
|
164
|
+
{
|
|
165
|
+
"--snutig-bg": "#0b0b0b",
|
|
166
|
+
"--snutig-border-radius": "8px",
|
|
167
|
+
} as React.CSSProperties
|
|
168
|
+
}
|
|
169
|
+
/>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`--snutig-primary`, `--snutig-bg`, `--snutig-fg` and `--snutig-border-radius` are
|
|
173
|
+
the main knobs; see `THEME_CSS` for the full list. One caveat: the checkmark and
|
|
174
|
+
error cross are inline SVG data URIs with the brand colors baked in, so recoloring
|
|
175
|
+
those means replacing `--snutig-checkmark` / `--snutig-error-cross` outright.
|
|
176
|
+
|
|
177
|
+
Prefer a stylesheet over the injected tag (strict CSP, or your own build
|
|
178
|
+
pipeline)? Pass `theme="external"` and import the CSS instead:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import "@snutig/react/theme.css";
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`theme="none"` is the other case — no theme at all. The stylesheet is
|
|
185
|
+
document-wide and keyed on the wrapper class, so an unthemed instance renders
|
|
186
|
+
as `<div class="snutig-captcha-unstyled">` and stays unstyled even when another
|
|
187
|
+
instance on the page injects the brand theme.
|
|
188
|
+
|
|
189
|
+
## Notes
|
|
190
|
+
|
|
191
|
+
- **The solver is not in this package.** It loads
|
|
192
|
+
`<apiUrl>/widget/v1/widget.js` once per page and dedupes concurrent mounts, so
|
|
193
|
+
many widgets on one page cost one request. The wire protocol is versioned with
|
|
194
|
+
the API, which is why there is no vendored or CDN-hosted copy — and why you never
|
|
195
|
+
have to match a version of this package to anything server-side.
|
|
196
|
+
- **Custom element, one definition.** `<snutig-captcha>` registers itself on first
|
|
197
|
+
load; a page that already has it (a WordPress page, another React root) reuses it.
|
|
198
|
+
- **Not SSR-rendered.** Server rendering emits the tag and its attributes; the
|
|
199
|
+
widget upgrades and takes over on the client. No hydration mismatch, no DOM
|
|
200
|
+
access during render.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { SnutigLabels, SnutigLocale } from "./labels.js";
|
|
3
|
+
import type { SnutigCaptchaElement, SnutigErrorDetail, SolveResult } from "./types.js";
|
|
4
|
+
export interface SnutigCaptchaProps {
|
|
5
|
+
/** Website key from the dashboard (Websites → Keys). */
|
|
6
|
+
siteKey: string;
|
|
7
|
+
/**
|
|
8
|
+
* snutig captcha API base URL. Defaults to the managed service
|
|
9
|
+
* (`https://api.captcha.snutig.dev`) — set it only for a staging deployment.
|
|
10
|
+
*/
|
|
11
|
+
apiUrl?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Full challenge endpoint base, overriding `apiUrl` + `siteKey`
|
|
14
|
+
* (`<apiUrl>/<siteKey>/`). Rarely needed — for a reverse-proxied path.
|
|
15
|
+
*/
|
|
16
|
+
endpoint?: string;
|
|
17
|
+
/**
|
|
18
|
+
* `name` of the hidden input the token is written to, for plain form posts.
|
|
19
|
+
* Defaults to `snutig-token`.
|
|
20
|
+
*/
|
|
21
|
+
fieldName?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Blocks native form submission until solved, via constraint validation
|
|
24
|
+
* (the widget participates as a form-associated element).
|
|
25
|
+
*/
|
|
26
|
+
required?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Invisible mode: no visible widget — nothing for the user to click, so the
|
|
29
|
+
* solve has to be started from the ref (`solve()`, typically in `onSubmit`).
|
|
30
|
+
*
|
|
31
|
+
* Plan-gated (`hiddenMode`). A site that is not licensed for invisible solving
|
|
32
|
+
* gets `widget.hiddenMode: false` on its challenge, and the widget then
|
|
33
|
+
* degrades to a visible badge in the bottom-right corner rather than letting a
|
|
34
|
+
* form ship without a check — so this never *breaks*, it only stops being
|
|
35
|
+
* invisible.
|
|
36
|
+
*
|
|
37
|
+
* `required` does not apply: constraint validation cannot report a problem on
|
|
38
|
+
* an element the user cannot see (the browser refuses to submit and shows
|
|
39
|
+
* nothing), so it is ignored here.
|
|
40
|
+
*/
|
|
41
|
+
hidden?: boolean;
|
|
42
|
+
/** Solver worker count. Defaults to `navigator.hardwareConcurrency`. */
|
|
43
|
+
workers?: number;
|
|
44
|
+
/** Label set. Defaults to `"de"` — the wording the WordPress plugin ships. */
|
|
45
|
+
locale?: SnutigLocale;
|
|
46
|
+
/** Per-label overrides, merged over the locale's set. */
|
|
47
|
+
labels?: Partial<SnutigLabels>;
|
|
48
|
+
/**
|
|
49
|
+
* `"snutig"` (default) injects the brand theme and marks the wrapper for it.
|
|
50
|
+
* `"external"` marks the wrapper but injects nothing — for importing
|
|
51
|
+
* `@snutig/react/theme.css` through your own pipeline (or under a strict CSP).
|
|
52
|
+
* `"none"` leaves the widget unstyled even when another instance on the page is
|
|
53
|
+
* themed: the brand CSS targets the wrapper class, so an unthemed instance has to
|
|
54
|
+
* not carry it.
|
|
55
|
+
*/
|
|
56
|
+
theme?: "snutig" | "external" | "none";
|
|
57
|
+
/** Help link, shown only after a failure. */
|
|
58
|
+
troubleshootingUrl?: string;
|
|
59
|
+
/** CSP nonce for the injected script + styles. */
|
|
60
|
+
nonce?: string;
|
|
61
|
+
className?: string;
|
|
62
|
+
style?: CSSProperties;
|
|
63
|
+
/** Solved — `token` goes to your server's `POST /:siteKey/siteverify`. */
|
|
64
|
+
onSolve?: (token: string) => void;
|
|
65
|
+
onError?: (error: SnutigErrorDetail) => void;
|
|
66
|
+
/** Solve progress, 0–100. */
|
|
67
|
+
onProgress?: (progress: number) => void;
|
|
68
|
+
/** Token cleared — on expiry, or via `reset()`. */
|
|
69
|
+
onReset?: () => void;
|
|
70
|
+
}
|
|
71
|
+
export interface SnutigCaptchaHandle {
|
|
72
|
+
/** Starts a solve (the widget also does this on click). */
|
|
73
|
+
solve(): Promise<SolveResult>;
|
|
74
|
+
/** Clears the token and the hidden field — call after a failed submit. */
|
|
75
|
+
reset(): void;
|
|
76
|
+
/** Current token, or `null` when unsolved. */
|
|
77
|
+
readonly token: string | null;
|
|
78
|
+
/** The underlying custom element, once mounted. */
|
|
79
|
+
readonly element: SnutigCaptchaElement | null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The snutig CAPTCHA widget as a React component.
|
|
83
|
+
*
|
|
84
|
+
* Wraps the `<snutig-captcha>` custom element served by the captcha server: this
|
|
85
|
+
* package loads that bundle, maps props to its attributes, and turns its
|
|
86
|
+
* `solve`/`error`/`progress`/`reset` DOM events into props. The widget renders
|
|
87
|
+
* itself into a shadow root and writes the token into a hidden input, so a plain
|
|
88
|
+
* `<form>` post carries it with no extra wiring.
|
|
89
|
+
*
|
|
90
|
+
* With `hidden` it renders nothing and solves only when `solve()` is called from
|
|
91
|
+
* the ref — the invisible mode, which is a plan entitlement the server asserts on
|
|
92
|
+
* every challenge.
|
|
93
|
+
*
|
|
94
|
+
* A token only proves the check ran in the browser — always verify it
|
|
95
|
+
* server-side with `POST <apiUrl>/<siteKey>/siteverify` before trusting the
|
|
96
|
+
* submission.
|
|
97
|
+
*/
|
|
98
|
+
export declare const SnutigCaptcha: import("react").ForwardRefExoticComponent<SnutigCaptchaProps & import("react").RefAttributes<SnutigCaptchaHandle>>;
|
|
99
|
+
//# sourceMappingURL=SnutigCaptcha.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SnutigCaptcha.d.ts","sourceRoot":"","sources":["../src/SnutigCaptcha.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAO,MAAM,OAAO,CAAC;AAGhD,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE9D,OAAO,KAAK,EACV,oBAAoB,EACpB,iBAAiB,EACjB,WAAW,EACZ,MAAM,YAAY,CAAC;AAyBpB,MAAM,WAAW,kBAAkB;IACjC,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,yDAAyD;IACzD,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;IAC/B;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,MAAM,CAAC;IACvC,6CAA6C;IAC7C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7C,6BAA6B;IAC7B,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,mDAAmD;IACnD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,2DAA2D;IAC3D,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B,0EAA0E;IAC1E,KAAK,IAAI,IAAI,CAAC;IACd,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,mDAAmD;IACnD,QAAQ,CAAC,OAAO,EAAE,oBAAoB,GAAG,IAAI,CAAC;CAC/C;AAWD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,aAAa,oHA+KxB,CAAC"}
|