From 5b9080704eab9c2be3fed0d59315582668906f02 Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Wed, 19 Mar 2025 11:33:46 +0100 Subject: [PATCH] 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 --- .../ForgotPassword/CreatePassword.tsx | 100 ++++---- .../ForgotPassword/ResetPasswordRequest.tsx | 75 +++--- frontend/app/components/Login/Login.tsx | 138 ++++------- frontend/app/components/Login/SSOLogin.tsx | 75 ++++++ frontend/app/mstore/userStore.ts | 10 +- frontend/app/services/UserService.ts | 23 +- frontend/app/withRecaptcha.tsx | 219 ++++++++++++++++++ 7 files changed, 443 insertions(+), 197 deletions(-) create mode 100644 frontend/app/components/Login/SSOLogin.tsx create mode 100644 frontend/app/withRecaptcha.tsx diff --git a/frontend/app/components/ForgotPassword/CreatePassword.tsx b/frontend/app/components/ForgotPassword/CreatePassword.tsx index f42790336..be3571a0f 100644 --- a/frontend/app/components/ForgotPassword/CreatePassword.tsx +++ b/frontend/app/components/ForgotPassword/CreatePassword.tsx @@ -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(null); - const [validationError, setValidationError] = React.useState( - null, - ); - const [updated, setUpdated] = React.useState(false); - const [passwordRepeat, setPasswordRepeat] = React.useState(''); - const [password, setPassword] = React.useState(''); + const [error, setError] = useState(null); + const [validationError, setValidationError] = useState(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 (
{!error && ( <> - +
- {CAPTCHA_ENABLED && ( -
- handleSubmit(token)} - /> -
- )} - - {t('Create')} + {isVerifyingCaptcha + ? t('Verifying...') + : loading + ? t('Processing...') + : t('Create')} )} @@ -153,4 +175,4 @@ function CreatePassword(props: Props) { ); } -export default observer(CreatePassword); +export default withCaptcha(observer(CreatePassword)); diff --git a/frontend/app/components/ForgotPassword/ResetPasswordRequest.tsx b/frontend/app/components/ForgotPassword/ResetPasswordRequest.tsx index c04fcc754..59210ef53 100644 --- a/frontend/app/components/ForgotPassword/ResetPasswordRequest.tsx +++ b/frontend/app/components/ForgotPassword/ResetPasswordRequest.tsx @@ -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(false); + const [requested, setRequested] = useState(false); + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + const [smtpError, setSmtpError] = useState(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 ( - - {CAPTCHA_ENABLED && ( -
- handleSubmit(token)} - /> -
- )} + {!requested && ( <> @@ -92,10 +87,14 @@ function ResetPasswordRequest() { )} @@ -146,4 +145,4 @@ function ResetPasswordRequest() { ); } -export default observer(ResetPasswordRequest); +export default withCaptcha(observer(ResetPasswordRequest)); diff --git a/frontend/app/components/Login/Login.tsx b/frontend/app/components/Login/Login.tsx index 88801611a..ce7d15522 100644 --- a/frontend/app/components/Login/Login.tsx +++ b/frontend/app/components/Login/Login.tsx @@ -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(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 (
- + Company Logo

@@ -145,15 +144,7 @@ function Login({ location }: LoginProps) { className={cn('flex items-center justify-center flex-col')} style={{ width: '350px' }} > - - {CAPTCHA_ENABLED && ( - handleSubmit(token)} - /> - )} +
@@ -186,8 +177,8 @@ function Login({ location }: LoginProps) { {errors && errors.length ? (
- {errors.map((error) => ( -
+ {errors.map((error, index) => ( +
{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')}
@@ -219,63 +216,12 @@ function Login({ location }: LoginProps) {
-
- {authDetails.sso ? ( - - - - ) : ( - - {authDetails.edition === 'ee' ? ( - - {t('SSO has not been configured.')} -
- {t('Please reach out to your admin.')} -
- ) : ( - ENTERPRISE_REQUEIRED(t) - )} -
- } - placement="top" - > - - - )} -
-
- + + {authDetails?.enforceSSO && ( + + )}
@@ -287,4 +233,6 @@ function Login({ location }: LoginProps) { ); } -export default withPageTitle('Login - OpenReplay')(observer(Login)); +export default withPageTitle('Login - OpenReplay')( + withCaptcha(observer(Login)) +); diff --git a/frontend/app/components/Login/SSOLogin.tsx b/frontend/app/components/Login/SSOLogin.tsx new file mode 100644 index 000000000..5b8e52216 --- /dev/null +++ b/frontend/app/components/Login/SSOLogin.tsx @@ -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 ( + + ); + } + + return ( +
+ {authDetails.sso ? ( + + + + ) : ( + + {authDetails.edition === 'ee' ? ( + + {t('SSO has not been configured.')} +
+ {t('Please reach out to your admin.')} +
+ ) : ( + ENTERPRISE_REQUEIRED(t) + )} +
+ } + placement="top" + > + + + + + )} +

+ ); +}; + +export default SSOLogin; diff --git a/frontend/app/mstore/userStore.ts b/frontend/app/mstore/userStore.ts index 21f3c884f..239c5881b 100644 --- a/frontend/app/mstore/userStore.ts +++ b/frontend/app/mstore/userStore.ts @@ -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; diff --git a/frontend/app/services/UserService.ts b/frontend/app/services/UserService.ts index 1c771ad19..2645c491e 100644 --- a/frontend/app/services/UserService.ts +++ b/frontend/app/services/UserService.ts @@ -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) { diff --git a/frontend/app/withRecaptcha.tsx b/frontend/app/withRecaptcha.tsx new file mode 100644 index 000000000..8ef4e31ba --- /dev/null +++ b/frontend/app/withRecaptcha.tsx @@ -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; + 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 =

( + WrappedComponent: ComponentType

, + options: WithCaptchaOptions = {} +): React.FC

=> { + // Default options + const { + position = 'hidden', + errorMessage = 'Please complete the CAPTCHA verification', + theme = 'light', + size = 'invisible' + } = options; + + const WithCaptchaComponent: React.FC

= (props: P) => { + const { enabled: CAPTCHA_ENABLED, siteKey: CAPTCHA_SITE_KEY } = getCaptchaConfig(); + const [captchaToken, setCaptchaToken] = useState(null); + const [isVerifyingCaptcha, setIsVerifyingCaptcha] = useState(false); + const [tokenExpired, setTokenExpired] = useState(false); + const recaptchaRef = useRef(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 => { + 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 && ( +

+ + {hasCaptchaError && ( +
+ {errorMessage} +
+ )} +
+ )} + + + ); + }; + + // Display name for debugging + const wrappedComponentName = + WrappedComponent.displayName || + WrappedComponent.name || + 'Component'; + + WithCaptchaComponent.displayName = `WithCaptcha(${wrappedComponentName})`; + + return WithCaptchaComponent; +}; + +export default withCaptcha;