* 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>
219 lines
7.1 KiB
TypeScript
219 lines
7.1 KiB
TypeScript
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;
|