Skip to content

Commit 16a7472

Browse files
Merge pull request #20 from atko-cic/feat/captcha-components
chore: added the captcha components
2 parents e81732b + b223555 commit 16a7472

39 files changed

+3708
-623
lines changed

package-lock.json

Lines changed: 810 additions & 602 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

react-js/jest.config.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ module.exports = {
1111
'^@/(.*)$': '<rootDir>/src/$1',
1212
},
1313
// Allow transforming ESM modules from node_modules.
14-
transformIgnorePatterns: ['/node_modules/(?!@auth0/auth0-acul-js/)'],
14+
transformIgnorePatterns: ['/node_modules/(?!(friendly-challenge|@auth0/auth0-acul-js)/)'],
1515
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
1616
};

react-js/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,17 @@
1717
"dependencies": {
1818
"@auth0/auth0-acul-js": "0.1.0-beta.7",
1919
"@base-ui-components/react": "^1.0.0-beta.1",
20+
"@hcaptcha/react-hcaptcha": "^1.12.1",
2021
"class-variance-authority": "^0.7.1",
2122
"clsx": "^2.1.1",
23+
"friendly-challenge": "^0.9.19",
2224
"lucide-react": "^0.525.0",
2325
"react": "^19.1.0",
2426
"react-dom": "^19.1.0",
27+
"react-google-recaptcha": "^3.1.0",
28+
"react-google-recaptcha-enterprise": "^1.0.3",
2529
"react-hook-form": "^7.60.0",
30+
"react-turnstile": "^1.1.4",
2631
"tailwind-merge": "^3.3.1"
2732
},
2833
"devDependencies": {
@@ -39,6 +44,8 @@
3944
"@types/node": "^24.0.15",
4045
"@types/react": "^19.1.8",
4146
"@types/react-dom": "^19.1.6",
47+
"@types/react-google-recaptcha": "^2.1.9",
48+
"@types/react-google-recaptcha-enterprise": "^1.0.3",
4249
"@typescript-eslint/eslint-plugin": "8.37.0",
4350
"@typescript-eslint/parser": "8.37.0",
4451
"@vitejs/plugin-react": "^4.7.0",
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React from "react";
2+
import { Control, FieldValues, Path, RegisterOptions } from "react-hook-form";
3+
4+
import AuthChallengeWidget from "./providers/AuthChallengeWidget";
5+
import FriendlyCaptchaWidget from "./providers/FriendlyCaptchaWidget";
6+
import HCaptchaWidget from "./providers/HCaptchaWidget";
7+
import RecaptchaCombinedWidget from "./providers/ReCaptchaCombinedWidget";
8+
import SimpleCaptchaWidget from "./providers/SimpleCaptchaWidget";
9+
10+
// ---
11+
// Interfaces
12+
// ---
13+
14+
export interface CaptchaResponse {
15+
provider: string;
16+
token?: string;
17+
answer?: string;
18+
arkoseToken?: string;
19+
}
20+
21+
export interface CaptchaWidgetProps<T extends FieldValues = FieldValues> {
22+
config: {
23+
provider: string;
24+
siteKey?: string;
25+
image?: string;
26+
size?: string;
27+
placeholder?: string;
28+
};
29+
control?: Control<T>;
30+
rules?: RegisterOptions<T>;
31+
name: Path<T>;
32+
onCaptchaResponse: (response: CaptchaResponse | null) => void;
33+
className?: string;
34+
label?: string;
35+
theme?: "light" | "dark" | "auto";
36+
error?: string;
37+
}
38+
39+
export interface ICaptcha {
40+
provider?: string;
41+
image?: string;
42+
imageAltText?: string;
43+
enabled?: boolean;
44+
siteKey?: string;
45+
}
46+
47+
export interface CaptchaProps<T extends FieldValues = FieldValues> {
48+
captcha?: ICaptcha;
49+
onValidationChange?: (
50+
isValid: boolean,
51+
value?: string,
52+
error?: string
53+
) => void;
54+
label?: string;
55+
sdkError?: string;
56+
theme?: "light" | "dark" | "auto";
57+
className?: string;
58+
control: Control<T>;
59+
rules?: RegisterOptions<T>;
60+
name: Path<T>;
61+
}
62+
63+
// ---
64+
// Main Component
65+
// ---
66+
67+
const Captcha = <T extends FieldValues = FieldValues>({
68+
control,
69+
rules,
70+
name,
71+
captcha,
72+
onValidationChange,
73+
label,
74+
sdkError,
75+
theme,
76+
className,
77+
}: CaptchaProps<T>) => {
78+
// ---
79+
// Constants and Mappings
80+
// ---
81+
82+
function getCaptchaWidgetMap<T extends FieldValues>() {
83+
return {
84+
recaptcha_v2: RecaptchaCombinedWidget as React.ComponentType<
85+
CaptchaWidgetProps<T>
86+
>,
87+
recaptcha_enterprise: RecaptchaCombinedWidget as React.ComponentType<
88+
CaptchaWidgetProps<T>
89+
>,
90+
hcaptcha: HCaptchaWidget as React.ComponentType<CaptchaWidgetProps<T>>,
91+
auth0_v2: AuthChallengeWidget as React.ComponentType<
92+
CaptchaWidgetProps<T>
93+
>,
94+
friendly_captcha: FriendlyCaptchaWidget as React.ComponentType<
95+
CaptchaWidgetProps<T>
96+
>,
97+
};
98+
}
99+
const CAPTCHA_WIDGET_MAP = getCaptchaWidgetMap<T>();
100+
const { provider, image, siteKey, enabled = true } = captcha || {}; // Default 'enabled' to true
101+
102+
// If captcha is not enabled or no provider is specified, render nothing.
103+
if (!enabled || !provider) {
104+
return null;
105+
}
106+
107+
const handleResponse = (res: CaptchaResponse | null) => {
108+
if (onValidationChange) {
109+
if (res) {
110+
const value =
111+
res.provider === "auth0" ? res.answer : res.token || res.arkoseToken;
112+
const isValid = !!value;
113+
onValidationChange(isValid, value);
114+
} else {
115+
onValidationChange(false);
116+
}
117+
}
118+
};
119+
120+
// Handle the special case for SimpleCaptchaWidget (Auth0 v1)
121+
if (provider === "auth0") {
122+
return image ? (
123+
<SimpleCaptchaWidget
124+
config={{ provider: "auth0", image }}
125+
onCaptchaResponse={handleResponse}
126+
control={control}
127+
name={name}
128+
rules={rules}
129+
label={label}
130+
error={sdkError}
131+
className={className}
132+
/>
133+
) : null;
134+
}
135+
136+
// Use the map for other providers
137+
const SpecificCaptchaWidget =
138+
CAPTCHA_WIDGET_MAP[provider as keyof typeof CAPTCHA_WIDGET_MAP];
139+
140+
if (SpecificCaptchaWidget && siteKey) {
141+
return (
142+
<SpecificCaptchaWidget
143+
config={{ provider, siteKey }}
144+
name={name}
145+
onCaptchaResponse={handleResponse}
146+
theme={theme}
147+
label={label}
148+
error={sdkError}
149+
className={className}
150+
/>
151+
);
152+
}
153+
return null;
154+
};
155+
156+
export default Captcha;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import Turnstile from "react-turnstile";
3+
4+
import { cn } from "@/lib/utils";
5+
6+
import type { CaptchaResponse, CaptchaWidgetProps } from "../index";
7+
8+
const MAX_RETRY_ATTEMPTS = 3; // Define a maximum number of retries
9+
10+
const AuthChallengeWidget: React.FC<CaptchaWidgetProps> = ({
11+
config,
12+
onCaptchaResponse,
13+
className = "",
14+
theme,
15+
error,
16+
}) => {
17+
const [errorMessage, setErrorMessage] = useState<string | undefined>(error); // Manage internal as well as error from backend
18+
const retryCount = useRef(0); // Use a ref to persist retry count across renders
19+
20+
// Update internal error if external error prop changes
21+
useEffect(() => {
22+
setErrorMessage(error);
23+
}, [error]);
24+
25+
if (config.provider !== "auth0_v2") {
26+
return null;
27+
}
28+
29+
const siteKey = config.siteKey;
30+
31+
if (!siteKey) {
32+
return null;
33+
}
34+
35+
const handleVerify = (token: string) => {
36+
retryCount.current = 0; // Reset retry count on successful verification
37+
setErrorMessage(undefined); // Clear any previous error
38+
39+
const response: CaptchaResponse = {
40+
provider: "auth0_v2",
41+
token: token,
42+
};
43+
onCaptchaResponse(response);
44+
};
45+
46+
const handleError = (error: string) => {
47+
onCaptchaResponse(null);
48+
49+
if (retryCount.current < MAX_RETRY_ATTEMPTS) {
50+
retryCount.current += 1;
51+
return;
52+
} else {
53+
setErrorMessage(`Verification failed after multiple attempts: ${error}`);
54+
}
55+
};
56+
57+
const handleExpire = () => {
58+
onCaptchaResponse(null);
59+
retryCount.current = 0; // Reset retry count on expiration
60+
};
61+
62+
const auth0CaptchaWidgetStyle = {
63+
transform: "scale(1.06)",
64+
transformOrigin: "0 0",
65+
overflow: "hidden",
66+
display: "inline-block",
67+
lineHeight: 0,
68+
fontSize: 0,
69+
};
70+
71+
return (
72+
<div className={cn("space-y-2", className)}>
73+
<div
74+
className={cn(
75+
"rounded-sm inline-block",
76+
errorMessage ? "border border-[#d93025]" : "none"
77+
)}
78+
style={auth0CaptchaWidgetStyle}
79+
>
80+
<Turnstile
81+
sitekey={siteKey}
82+
onVerify={handleVerify}
83+
onError={handleError}
84+
onExpire={handleExpire}
85+
theme={theme}
86+
size="normal"
87+
/>
88+
</div>
89+
</div>
90+
);
91+
};
92+
93+
export default AuthChallengeWidget;

0 commit comments

Comments
 (0)