Skip to content

Commit b5b148f

Browse files
authored
[CA-4854] Catchafox support (#294)
1 parent eef34fa commit b5b148f

17 files changed

+543
-378
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
- Add CaptchaFox support
11+
1012
## [1.35.0] - 2025-05-13
1113

1214
### Changed

package-lock.json

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

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
"format:check": "prettier --check ."
3434
},
3535
"dependencies": {
36-
"@reachfive/identity-core": "^1.36.0",
36+
"@captchafox/react": "^1.9.0",
37+
"@reachfive/identity-core": "^1.37.0",
3738
"buffer": "^6.0.3",
3839
"char-info": "0.3.2",
3940
"class-variance-authority": "^0.7.1",

src/components/captcha.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { CaptchaFoxInstance, CaptchaFox as CaptchaFoxWidget } from '@captchafox/react';
2+
import React, { ComponentType, useRef } from 'react';
3+
4+
import styled from 'styled-components';
5+
import CaptchaFox, { CaptchaFoxConf } from './captchaFox';
6+
import ReCaptcha, { RecaptchaAction, ReCaptchaConf } from './reCaptcha';
7+
8+
const StyledCaptchaFoxWidget = styled(CaptchaFoxWidget)`
9+
margin-bottom: ${props => props.theme.spacing}px;
10+
`;
11+
12+
export type WithCaptchaProps<T> = T & Partial<ReCaptchaConf & CaptchaFoxConf>;
13+
14+
export type WithCaptchaToken<T> = T & { captchaToken?: string };
15+
16+
export type CaptchaValues = {
17+
handler: <T, R>(data: T, callback: (data: T) => Promise<R>) => Promise<R>;
18+
Captcha?: ComponentType;
19+
};
20+
21+
const defaultHandler = <T, R>(data: T, callback: (data: T) => Promise<R>) => callback(data);
22+
23+
export const CaptchaContext = React.createContext<CaptchaValues>({
24+
handler: defaultHandler,
25+
});
26+
27+
export const useCaptcha = () => {
28+
return React.useContext(CaptchaContext);
29+
};
30+
31+
export type CaptchaProviderProps = WithCaptchaProps<{
32+
children: React.ReactNode;
33+
action: RecaptchaAction;
34+
}>;
35+
36+
export const CaptchaProvider = ({ children, action, ...options }: CaptchaProviderProps) => {
37+
const captchaFoxInstanceRef = useRef<CaptchaFoxInstance>(null);
38+
39+
if (options.recaptcha_enabled && options.recaptcha_site_key) {
40+
const handler = <T, R>(data: T, callback: (data: T) => Promise<R>) =>
41+
ReCaptcha.handle(
42+
data,
43+
{ recaptcha_enabled: true, recaptcha_site_key: options.recaptcha_site_key! },
44+
callback,
45+
action
46+
);
47+
48+
return <CaptchaContext.Provider value={{ handler }}>{children}</CaptchaContext.Provider>;
49+
}
50+
51+
if (options.captchaFoxEnabled && options.captchaFoxSiteKey) {
52+
const handler = async <T, R>(data: T, callback: (data: T) => Promise<R>) =>
53+
CaptchaFox.handle(data, captchaFoxInstanceRef.current, callback);
54+
55+
return (
56+
<CaptchaContext.Provider
57+
value={{
58+
handler,
59+
Captcha: () => (
60+
<StyledCaptchaFoxWidget
61+
ref={captchaFoxInstanceRef}
62+
sitekey={options.captchaFoxSiteKey!}
63+
mode={options.captchaFoxMode ?? 'hidden'}
64+
className="[&_.cf-button]:!max-w-full"
65+
/>
66+
),
67+
}}
68+
>
69+
{children}
70+
</CaptchaContext.Provider>
71+
);
72+
}
73+
74+
return (
75+
<CaptchaContext.Provider
76+
value={{
77+
handler: defaultHandler,
78+
}}
79+
>
80+
{children}
81+
</CaptchaContext.Provider>
82+
);
83+
};

src/components/captchaFox.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { AppError } from '@/helpers/errors';
2+
import { CaptchaFoxInstance } from '@captchafox/react';
3+
import type { WidgetDisplayMode } from '@captchafox/types';
4+
import { WithCaptchaToken } from './captcha';
5+
6+
export interface CaptchaFoxConf {
7+
/**
8+
* Boolean that specifies whether CaptchaFox is enabled or not.
9+
*/
10+
captchaFoxEnabled: boolean;
11+
/**
12+
* The SITE key that comes from your [CaptchaFox](https://docs.captchafox.com/getting-started#get-your-captchafox-keys) setup.
13+
* This must be paired with the appropriate secret key that you received when setting up CaptchaFox.
14+
*/
15+
captchaFoxSiteKey: string;
16+
/**
17+
* Define how CaptchaFox is displayed (hidden|inline|popup)/ Default to hidden.
18+
*/
19+
captchaFoxMode?: WidgetDisplayMode;
20+
}
21+
22+
export default class CaptchaFox {
23+
static handle = async <T, R = {}>(
24+
data: T,
25+
instance: CaptchaFoxInstance | null,
26+
callback: (data: WithCaptchaToken<T>) => Promise<R>
27+
) => {
28+
try {
29+
const captchaToken = await instance?.execute();
30+
return callback({ ...data, captchaToken, captchaProvider: 'captchafox' });
31+
} catch (_error) {
32+
return Promise.reject({
33+
errorId: '',
34+
error: 'CaptchaFox error',
35+
errorDescription: 'CaptchaFox Error',
36+
errorMessageKey: 'captchaFox.error',
37+
} satisfies AppError);
38+
}
39+
};
40+
}

src/components/form/formComponent.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { isAppError } from '../../helpers/errors';
1010
import { logError } from '../../helpers/logger';
1111
import { useDebounceCallback } from '../../helpers/useDebounceCallback';
1212
import { type Config } from '../../types';
13+
import { useCaptcha } from '../captcha';
1314
import { ErrorText, MutedText } from '../miscComponent';
1415
import { PrimaryButton } from './buttonComponent';
1516
import type { Field, FieldCreator, FieldValue } from './fieldCreator';
@@ -118,6 +119,7 @@ export function createForm<Model extends Record<PropertyKey, unknown> = {}, P =
118119
const config = useConfig();
119120
const i18n = useI18n();
120121
const client = useReachfive();
122+
const { Captcha, handler: captchaHandler } = useCaptcha();
121123

122124
const {
123125
beforeSubmit,
@@ -346,7 +348,7 @@ export function createForm<Model extends Record<PropertyKey, unknown> = {}, P =
346348
event.preventDefault();
347349

348350
await processData(processedData => {
349-
handler(processedData)
351+
captchaHandler(processedData, handler)
350352
.then(handleSuccess)
351353
.catch((err: unknown) => {
352354
(typeof skipError === 'function' ? skipError(err) : skipError === true)
@@ -371,6 +373,7 @@ export function createForm<Model extends Record<PropertyKey, unknown> = {}, P =
371373
})
372374
: field.staticContent
373375
)}
376+
{Captcha && <Captcha />}
374377
{SubmitComponent ? (
375378
<SubmitComponent
376379
disabled={isLoading}

src/components/form/passwordSignupFormComponent.tsx

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,11 @@ import { createForm } from './formComponent';
77
import { UserAgreementStyle } from './formControlsComponent';
88
import { buildFormFields, type Field } from './formFieldFactory';
99

10+
import { CaptchaProvider, WithCaptchaProps, type WithCaptchaToken } from '../../components/captcha';
1011
import { snakeCaseProperties } from '../../helpers/transformObjectProperties';
1112
import { isValued } from '../../helpers/utils';
1213
import { MarkdownContent } from '../miscComponent';
13-
import ReCaptcha, {
14-
extractCaptchaTokenFromData,
15-
importGoogleRecaptchaScript,
16-
type WithCaptchaToken,
17-
} from '../reCaptcha';
14+
import { extractCaptchaTokenFromData, importGoogleRecaptchaScript } from '../reCaptcha';
1815

1916
import { useConfig } from '../../contexts/config';
2017
import { useReachfive } from '../../contexts/reachfive';
@@ -33,8 +30,6 @@ export interface PasswordSignupFormProps {
3330
beforeSignup?: <T>(param: T) => T;
3431
canShowPassword?: boolean;
3532
phoneNumberOptions?: PhoneNumberOptions;
36-
recaptcha_enabled?: boolean;
37-
recaptcha_site_key?: string;
3833
redirectUrl?: string;
3934
returnToAfterEmailConfirmation?: string;
4035
showLabels?: boolean;
@@ -57,14 +52,17 @@ export const PasswordSignupForm = ({
5752
phoneNumberOptions,
5853
recaptcha_enabled = false,
5954
recaptcha_site_key,
55+
captchaFoxEnabled = false,
56+
captchaFoxSiteKey,
57+
captchaFoxMode = 'hidden',
6058
redirectUrl,
6159
returnToAfterEmailConfirmation,
6260
showLabels,
6361
signupFields = ['given_name', 'family_name', 'email', 'password', 'password_confirmation'],
6462
userAgreement,
6563
onError = (() => {}) as OnError,
6664
onSuccess = (() => {}) satisfies OnSuccess,
67-
}: PasswordSignupFormProps) => {
65+
}: WithCaptchaProps<PasswordSignupFormProps>) => {
6866
const coreClient = useReachfive();
6967
const config = useConfig();
7068
const [blacklist, setBlacklist] = useState<string[]>([]);
@@ -128,28 +126,30 @@ export const PasswordSignupForm = ({
128126
: fields;
129127

130128
return (
131-
<SignupForm
132-
fields={allFields}
133-
showLabels={showLabels}
134-
beforeSubmit={beforeSignup}
135-
onFieldChange={refreshBlacklist}
136-
sharedProps={{
137-
blacklist,
138-
...phoneNumberOptions,
139-
}}
140-
handler={(data: SignupParams['data']) =>
141-
ReCaptcha.handle(
142-
data,
143-
{ recaptcha_enabled, recaptcha_site_key },
144-
callback,
145-
'signup'
146-
)
147-
}
148-
onSuccess={authResult => {
149-
onSuccess({ name: 'signup', authResult });
150-
}}
151-
onError={onError}
152-
/>
129+
<CaptchaProvider
130+
recaptcha_enabled={recaptcha_enabled}
131+
recaptcha_site_key={recaptcha_site_key}
132+
captchaFoxEnabled={captchaFoxEnabled}
133+
captchaFoxSiteKey={captchaFoxSiteKey}
134+
captchaFoxMode={captchaFoxMode}
135+
action="signup"
136+
>
137+
<SignupForm
138+
fields={allFields}
139+
showLabels={showLabels}
140+
beforeSubmit={beforeSignup}
141+
onFieldChange={refreshBlacklist}
142+
sharedProps={{
143+
blacklist,
144+
...phoneNumberOptions,
145+
}}
146+
handler={callback}
147+
onSuccess={authResult => {
148+
onSuccess({ name: 'signup', authResult });
149+
}}
150+
onError={onError}
151+
/>
152+
</CaptchaProvider>
153153
);
154154
};
155155

0 commit comments

Comments
 (0)