@prosopo/procaptcha-puzzle 2.10.8 → 2.10.9

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.
@@ -0,0 +1,261 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
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.
14
+
15
+ import { loadI18next, useTranslation } from "@prosopo/locale";
16
+ import { buildUpdateState, useProcaptcha } from "@prosopo/procaptcha-common";
17
+ import { Checkbox, Honeypot } from "@prosopo/procaptcha-common";
18
+ import {
19
+ type GetPuzzleCaptchaResponse,
20
+ ModeEnum,
21
+ type ProcaptchaProps,
22
+ type PuzzleEvent,
23
+ } from "@prosopo/types";
24
+ import { darkTheme, lightTheme } from "@prosopo/widget-skeleton";
25
+ import { useCallback, useEffect, useRef, useState } from "react";
26
+ import { Manager } from "../services/Manager.js";
27
+ import { PuzzleCanvas } from "./PuzzleCanvas.js";
28
+
29
+ // Define the same event name as in the bundle for consistency
30
+ const PROCAPTCHA_EXECUTE_EVENT = "procaptcha:execute";
31
+
32
+ type PuzzlePhase = "checkbox" | "dragging" | "submitting";
33
+
34
+ const Procaptcha = (props: ProcaptchaProps) => {
35
+ const { t, ready: isTranslationReady } = useTranslation();
36
+ const config = props.config;
37
+ const i18n = props.i18n;
38
+ const theme = "light" === config.theme ? lightTheme : darkTheme;
39
+ const frictionlessState = props.frictionlessState; // Set up Session ID and Provider if they exist
40
+ const callbacks = props.callbacks || {};
41
+ const [state, _updateState] = useProcaptcha(useState, useRef);
42
+ const [loading, setLoading] = useState(false);
43
+ const [puzzlePhase, setPuzzlePhase] = useState<PuzzlePhase>("checkbox");
44
+ const [challengeData, setChallengeData] =
45
+ useState<GetPuzzleCaptchaResponse | null>(null);
46
+ const [showRetry, setShowRetry] = useState(false);
47
+ // get the state update mechanism
48
+ const updateState = buildUpdateState(state, _updateState);
49
+ const hpRef = useRef<HTMLInputElement>(null);
50
+ const manager = useRef(
51
+ Manager(
52
+ config,
53
+ state,
54
+ updateState,
55
+ callbacks,
56
+ frictionlessState,
57
+ () => hpRef.current?.value || undefined,
58
+ ),
59
+ );
60
+
61
+ useEffect(() => {
62
+ if (config.language) {
63
+ if (i18n) {
64
+ if (i18n.language !== config.language) {
65
+ i18n.changeLanguage(config.language).then((r) => r);
66
+ }
67
+ } else {
68
+ loadI18next(false).then((i18n) => {
69
+ if (i18n.language !== config.language)
70
+ i18n.changeLanguage(config.language).then((r) => r);
71
+ });
72
+ }
73
+ }
74
+ }, [i18n, config.language]);
75
+
76
+ useEffect(() => {
77
+ if (!state.error) return undefined;
78
+ setLoading(false);
79
+ setPuzzlePhase("checkbox");
80
+ setChallengeData(null);
81
+ setShowRetry(false);
82
+ if (state.error.key === "CAPTCHA.NO_SESSION_FOUND" && frictionlessState) {
83
+ const timer = setTimeout(() => {
84
+ frictionlessState.restart();
85
+ }, 100);
86
+ return () => clearTimeout(timer);
87
+ }
88
+ return undefined;
89
+ }, [state.error, frictionlessState]);
90
+
91
+ // Add event listener for the execute event (works for invisible mode)
92
+ useEffect(() => {
93
+ // Only set up event listener if in invisible mode
94
+ if (config.mode === ModeEnum.invisible) {
95
+ // Event handler for when execute() is called: fetch a challenge
96
+ // then drive the puzzle UI through the same phase transitions as
97
+ // the visible checkbox flow.
98
+ const handleExecuteEvent = async () => {
99
+ if (loading) {
100
+ return;
101
+ }
102
+ setLoading(true);
103
+ setShowRetry(false);
104
+ try {
105
+ const challenge = await manager.current.start();
106
+ if (challenge) {
107
+ setChallengeData(challenge);
108
+ setPuzzlePhase("dragging");
109
+ }
110
+ } catch (error) {
111
+ callbacks.onError?.(
112
+ error instanceof Error ? error : new Error(String(error)),
113
+ );
114
+ } finally {
115
+ setLoading(false);
116
+ }
117
+ };
118
+
119
+ document.addEventListener(PROCAPTCHA_EXECUTE_EVENT, handleExecuteEvent);
120
+
121
+ // Cleanup function to remove event listener
122
+ return () => {
123
+ document.removeEventListener(
124
+ PROCAPTCHA_EXECUTE_EVENT,
125
+ handleExecuteEvent,
126
+ );
127
+ };
128
+ }
129
+
130
+ // Return empty cleanup function when not in invisible mode
131
+ return () => {};
132
+ }, [config.mode, callbacks.onError, loading]);
133
+
134
+ const handlePuzzleComplete = useCallback(
135
+ async (finalX: number, finalY: number, puzzleEvents: PuzzleEvent[]) => {
136
+ setPuzzlePhase("submitting");
137
+ let verified = false;
138
+ try {
139
+ verified = await manager.current.submitSolution(
140
+ finalX,
141
+ finalY,
142
+ puzzleEvents,
143
+ );
144
+ } catch (error) {
145
+ callbacks.onError?.(
146
+ error instanceof Error ? error : new Error(String(error)),
147
+ );
148
+ }
149
+
150
+ if (verified) {
151
+ setPuzzlePhase("checkbox");
152
+ setChallengeData(null);
153
+ setShowRetry(false);
154
+ setLoading(false);
155
+ return;
156
+ }
157
+
158
+ // Failed — show retry message and fetch a new challenge
159
+ setShowRetry(true);
160
+ setPuzzlePhase("dragging");
161
+
162
+ try {
163
+ const newChallenge = await manager.current.start();
164
+ if (newChallenge) {
165
+ setChallengeData(newChallenge);
166
+ } else {
167
+ // Couldn't get new challenge, fall back to checkbox
168
+ setPuzzlePhase("checkbox");
169
+ setChallengeData(null);
170
+ setShowRetry(false);
171
+ }
172
+ } catch {
173
+ setPuzzlePhase("checkbox");
174
+ setChallengeData(null);
175
+ setShowRetry(false);
176
+ }
177
+ setLoading(false);
178
+ },
179
+ [callbacks.onError],
180
+ );
181
+
182
+ const isInvisible = config.mode === ModeEnum.invisible;
183
+ const showPuzzleOverlay =
184
+ (puzzlePhase === "dragging" || puzzlePhase === "submitting") &&
185
+ challengeData;
186
+
187
+ return (
188
+ <>
189
+ {frictionlessState?.hp && (
190
+ <Honeypot ref={hpRef} encodedQuestion={frictionlessState.hp} />
191
+ )}
192
+ {/* Puzzle overlay — rendered outside the shadow DOM flow via fixed
193
+ positioning. Shown in both visible and invisible modes once a
194
+ challenge has been fetched; puzzle is inherently interactive. */}
195
+ {showPuzzleOverlay && (
196
+ <PuzzleCanvas
197
+ originX={challengeData.originX}
198
+ originY={challengeData.originY}
199
+ targetX={challengeData.targetX}
200
+ targetY={challengeData.targetY}
201
+ onComplete={handlePuzzleComplete}
202
+ showRetry={showRetry}
203
+ submitting={puzzlePhase === "submitting"}
204
+ />
205
+ )}
206
+
207
+ {/* Checkbox — only in visible mode. Invisible mode is driven by
208
+ the host page's execute() call (e.g. on form submit). */}
209
+ {!isInvisible && (
210
+ <Checkbox
211
+ checked={state.isHuman}
212
+ theme={theme}
213
+ onChange={async (event: React.MouseEvent | React.TouchEvent) => {
214
+ if (loading) {
215
+ return;
216
+ }
217
+ setLoading(true);
218
+ setShowRetry(false);
219
+
220
+ // Capture click coordinates (mirrors POW widget) so the
221
+ // puzzle solution salt records the entry-point telemetry.
222
+ let x = 0;
223
+ let y = 0;
224
+ const mouseOrTouchEvent = event.nativeEvent;
225
+ if (!mouseOrTouchEvent.isTrusted) {
226
+ // Don't capture coordinates for non-trusted events
227
+ } else if (
228
+ "touches" in mouseOrTouchEvent &&
229
+ mouseOrTouchEvent.touches.length > 0 &&
230
+ mouseOrTouchEvent.touches[0]
231
+ ) {
232
+ x = mouseOrTouchEvent.touches[0].clientX;
233
+ y = mouseOrTouchEvent.touches[0].clientY;
234
+ } else if (
235
+ "clientX" in mouseOrTouchEvent &&
236
+ "clientY" in mouseOrTouchEvent
237
+ ) {
238
+ x = mouseOrTouchEvent.clientX;
239
+ y = mouseOrTouchEvent.clientY;
240
+ }
241
+
242
+ const challenge = await manager.current.start(x, y);
243
+
244
+ if (challenge) {
245
+ setChallengeData(challenge);
246
+ setPuzzlePhase("dragging");
247
+ }
248
+
249
+ setLoading(false);
250
+ }}
251
+ labelText={isTranslationReady ? t("WIDGET.I_AM_HUMAN") : ""}
252
+ error={state.error?.message}
253
+ aria-label="human checkbox"
254
+ loading={loading || puzzlePhase === "submitting"}
255
+ />
256
+ )}
257
+ </>
258
+ );
259
+ };
260
+
261
+ export default Procaptcha;
@@ -0,0 +1,336 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
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.
14
+
15
+ import type { PuzzleEvent } from "@prosopo/types";
16
+ import { useCallback, useEffect, useRef, useState } from "react";
17
+
18
+ interface PuzzleCanvasProps {
19
+ originX: number;
20
+ originY: number;
21
+ targetX: number;
22
+ targetY: number;
23
+ onComplete: (
24
+ finalX: number,
25
+ finalY: number,
26
+ puzzleEvents: PuzzleEvent[],
27
+ ) => void;
28
+ showRetry: boolean;
29
+ submitting: boolean;
30
+ }
31
+
32
+ const CONTAINER_WIDTH = 300;
33
+ const CONTAINER_HEIGHT = 200;
34
+ const TARGET_SIZE = 30;
35
+ const PIECE_SIZE = 24;
36
+
37
+ const SHAKE_KEYFRAMES = `
38
+ @keyframes prosopo-puzzle-shake {
39
+ 0%, 100% { transform: translateX(0); }
40
+ 10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
41
+ 20%, 40%, 60%, 80% { transform: translateX(4px); }
42
+ }
43
+ `;
44
+
45
+ export const PuzzleCanvas = ({
46
+ originX,
47
+ originY,
48
+ targetX,
49
+ targetY,
50
+ onComplete,
51
+ showRetry,
52
+ submitting,
53
+ }: PuzzleCanvasProps) => {
54
+ const [posX, setPosX] = useState<number>(originX);
55
+ const [posY, setPosY] = useState<number>(originY);
56
+ const isDragging = useRef<boolean>(false);
57
+ const puzzleEvents = useRef<PuzzleEvent[]>([]);
58
+ const containerRef = useRef<HTMLDivElement | null>(null);
59
+ const offsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
60
+ const [visible, setVisible] = useState(false);
61
+ const [shaking, setShaking] = useState(false);
62
+
63
+ // Reset piece position when challenge data changes (new puzzle on retry)
64
+ useEffect(() => {
65
+ setPosX(originX);
66
+ setPosY(originY);
67
+ }, [originX, originY]);
68
+
69
+ // Trigger entrance animation after mount
70
+ useEffect(() => {
71
+ const frame = requestAnimationFrame(() => setVisible(true));
72
+ return () => cancelAnimationFrame(frame);
73
+ }, []);
74
+
75
+ // Trigger shake on retry
76
+ useEffect(() => {
77
+ if (showRetry) {
78
+ setShaking(true);
79
+ const timer = setTimeout(() => setShaking(false), 500);
80
+ return () => clearTimeout(timer);
81
+ }
82
+ return () => {};
83
+ }, [showRetry]);
84
+
85
+ const clamp = useCallback(
86
+ (value: number, min: number, max: number): number => {
87
+ return Math.max(min, Math.min(max, value));
88
+ },
89
+ [],
90
+ );
91
+
92
+ const getContainerOffset = useCallback((): { x: number; y: number } => {
93
+ if (containerRef.current) {
94
+ const rect = containerRef.current.getBoundingClientRect();
95
+ return { x: rect.left, y: rect.top };
96
+ }
97
+ return { x: 0, y: 0 };
98
+ }, []);
99
+
100
+ const handleMoveEvent = useCallback(
101
+ (clientX: number, clientY: number) => {
102
+ if (!isDragging.current) {
103
+ return;
104
+ }
105
+
106
+ const containerOffset = getContainerOffset();
107
+ const newX = clamp(
108
+ clientX - containerOffset.x - offsetRef.current.x,
109
+ 0,
110
+ CONTAINER_WIDTH,
111
+ );
112
+ const newY = clamp(
113
+ clientY - containerOffset.y - offsetRef.current.y,
114
+ 0,
115
+ CONTAINER_HEIGHT,
116
+ );
117
+
118
+ setPosX(newX);
119
+ setPosY(newY);
120
+
121
+ puzzleEvents.current.push({ x: newX, y: newY, t: Date.now() });
122
+ },
123
+ [clamp, getContainerOffset],
124
+ );
125
+
126
+ const handleEndEvent = useCallback(() => {
127
+ if (!isDragging.current) {
128
+ return;
129
+ }
130
+
131
+ isDragging.current = false;
132
+
133
+ const currentEvents = [...puzzleEvents.current];
134
+ const lastEvent = currentEvents[currentEvents.length - 1];
135
+ const finalX = lastEvent ? lastEvent.x : originX;
136
+ const finalY = lastEvent ? lastEvent.y : originY;
137
+
138
+ onComplete(finalX, finalY, currentEvents);
139
+ }, [onComplete, originX, originY]);
140
+
141
+ const handleMouseMove = useCallback(
142
+ (event: MouseEvent) => {
143
+ handleMoveEvent(event.clientX, event.clientY);
144
+ },
145
+ [handleMoveEvent],
146
+ );
147
+
148
+ const handleTouchMove = useCallback(
149
+ (event: TouchEvent) => {
150
+ const touch = event.touches[0];
151
+ if (touch) {
152
+ handleMoveEvent(touch.clientX, touch.clientY);
153
+ }
154
+ },
155
+ [handleMoveEvent],
156
+ );
157
+
158
+ const handleMouseUp = useCallback(() => {
159
+ handleEndEvent();
160
+ }, [handleEndEvent]);
161
+
162
+ const handleTouchEnd = useCallback(() => {
163
+ handleEndEvent();
164
+ }, [handleEndEvent]);
165
+
166
+ useEffect(() => {
167
+ document.addEventListener("mousemove", handleMouseMove);
168
+ document.addEventListener("mouseup", handleMouseUp);
169
+ document.addEventListener("touchmove", handleTouchMove);
170
+ document.addEventListener("touchend", handleTouchEnd);
171
+
172
+ return () => {
173
+ document.removeEventListener("mousemove", handleMouseMove);
174
+ document.removeEventListener("mouseup", handleMouseUp);
175
+ document.removeEventListener("touchmove", handleTouchMove);
176
+ document.removeEventListener("touchend", handleTouchEnd);
177
+ };
178
+ }, [handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
179
+
180
+ const handlePieceMouseDown = useCallback(
181
+ (event: React.MouseEvent<HTMLDivElement>) => {
182
+ if (submitting) return;
183
+ isDragging.current = true;
184
+ puzzleEvents.current = [];
185
+ const containerOffset = getContainerOffset();
186
+ offsetRef.current = {
187
+ x: event.clientX - containerOffset.x - posX,
188
+ y: event.clientY - containerOffset.y - posY,
189
+ };
190
+ },
191
+ [getContainerOffset, posX, posY, submitting],
192
+ );
193
+
194
+ const handlePieceTouchStart = useCallback(
195
+ (event: React.TouchEvent<HTMLDivElement>) => {
196
+ if (submitting) return;
197
+ const touch = event.touches[0];
198
+ if (touch) {
199
+ isDragging.current = true;
200
+ puzzleEvents.current = [];
201
+ const containerOffset = getContainerOffset();
202
+ offsetRef.current = {
203
+ x: touch.clientX - containerOffset.x - posX,
204
+ y: touch.clientY - containerOffset.y - posY,
205
+ };
206
+ }
207
+ },
208
+ [getContainerOffset, posX, posY, submitting],
209
+ );
210
+
211
+ const instructionText = showRetry
212
+ ? "Not quite \u2014 try again"
213
+ : "Drag the piece to the target";
214
+
215
+ const headerBorderColor = showRetry
216
+ ? "rgba(220, 53, 69, 0.4)"
217
+ : "transparent";
218
+
219
+ const headerTextColor = showRetry ? "#dc3545" : "#333";
220
+
221
+ return (
222
+ <div
223
+ style={{
224
+ position: "fixed",
225
+ inset: 0,
226
+ zIndex: 2147483646,
227
+ display: "flex",
228
+ alignItems: "center",
229
+ justifyContent: "center",
230
+ backgroundColor: visible ? "rgba(0, 0, 0, 0.4)" : "rgba(0, 0, 0, 0)",
231
+ transition: "background-color 0.3s ease",
232
+ }}
233
+ >
234
+ {/* Inject shake keyframes */}
235
+ <style>{SHAKE_KEYFRAMES}</style>
236
+
237
+ <div
238
+ style={{
239
+ display: "flex",
240
+ flexDirection: "column",
241
+ alignItems: "center",
242
+ gap: "0",
243
+ zIndex: 2147483647,
244
+ opacity: visible ? 1 : 0,
245
+ transform: visible ? "scale(1)" : "scale(0.9)",
246
+ transition: "opacity 0.3s ease, transform 0.3s ease",
247
+ animation: shaking ? "prosopo-puzzle-shake 0.5s ease" : "none",
248
+ }}
249
+ >
250
+ {/* Instruction text */}
251
+ <div
252
+ style={{
253
+ backgroundColor: "#fff",
254
+ borderRadius: "8px 8px 0 0",
255
+ padding: "12px 20px",
256
+ width: `${CONTAINER_WIDTH}px`,
257
+ boxSizing: "border-box",
258
+ textAlign: "center",
259
+ fontFamily:
260
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
261
+ fontSize: "14px",
262
+ fontWeight: 500,
263
+ color: headerTextColor,
264
+ borderBottom: `2px solid ${headerBorderColor}`,
265
+ boxShadow:
266
+ "0px 11px 15px -7px rgba(0,0,0,0.2), 0px 24px 38px 3px rgba(0,0,0,0.14), 0px 9px 46px 8px rgba(0,0,0,0.12)",
267
+ transition: "color 0.3s ease, border-color 0.3s ease",
268
+ }}
269
+ >
270
+ {instructionText}
271
+ </div>
272
+
273
+ {/* Puzzle area */}
274
+ <div
275
+ ref={containerRef}
276
+ style={{
277
+ position: "relative",
278
+ width: `${CONTAINER_WIDTH}px`,
279
+ height: `${CONTAINER_HEIGHT}px`,
280
+ background:
281
+ "linear-gradient(135deg, #e8eaf6 0%, #c5cae9 50%, #e8eaf6 100%)",
282
+ borderRadius: "0 0 8px 8px",
283
+ overflow: "hidden",
284
+ userSelect: "none",
285
+ boxShadow:
286
+ "0px 11px 15px -7px rgba(0,0,0,0.2), 0px 24px 38px 3px rgba(0,0,0,0.14), 0px 9px 46px 8px rgba(0,0,0,0.12)",
287
+ opacity: submitting ? 0.6 : 1,
288
+ pointerEvents: submitting ? "none" : "auto",
289
+ transition: "opacity 0.2s ease",
290
+ }}
291
+ >
292
+ {/* Target zone */}
293
+ <div
294
+ style={{
295
+ position: "absolute",
296
+ left: `${targetX - TARGET_SIZE / 2}px`,
297
+ top: `${targetY - TARGET_SIZE / 2}px`,
298
+ width: `${TARGET_SIZE}px`,
299
+ height: `${TARGET_SIZE}px`,
300
+ borderRadius: "50%",
301
+ border: "2px dashed rgba(74, 144, 217, 0.6)",
302
+ backgroundColor: "rgba(74, 144, 217, 0.08)",
303
+ boxSizing: "border-box",
304
+ }}
305
+ />
306
+ {/* Puzzle piece */}
307
+ <div
308
+ onMouseDown={handlePieceMouseDown}
309
+ onTouchStart={handlePieceTouchStart}
310
+ style={{
311
+ position: "absolute",
312
+ left: `${posX - PIECE_SIZE / 2}px`,
313
+ top: `${posY - PIECE_SIZE / 2}px`,
314
+ width: `${PIECE_SIZE}px`,
315
+ height: `${PIECE_SIZE}px`,
316
+ borderRadius: "50%",
317
+ background:
318
+ "radial-gradient(circle at 40% 40%, #6ab0ff, #4a90d9)",
319
+ cursor: submitting
320
+ ? "default"
321
+ : isDragging.current
322
+ ? "grabbing"
323
+ : "grab",
324
+ boxShadow: isDragging.current
325
+ ? "0 4px 12px rgba(74, 144, 217, 0.5)"
326
+ : "0 2px 6px rgba(74, 144, 217, 0.3)",
327
+ transition: isDragging.current
328
+ ? "none"
329
+ : "box-shadow 0.2s ease, left 0.3s ease, top 0.3s ease",
330
+ }}
331
+ />
332
+ </div>
333
+ </div>
334
+ </div>
335
+ );
336
+ };
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
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.
14
+ export * from "./components/ProcaptchaWidget.js";
15
+ export * from "./components/ProcaptchaPuzzle.js";