feat(auth): implement withCaptcha HOC for consistent reCAPTCHA (#3175)

* refactor(searchStore): reformat filterMap function parameters (#3166)

- Reformat the parameters of the filterMap function for better readability.
- Comment out the fetchSessions call in clearSearch method to avoid unnecessary session fetch.

* Increment frontend chart version (#3167)

Co-authored-by: GitHub Action <action@github.com>

* refactor(chalice): cleaned code
fix(chalice): fixed session-search-pg sortKey issue
fix(chalice): fixed CH-query-formatter to handle special chars
fix(chalice): fixed /ids response

* feat(auth): implement withCaptcha HOC for consistent reCAPTCHA

This commit refactors the reCAPTCHA implementation across the application
by introducing a Higher Order Component (withCaptcha) that encapsulates
captcha verification logic. The changes:

- Create a reusable withCaptcha HOC in withRecaptcha.tsx
- Refactor Login, ResetPasswordRequest, and CreatePassword components
- Extract SSOLogin into a separate component
- Improve error handling and user feedback
- Standardize loading and verification states across forms
- Make captcha implementation more maintainable and consistent

---------

Co-authored-by: Mehdi Osman <estradino@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Taha Yassine Kraiem <tahayk2@gmail.com>
This commit is contained in:
Shekar Siri 2025-03-19 11:37:50 +01:00 committed by GitHub
parent 2cb33d7894
commit 605fa96a34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 444 additions and 198 deletions

View file

@ -1,52 +1,80 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import ReCAPTCHA from 'react-google-recaptcha';
import { Form, Input, Loader, Icon, Message } from 'UI';
import { Button } from 'antd';
import { validatePassword } from 'App/validate';
import { PASSWORD_POLICY } from 'App/constants';
import stl from './forgotPassword.module.css';
import { useTranslation } from 'react-i18next';
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
const recaptchaRef = React.createRef();
const ERROR_DONT_MATCH = (t) => t("Passwords don't match.");
const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
const { CAPTCHA_SITE_KEY } = window.env;
interface Props {
params: any;
}
function CreatePassword(props: Props) {
function CreatePassword(props: Props & WithCaptchaProps) {
const { t } = useTranslation();
const { params } = props;
const { userStore } = useStore();
const { loading } = userStore;
const { resetPassword } = userStore;
const [error, setError] = React.useState<string | null>(null);
const [validationError, setValidationError] = React.useState<string | null>(
null,
);
const [updated, setUpdated] = React.useState(false);
const [passwordRepeat, setPasswordRepeat] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [updated, setUpdated] = useState(false);
const [passwordRepeat, setPasswordRepeat] = useState('');
const [password, setPassword] = useState('');
const pass = params.get('pass');
const invitation = params.get('invitation');
const handleSubmit = () => {
if (!validatePassword(password)) {
const { submitWithCaptcha, isVerifyingCaptcha, resetCaptcha } = props;
const handleSubmit = (token?: string) => {
if (!validatePassword(password) || !token) {
return;
}
void resetPassword({ invitation, pass, password });
resetPassword({
invitation,
pass,
password,
'g-recaptcha-response': token
})
.then(() => {
setUpdated(true);
})
.catch((err) => {
setError(err.message);
// Reset captcha for the next attempt
resetCaptcha();
});
};
const onSubmit = (e: any) => {
e.preventDefault();
if (CAPTCHA_ENABLED && recaptchaRef.current) {
recaptchaRef.current.execute();
} else if (!CAPTCHA_ENABLED) {
handleSubmit();
const onSubmit = () => {
// Validate before attempting captcha verification
if (!validatePassword(password) || password !== passwordRepeat) {
setValidationError(
password !== passwordRepeat
? ERROR_DONT_MATCH(t)
: PASSWORD_POLICY(t)
);
return;
}
// Reset any previous errors
setError(null);
setValidationError(null);
submitWithCaptcha({ pass, invitation, password })
.then((data) => {
handleSubmit(data['g-recaptcha-response']);
})
.catch((error) => {
console.error('Captcha verification failed:', error);
// The component will handle showing appropriate messages
});
};
const write = (e: any) => {
@ -63,7 +91,7 @@ function CreatePassword(props: Props) {
} else {
setValidationError(null);
}
}, [passwordRepeat, password]);
}, [passwordRepeat, password, t]);
return (
<Form
@ -73,19 +101,8 @@ function CreatePassword(props: Props) {
>
{!error && (
<>
<Loader loading={loading}>
<Loader loading={loading || isVerifyingCaptcha}>
<div data-hidden={updated} className="w-full">
{CAPTCHA_ENABLED && (
<div className={stl.recaptcha}>
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey={CAPTCHA_SITE_KEY}
onChange={(token: any) => handleSubmit(token)}
/>
</div>
)}
<Form.Field>
<label>{t('New password')}</label>
<Input
@ -132,10 +149,15 @@ function CreatePassword(props: Props) {
<Button
htmlType="submit"
type="primary"
loading={loading}
loading={loading || isVerifyingCaptcha}
disabled={loading || isVerifyingCaptcha || validationError !== null}
className="w-full mt-4"
>
{t('Create')}
{isVerifyingCaptcha
? t('Verifying...')
: loading
? t('Processing...')
: t('Create')}
</Button>
)}
</>
@ -153,4 +175,4 @@ function CreatePassword(props: Props) {
);
}
export default observer(CreatePassword);
export default withCaptcha(observer(CreatePassword));

View file

@ -1,24 +1,26 @@
import React from 'react';
import React, { useState } from 'react';
import { Loader, Icon } from 'UI';
import ReCAPTCHA from 'react-google-recaptcha';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import { Form, Input, Button, Typography } from 'antd';
import { SquareArrowOutUpRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
function ResetPasswordRequest() {
interface Props {
}
function ResetPasswordRequest(props: Props & WithCaptchaProps) {
const { t } = useTranslation();
const { userStore } = useStore();
const { loading } = userStore;
const { requestResetPassword } = userStore;
const recaptchaRef = React.createRef();
const [requested, setRequested] = React.useState(false);
const [email, setEmail] = React.useState('');
const [error, setError] = React.useState(null);
const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
const { CAPTCHA_SITE_KEY } = window.env;
const [smtpError, setSmtpError] = React.useState<boolean>(false);
const [requested, setRequested] = useState(false);
const [email, setEmail] = useState('');
const [error, setError] = useState(null);
const [smtpError, setSmtpError] = useState<boolean>(false);
const { submitWithCaptcha, isVerifyingCaptcha, resetCaptcha } = props;
const write = (e: any) => {
const { name, value } = e.target;
@ -26,21 +28,22 @@ function ResetPasswordRequest() {
};
const onSubmit = () => {
// e.preventDefault();
if (CAPTCHA_ENABLED && recaptchaRef.current) {
recaptchaRef.current.execute();
} else if (!CAPTCHA_ENABLED) {
handleSubmit();
// Validation check
if (!email || email.trim() === '') {
return;
}
submitWithCaptcha({ email: email.trim() })
.then((data) => {
handleSubmit(data['g-recaptcha-response']);
})
.catch((error: any) => {
console.error('Captcha verification failed:', error);
});
};
const handleSubmit = (token?: any) => {
if (
CAPTCHA_ENABLED &&
recaptchaRef.current &&
(token === null || token === undefined)
)
return;
const handleSubmit = (token?: string) => {
if (!token) return;
setError(null);
requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token })
@ -50,29 +53,21 @@ function ResetPasswordRequest() {
}
setError(err.message);
// Reset captcha for the next attempt
resetCaptcha();
})
.finally(() => {
setRequested(true);
});
};
return (
<Form
onFinish={onSubmit}
style={{ minWidth: '50%' }}
className="flex flex-col"
>
<Loader loading={false}>
{CAPTCHA_ENABLED && (
<div className="flex justify-center">
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
data-hidden={requested}
sitekey={CAPTCHA_SITE_KEY}
onChange={(token: any) => handleSubmit(token)}
/>
</div>
)}
<Loader loading={loading || isVerifyingCaptcha}>
{!requested && (
<>
<Form.Item>
@ -92,10 +87,14 @@ function ResetPasswordRequest() {
<Button
type="primary"
htmlType="submit"
loading={loading}
disabled={loading}
loading={loading || isVerifyingCaptcha}
disabled={loading || isVerifyingCaptcha}
>
{t('Email Password Reset Link')}
{isVerifyingCaptcha
? t('Verifying...')
: loading
? t('Processing...')
: t('Email Password Reset Link')}
</Button>
</>
)}
@ -146,4 +145,4 @@ function ResetPasswordRequest() {
);
}
export default observer(ResetPasswordRequest);
export default withCaptcha(observer(ResetPasswordRequest));

View file

@ -1,23 +1,18 @@
import withPageTitle from 'HOCs/withPageTitle';
import cn from 'classnames';
import React, { useEffect, useMemo, useRef, useState } from 'react';
// Consider using a different approach for titles in functional components
import ReCAPTCHA from 'react-google-recaptcha';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { observer } from 'mobx-react-lite';
import { toast } from 'react-toastify';
import { ENTERPRISE_REQUEIRED } from 'App/constants';
import { forgotPassword, signup } from 'App/routes';
import { Icon, Link, Loader, Tooltip } from 'UI';
import { Icon, Link, Loader } from 'UI';
import { Button, Form, Input } from 'antd';
import Copyright from 'Shared/Copyright';
import stl from './login.module.css';
import { useTranslation } from 'react-i18next';
import { useStore } from 'App/mstore';
import LanguageSwitcher from '../LanguageSwitcher';
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
import SSOLogin from './SSOLogin';
const FORGOT_PASSWORD = forgotPassword();
const SIGNUP_ROUTE = signup();
@ -26,14 +21,15 @@ interface LoginProps {
location: Location;
}
const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
function Login({ location }: LoginProps) {
function Login({
location,
submitWithCaptcha,
isVerifyingCaptcha,
resetCaptcha,
}: LoginProps & WithCaptchaProps) {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// const CAPTCHA_ENABLED = useMemo(() => window.env.CAPTCHA_ENABLED === 'true', []);
const recaptchaRef = useRef<ReCAPTCHA>(null);
const { loginStore, userStore } = useStore();
const { errors } = userStore.loginRequest;
const { loading } = loginStore;
@ -49,7 +45,6 @@ function Login({ location }: LoginProps) {
}, [authDetails]);
useEffect(() => {
// void fetchTenants();
const jwt = params.get('jwt');
const spotJwt = params.get('spotJwt');
if (spotJwt) {
@ -108,32 +103,36 @@ function Login({ location }: LoginProps) {
if (resp) {
userStore.syntheticLogin(resp);
setJwt({ jwt: resp.jwt, spotJwt: resp.spotJwt ?? null });
handleSpotLogin(resp.spotJwt);
if (resp.spotJwt) {
handleSpotLogin(resp.spotJwt);
}
}
})
.catch((e) => {
userStore.syntheticLoginError(e);
resetCaptcha();
});
};
const onSubmit = () => {
if (CAPTCHA_ENABLED && recaptchaRef.current) {
recaptchaRef.current.execute();
} else if (!CAPTCHA_ENABLED) {
handleSubmit();
if (!email || !password) {
return;
}
};
const ssoLink =
window !== window.top
? `${window.location.origin}/api/sso/saml2?iFrame=true`
: `${window.location.origin}/api/sso/saml2`;
submitWithCaptcha({ email: email.trim(), password })
.then((data) => {
handleSubmit(data['g-recaptcha-response']);
})
.catch((error: any) => {
console.error('Captcha error:', error);
});
};
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center">
<div className="m-10 ">
<img src="/assets/logo.svg" width={200} />
<img src="/assets/logo.svg" width={200} alt="Company Logo" />
</div>
<div className="border rounded-lg bg-white shadow-sm">
<h2 className="text-center text-2xl font-medium mb-6 border-b p-5 w-full">
@ -145,15 +144,7 @@ function Login({ location }: LoginProps) {
className={cn('flex items-center justify-center flex-col')}
style={{ width: '350px' }}
>
<Loader loading={loading}>
{CAPTCHA_ENABLED && (
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey={window.env.CAPTCHA_SITE_KEY}
onChange={(token) => handleSubmit(token)}
/>
)}
<Loader loading={loading || isVerifyingCaptcha}>
<div style={{ width: '350px' }} className="px-8">
<Form.Item>
<label>{t('Email Address')}</label>
@ -186,8 +177,8 @@ function Login({ location }: LoginProps) {
</Loader>
{errors && errors.length ? (
<div className="px-8 my-2 w-full">
{errors.map((error) => (
<div className="flex items-center bg-red-lightest rounded p-3">
{errors.map((error, index) => (
<div key={index} className="flex items-center bg-red-lightest rounded p-3">
<Icon name="info" color="red" size="20" />
<span className="color-red ml-2">
{error}
@ -204,8 +195,14 @@ function Login({ location }: LoginProps) {
className="mt-2 w-full text-center rounded-lg"
type="primary"
htmlType="submit"
loading={loading || isVerifyingCaptcha}
disabled={loading || isVerifyingCaptcha}
>
{t('Login')}
{isVerifyingCaptcha
? t('Verifying...')
: loading
? t('Logging in...')
: t('Login')}
</Button>
<div className="my-8 flex justify-center items-center flex-wrap">
@ -219,63 +216,12 @@ function Login({ location }: LoginProps) {
</div>
</Form>
<div className={cn(stl.sso, 'py-2 flex flex-col items-center')}>
{authDetails.sso ? (
<a href={ssoLink} rel="noopener noreferrer">
<Button type="text" htmlType="submit">
{`${t('Login with SSO')} ${
authDetails.ssoProvider
? `(${authDetails.ssoProvider})`
: ''
}`}
</Button>
</a>
) : (
<Tooltip
delay={0}
title={
<div className="text-center">
{authDetails.edition === 'ee' ? (
<span>
{t('SSO has not been configured.')}
<br />
{t('Please reach out to your admin.')}
</span>
) : (
ENTERPRISE_REQUEIRED(t)
)}
</div>
}
placement="top"
>
<Button
type="text"
htmlType="submit"
className="pointer-events-none opacity-30"
>
{`${t('Login with SSO')} ${
authDetails.ssoProvider
? `(${authDetails.ssoProvider})`
: ''
}`}
</Button>
</Tooltip>
)}
</div>
</div>
<div
className={cn('flex items-center w-96 justify-center my-8', {
'!hidden': !authDetails?.enforceSSO,
})}
>
<a href={ssoLink} rel="noopener noreferrer">
<Button type="primary">
{`${t('Login with SSO')} ${
authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
}`}
</Button>
</a>
<SSOLogin authDetails={authDetails} />
</div>
{authDetails?.enforceSSO && (
<SSOLogin authDetails={authDetails} enforceSSO={true} />
)}
</div>
</div>
@ -287,4 +233,6 @@ function Login({ location }: LoginProps) {
);
}
export default withPageTitle('Login - OpenReplay')(observer(Login));
export default withPageTitle('Login - OpenReplay')(
withCaptcha(observer(Login))
);

View file

@ -0,0 +1,75 @@
import React from 'react';
import cn from 'classnames';
import { Button, Tooltip } from 'antd';
import { useTranslation } from 'react-i18next';
import { ENTERPRISE_REQUEIRED } from 'App/constants';
import stl from './login.module.css';
interface SSOLoginProps {
authDetails: any;
enforceSSO?: boolean;
}
const SSOLogin = ({ authDetails, enforceSSO = false }: SSOLoginProps) => {
const { t } = useTranslation();
const getSSOLink = () =>
window !== window.top
? `${window.location.origin}/api/sso/saml2?iFrame=true`
: `${window.location.origin}/api/sso/saml2`;
const ssoLink = getSSOLink();
const ssoButtonText = `${t('Login with SSO')} ${authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
}`;
if (enforceSSO) {
return (
<div className={cn('flex items-center w-96 justify-center my-8')}>
<a href={ssoLink} rel="noopener noreferrer">
<Button type="primary">{ssoButtonText}</Button>
</a>
</div>
);
}
return (
<div className={cn(stl.sso, 'py-2 flex flex-col items-center')}>
{authDetails.sso ? (
<a href={ssoLink} rel="noopener noreferrer">
<Button type="text" htmlType="submit">
{ssoButtonText}
</Button>
</a>
) : (
<Tooltip
title={
<div className="text-center">
{authDetails.edition === 'ee' ? (
<span>
{t('SSO has not been configured.')}
<br />
{t('Please reach out to your admin.')}
</span>
) : (
ENTERPRISE_REQUEIRED(t)
)}
</div>
}
placement="top"
>
<span className="cursor-not-allowed">
<Button
type="text"
htmlType="submit"
disabled={true}
>
{ssoButtonText}
</Button>
</span>
</Tooltip>
)}
</div>
);
};
export default SSOLogin;

View file

@ -245,8 +245,8 @@ class UserStore {
const errStr = err.errors[0]
? err.errors[0].includes('already exists')
? this.t(
"This email is already linked to an account or team on OpenReplay and can't be used again.",
)
"This email is already linked to an account or team on OpenReplay and can't be used again.",
)
: err.errors[0]
: this.t('Error saving user');
toast.error(errStr);
@ -416,9 +416,9 @@ class UserStore {
this.jwt = data.jwt;
this.spotJwt = data.spotJwt;
});
} catch (error) {
toast.error(this.t('Error resetting your password; please try again'));
return error.response;
} catch (e) {
toast.error(e.message || this.t('Error resetting your password; please try again'));
throw e;
} finally {
runInAction(() => {
this.loading = false;

View file

@ -138,26 +138,9 @@ export default class UserService {
}
async resetPassword(data: any) {
try {
const response = await this.client.post('/password/reset', data);
const responseData = await response.json();
if (responseData.errors) {
throw new Error(
responseData.errors[0] || 'An unexpected error occurred.',
);
}
return responseData || {};
} catch (error: any) {
if (error.response) {
const errorData = await error.response.json();
const errorMessage = errorData.errors
? errorData.errors[0]
: 'An unexpected error occurred.';
throw new Error(errorMessage);
}
throw new Error('An unexpected error occurred.');
}
const response = await this.client.post('/password/reset', data);
const responseData = await response.json();
return responseData || {};
}
async requestResetPassword(data: any) {

View file

@ -0,0 +1,219 @@
import React, { useState, useRef, ComponentType, ReactNode, useCallback, useEffect, useLayoutEffect } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { toast } from "react-toastify";
// Define a more specific type for submission data
export interface SubmissionData {
[key: string]: any;
}
export interface WithCaptchaProps {
submitWithCaptcha: (data: SubmissionData) => Promise<any>;
hasCaptchaError: boolean;
isVerifyingCaptcha: boolean;
resetCaptcha: () => void;
}
export interface WithCaptchaOptions {
position?: 'visible' | 'hidden';
errorMessage?: string;
theme?: 'light' | 'dark';
size?: 'normal' | 'compact' | 'invisible';
}
// Safely get environment variables with fallbacks
const getCaptchaConfig = () => {
const enabled = typeof window !== 'undefined' &&
window.env?.CAPTCHA_ENABLED === 'true';
const siteKey = typeof window !== 'undefined' ?
window.env?.CAPTCHA_SITE_KEY || '' : '';
return { enabled, siteKey };
};
/**
* Higher-Order Component that adds reCAPTCHA functionality to a form component
*
* @param WrappedComponent The component to wrap with CAPTCHA functionality
* @param options Configuration options for the CAPTCHA behavior
* @returns A new component with CAPTCHA capabilities
*/
const withCaptcha = <P extends object>(
WrappedComponent: ComponentType<P & WithCaptchaProps>,
options: WithCaptchaOptions = {}
): React.FC<P> => {
// Default options
const {
position = 'hidden',
errorMessage = 'Please complete the CAPTCHA verification',
theme = 'light',
size = 'invisible'
} = options;
const WithCaptchaComponent: React.FC<P> = (props: P) => {
const { enabled: CAPTCHA_ENABLED, siteKey: CAPTCHA_SITE_KEY } = getCaptchaConfig();
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [isVerifyingCaptcha, setIsVerifyingCaptcha] = useState<boolean>(false);
const [tokenExpired, setTokenExpired] = useState<boolean>(false);
const recaptchaRef = useRef<ReCAPTCHA>(null);
// Reset token when expired
useEffect(() => {
if (tokenExpired) {
setCaptchaToken(null);
setTokenExpired(false);
}
}, [tokenExpired]);
// Handle token expiration
const onCaptchaExpired = useCallback(() => {
setTokenExpired(true);
if (CAPTCHA_ENABLED) {
toast.warning('CAPTCHA verification expired. Please verify again.');
}
}, [CAPTCHA_ENABLED]);
// Handle token change
let onCaptchaChange = (token: string | null) => {
console.log('Standard captcha callback received token:', !!token);
setCaptchaToken(token);
setTokenExpired(false);
};
// Reset captcha manually
const resetCaptcha = useCallback(() => {
recaptchaRef.current?.reset();
setCaptchaToken(null);
}, []);
// Submit with captcha verification
const submitWithCaptcha = useCallback(
(data: SubmissionData): Promise<any> => {
return new Promise((resolve, reject) => {
if (!CAPTCHA_ENABLED) {
// CAPTCHA not enabled, resolve with original data
resolve(data);
return;
}
setIsVerifyingCaptcha(true);
// Special handling for invisible reCAPTCHA
if (size === 'invisible') {
// Create a direct token handler function
const handleToken = (receivedToken: string | null) => {
console.log('reCAPTCHA token received:', !!receivedToken);
if (receivedToken) {
// We have a token, resolve the promise
const dataWithCaptcha = {
...data,
'g-recaptcha-response': receivedToken
};
resolve(dataWithCaptcha);
// Reset for next use
setTimeout(() => {
recaptchaRef.current?.reset();
setIsVerifyingCaptcha(false);
}, 100);
}
};
// Set up a callback directly on the reCAPTCHA ref
if (recaptchaRef.current) {
console.log('Executing invisible reCAPTCHA');
// Execute the reCAPTCHA challenge
recaptchaRef.current.executeAsync()
.then((token: string | null) => {
handleToken(token);
})
.catch((error: any) => {
console.error('reCAPTCHA execution failed:', error);
setIsVerifyingCaptcha(false);
reject(new Error('CAPTCHA verification failed'));
});
// Set a timeout in case the promise doesn't resolve
setTimeout(() => {
if (isVerifyingCaptcha) {
console.log('reCAPTCHA verification timed out');
setIsVerifyingCaptcha(false);
toast.error(errorMessage || 'Verification timed out. Please try again.');
reject(new Error('CAPTCHA verification timeout'));
}
}, 5000);
} else {
console.error('reCAPTCHA ref not available');
setIsVerifyingCaptcha(false);
reject(new Error('CAPTCHA component not initialized'));
}
} else if (captchaToken) {
// Standard reCAPTCHA with token already available
const dataWithCaptcha = {
...data,
'g-recaptcha-response': captchaToken
};
resolve(dataWithCaptcha);
recaptchaRef.current?.reset();
setCaptchaToken(null);
setIsVerifyingCaptcha(false);
} else {
// Standard reCAPTCHA but no token yet
toast.error(errorMessage || 'Please complete the CAPTCHA verification');
reject(new Error('CAPTCHA verification required'));
setIsVerifyingCaptcha(false);
}
});
},
[CAPTCHA_ENABLED, captchaToken, errorMessage, size, isVerifyingCaptcha]
);
const hasCaptchaError = !captchaToken && CAPTCHA_ENABLED === true;
return (
<>
{CAPTCHA_ENABLED && (
<div className={position === 'hidden' ? 'sr-only' : 'mb-4'}>
<ReCAPTCHA
ref={recaptchaRef}
sitekey={CAPTCHA_SITE_KEY}
onChange={onCaptchaChange}
onExpired={onCaptchaExpired}
theme={theme}
size={size}
/>
{hasCaptchaError && (
<div className="text-red-500 text-sm mt-1">
{errorMessage}
</div>
)}
</div>
)}
<WrappedComponent
{...props}
submitWithCaptcha={submitWithCaptcha}
hasCaptchaError={hasCaptchaError}
isVerifyingCaptcha={isVerifyingCaptcha}
resetCaptcha={resetCaptcha}
/>
</>
);
};
// Display name for debugging
const wrappedComponentName =
WrappedComponent.displayName ||
WrappedComponent.name ||
'Component';
WithCaptchaComponent.displayName = `WithCaptcha(${wrappedComponentName})`;
return WithCaptchaComponent;
};
export default withCaptcha;

View file

@ -18,4 +18,4 @@ version: 0.1.10
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.22.0"
AppVersion: "v1.22.1"