@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 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"}