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 { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import ReCAPTCHA from 'react-google-recaptcha';
import { Form, Input, Loader, Icon, Message } from 'UI'; import { Form, Input, Loader, Icon, Message } from 'UI';
import { Button } from 'antd'; import { Button } from 'antd';
import { validatePassword } from 'App/validate'; import { validatePassword } from 'App/validate';
import { PASSWORD_POLICY } from 'App/constants'; import { PASSWORD_POLICY } from 'App/constants';
import stl from './forgotPassword.module.css';
import { useTranslation } from 'react-i18next'; 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 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 { interface Props {
params: any; params: any;
} }
function CreatePassword(props: Props) {
function CreatePassword(props: Props & WithCaptchaProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { params } = props; const { params } = props;
const { userStore } = useStore(); const { userStore } = useStore();
const { loading } = userStore; const { loading } = userStore;
const { resetPassword } = userStore; const { resetPassword } = userStore;
const [error, setError] = React.useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [validationError, setValidationError] = React.useState<string | null>( const [validationError, setValidationError] = useState<string | null>(null);
null, const [updated, setUpdated] = useState(false);
); const [passwordRepeat, setPasswordRepeat] = useState('');
const [updated, setUpdated] = React.useState(false); const [password, setPassword] = useState('');
const [passwordRepeat, setPasswordRepeat] = React.useState('');
const [password, setPassword] = React.useState('');
const pass = params.get('pass'); const pass = params.get('pass');
const invitation = params.get('invitation'); const invitation = params.get('invitation');
const handleSubmit = () => { const { submitWithCaptcha, isVerifyingCaptcha, resetCaptcha } = props;
if (!validatePassword(password)) {
const handleSubmit = (token?: string) => {
if (!validatePassword(password) || !token) {
return; 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) => { const onSubmit = () => {
e.preventDefault(); // Validate before attempting captcha verification
if (CAPTCHA_ENABLED && recaptchaRef.current) { if (!validatePassword(password) || password !== passwordRepeat) {
recaptchaRef.current.execute(); setValidationError(
} else if (!CAPTCHA_ENABLED) { password !== passwordRepeat
handleSubmit(); ? 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) => { const write = (e: any) => {
@ -63,7 +91,7 @@ function CreatePassword(props: Props) {
} else { } else {
setValidationError(null); setValidationError(null);
} }
}, [passwordRepeat, password]); }, [passwordRepeat, password, t]);
return ( return (
<Form <Form
@ -73,19 +101,8 @@ function CreatePassword(props: Props) {
> >
{!error && ( {!error && (
<> <>
<Loader loading={loading}> <Loader loading={loading || isVerifyingCaptcha}>
<div data-hidden={updated} className="w-full"> <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> <Form.Field>
<label>{t('New password')}</label> <label>{t('New password')}</label>
<Input <Input
@ -132,10 +149,15 @@ function CreatePassword(props: Props) {
<Button <Button
htmlType="submit" htmlType="submit"
type="primary" type="primary"
loading={loading} loading={loading || isVerifyingCaptcha}
disabled={loading || isVerifyingCaptcha || validationError !== null}
className="w-full mt-4" className="w-full mt-4"
> >
{t('Create')} {isVerifyingCaptcha
? t('Verifying...')
: loading
? t('Processing...')
: t('Create')}
</Button> </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 { Loader, Icon } from 'UI';
import ReCAPTCHA from 'react-google-recaptcha';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { Form, Input, Button, Typography } from 'antd'; import { Form, Input, Button, Typography } from 'antd';
import { SquareArrowOutUpRight } from 'lucide-react'; import { SquareArrowOutUpRight } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
function ResetPasswordRequest() { interface Props {
}
function ResetPasswordRequest(props: Props & WithCaptchaProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { userStore } = useStore(); const { userStore } = useStore();
const { loading } = userStore; const { loading } = userStore;
const { requestResetPassword } = userStore; const { requestResetPassword } = userStore;
const recaptchaRef = React.createRef(); const [requested, setRequested] = useState(false);
const [requested, setRequested] = React.useState(false); const [email, setEmail] = useState('');
const [email, setEmail] = React.useState(''); const [error, setError] = useState(null);
const [error, setError] = React.useState(null); const [smtpError, setSmtpError] = useState<boolean>(false);
const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
const { CAPTCHA_SITE_KEY } = window.env; const { submitWithCaptcha, isVerifyingCaptcha, resetCaptcha } = props;
const [smtpError, setSmtpError] = React.useState<boolean>(false);
const write = (e: any) => { const write = (e: any) => {
const { name, value } = e.target; const { name, value } = e.target;
@ -26,21 +28,22 @@ function ResetPasswordRequest() {
}; };
const onSubmit = () => { const onSubmit = () => {
// e.preventDefault(); // Validation check
if (CAPTCHA_ENABLED && recaptchaRef.current) { if (!email || email.trim() === '') {
recaptchaRef.current.execute(); return;
} else if (!CAPTCHA_ENABLED) {
handleSubmit();
} }
submitWithCaptcha({ email: email.trim() })
.then((data) => {
handleSubmit(data['g-recaptcha-response']);
})
.catch((error: any) => {
console.error('Captcha verification failed:', error);
});
}; };
const handleSubmit = (token?: any) => { const handleSubmit = (token?: string) => {
if ( if (!token) return;
CAPTCHA_ENABLED &&
recaptchaRef.current &&
(token === null || token === undefined)
)
return;
setError(null); setError(null);
requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token }) requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token })
@ -50,29 +53,21 @@ function ResetPasswordRequest() {
} }
setError(err.message); setError(err.message);
// Reset captcha for the next attempt
resetCaptcha();
}) })
.finally(() => { .finally(() => {
setRequested(true); setRequested(true);
}); });
}; };
return ( return (
<Form <Form
onFinish={onSubmit} onFinish={onSubmit}
style={{ minWidth: '50%' }} style={{ minWidth: '50%' }}
className="flex flex-col" className="flex flex-col"
> >
<Loader loading={false}> <Loader loading={loading || isVerifyingCaptcha}>
{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>
)}
{!requested && ( {!requested && (
<> <>
<Form.Item> <Form.Item>
@ -92,10 +87,14 @@ function ResetPasswordRequest() {
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
loading={loading} loading={loading || isVerifyingCaptcha}
disabled={loading} disabled={loading || isVerifyingCaptcha}
> >
{t('Email Password Reset Link')} {isVerifyingCaptcha
? t('Verifying...')
: loading
? t('Processing...')
: t('Email Password Reset Link')}
</Button> </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 withPageTitle from 'HOCs/withPageTitle';
import cn from 'classnames'; import cn from 'classnames';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useState } from 'react';
// Consider using a different approach for titles in functional components
import ReCAPTCHA from 'react-google-recaptcha';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { ENTERPRISE_REQUEIRED } from 'App/constants';
import { forgotPassword, signup } from 'App/routes'; 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 { Button, Form, Input } from 'antd';
import Copyright from 'Shared/Copyright'; import Copyright from 'Shared/Copyright';
import stl from './login.module.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import LanguageSwitcher from '../LanguageSwitcher'; import LanguageSwitcher from '../LanguageSwitcher';
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
import SSOLogin from './SSOLogin';
const FORGOT_PASSWORD = forgotPassword(); const FORGOT_PASSWORD = forgotPassword();
const SIGNUP_ROUTE = signup(); const SIGNUP_ROUTE = signup();
@ -26,14 +21,15 @@ interface LoginProps {
location: Location; location: Location;
} }
const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true'; function Login({
location,
function Login({ location }: LoginProps) { submitWithCaptcha,
isVerifyingCaptcha,
resetCaptcha,
}: LoginProps & WithCaptchaProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
// const CAPTCHA_ENABLED = useMemo(() => window.env.CAPTCHA_ENABLED === 'true', []);
const recaptchaRef = useRef<ReCAPTCHA>(null);
const { loginStore, userStore } = useStore(); const { loginStore, userStore } = useStore();
const { errors } = userStore.loginRequest; const { errors } = userStore.loginRequest;
const { loading } = loginStore; const { loading } = loginStore;
@ -49,7 +45,6 @@ function Login({ location }: LoginProps) {
}, [authDetails]); }, [authDetails]);
useEffect(() => { useEffect(() => {
// void fetchTenants();
const jwt = params.get('jwt'); const jwt = params.get('jwt');
const spotJwt = params.get('spotJwt'); const spotJwt = params.get('spotJwt');
if (spotJwt) { if (spotJwt) {
@ -108,32 +103,36 @@ function Login({ location }: LoginProps) {
if (resp) { if (resp) {
userStore.syntheticLogin(resp); userStore.syntheticLogin(resp);
setJwt({ jwt: resp.jwt, spotJwt: resp.spotJwt ?? null }); setJwt({ jwt: resp.jwt, spotJwt: resp.spotJwt ?? null });
handleSpotLogin(resp.spotJwt); if (resp.spotJwt) {
handleSpotLogin(resp.spotJwt);
}
} }
}) })
.catch((e) => { .catch((e) => {
userStore.syntheticLoginError(e); userStore.syntheticLoginError(e);
resetCaptcha();
}); });
}; };
const onSubmit = () => { const onSubmit = () => {
if (CAPTCHA_ENABLED && recaptchaRef.current) { if (!email || !password) {
recaptchaRef.current.execute(); return;
} else if (!CAPTCHA_ENABLED) {
handleSubmit();
} }
};
const ssoLink = submitWithCaptcha({ email: email.trim(), password })
window !== window.top .then((data) => {
? `${window.location.origin}/api/sso/saml2?iFrame=true` handleSubmit(data['g-recaptcha-response']);
: `${window.location.origin}/api/sso/saml2`; })
.catch((error: any) => {
console.error('Captcha error:', error);
});
};
return ( return (
<div className="flex items-center justify-center h-screen"> <div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="m-10 "> <div className="m-10 ">
<img src="/assets/logo.svg" width={200} /> <img src="/assets/logo.svg" width={200} alt="Company Logo" />
</div> </div>
<div className="border rounded-lg bg-white shadow-sm"> <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"> <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')} className={cn('flex items-center justify-center flex-col')}
style={{ width: '350px' }} style={{ width: '350px' }}
> >
<Loader loading={loading}> <Loader loading={loading || isVerifyingCaptcha}>
{CAPTCHA_ENABLED && (
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey={window.env.CAPTCHA_SITE_KEY}
onChange={(token) => handleSubmit(token)}
/>
)}
<div style={{ width: '350px' }} className="px-8"> <div style={{ width: '350px' }} className="px-8">
<Form.Item> <Form.Item>
<label>{t('Email Address')}</label> <label>{t('Email Address')}</label>
@ -186,8 +177,8 @@ function Login({ location }: LoginProps) {
</Loader> </Loader>
{errors && errors.length ? ( {errors && errors.length ? (
<div className="px-8 my-2 w-full"> <div className="px-8 my-2 w-full">
{errors.map((error) => ( {errors.map((error, index) => (
<div className="flex items-center bg-red-lightest rounded p-3"> <div key={index} className="flex items-center bg-red-lightest rounded p-3">
<Icon name="info" color="red" size="20" /> <Icon name="info" color="red" size="20" />
<span className="color-red ml-2"> <span className="color-red ml-2">
{error} {error}
@ -204,8 +195,14 @@ function Login({ location }: LoginProps) {
className="mt-2 w-full text-center rounded-lg" className="mt-2 w-full text-center rounded-lg"
type="primary" type="primary"
htmlType="submit" htmlType="submit"
loading={loading || isVerifyingCaptcha}
disabled={loading || isVerifyingCaptcha}
> >
{t('Login')} {isVerifyingCaptcha
? t('Verifying...')
: loading
? t('Logging in...')
: t('Login')}
</Button> </Button>
<div className="my-8 flex justify-center items-center flex-wrap"> <div className="my-8 flex justify-center items-center flex-wrap">
@ -219,63 +216,12 @@ function Login({ location }: LoginProps) {
</div> </div>
</Form> </Form>
<div className={cn(stl.sso, 'py-2 flex flex-col items-center')}> <SSOLogin authDetails={authDetails} />
{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>
</div> </div>
{authDetails?.enforceSSO && (
<SSOLogin authDetails={authDetails} enforceSSO={true} />
)}
</div> </div>
</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] const errStr = err.errors[0]
? err.errors[0].includes('already exists') ? err.errors[0].includes('already exists')
? this.t( ? 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] : err.errors[0]
: this.t('Error saving user'); : this.t('Error saving user');
toast.error(errStr); toast.error(errStr);
@ -416,9 +416,9 @@ class UserStore {
this.jwt = data.jwt; this.jwt = data.jwt;
this.spotJwt = data.spotJwt; this.spotJwt = data.spotJwt;
}); });
} catch (error) { } catch (e) {
toast.error(this.t('Error resetting your password; please try again')); toast.error(e.message || this.t('Error resetting your password; please try again'));
return error.response; throw e;
} finally { } finally {
runInAction(() => { runInAction(() => {
this.loading = false; this.loading = false;

View file

@ -138,26 +138,9 @@ export default class UserService {
} }
async resetPassword(data: any) { async resetPassword(data: any) {
try { const response = await this.client.post('/password/reset', data);
const response = await this.client.post('/password/reset', data); const responseData = await response.json();
const responseData = await response.json(); return responseData || {};
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.');
}
} }
async requestResetPassword(data: any) { 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 # 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. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
AppVersion: "v1.22.0" AppVersion: "v1.22.1"