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:
parent
2cb33d7894
commit
605fa96a34
8 changed files with 444 additions and 198 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
|
|
|
|||
75
frontend/app/components/Login/SSOLogin.tsx
Normal file
75
frontend/app/components/Login/SSOLogin.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
219
frontend/app/withRecaptcha.tsx
Normal file
219
frontend/app/withRecaptcha.tsx
Normal 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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue