feat(auth): implement withCaptcha HOC for consistent reCAPTCHA (#3177)
* 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 * feat(auth): support msaas edition for enterprise features Add msaas to the isEnterprise check alongside ee edition to properly display enterprise features. Use userStore.isEnterprise in SSOLogin component instead of directly checking authDetails.edition for consistent enterprise status detection.
This commit is contained in:
parent
5fec615044
commit
8eec6e983b
7 changed files with 451 additions and 200 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))
|
||||
);
|
||||
|
|
|
|||
78
frontend/app/components/Login/SSOLogin.tsx
Normal file
78
frontend/app/components/Login/SSOLogin.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
||||
interface SSOLoginProps {
|
||||
authDetails: any;
|
||||
enforceSSO?: boolean;
|
||||
}
|
||||
|
||||
const SSOLogin = ({ authDetails, enforceSSO = false }: SSOLoginProps) => {
|
||||
const { userStore } = useStore();
|
||||
const { t } = useTranslation();
|
||||
const { isEnterprise } = userStore;
|
||||
|
||||
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">
|
||||
{isEnterprise ? (
|
||||
<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;
|
||||
|
|
@ -114,7 +114,9 @@ class UserStore {
|
|||
get isEnterprise() {
|
||||
return (
|
||||
this.account?.edition === 'ee' ||
|
||||
this.authStore.authDetails?.edition === 'ee'
|
||||
this.account?.edition === 'msaas' ||
|
||||
this.authStore.authDetails?.edition === 'ee' ||
|
||||
this.authStore.authDetails?.edition === 'msaas'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -245,8 +247,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 +418,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;
|
||||
|
|
@ -663,14 +665,14 @@ class AuthStore {
|
|||
{
|
||||
key: 'authDetails',
|
||||
serialize: (ad) => {
|
||||
delete ad.edition;
|
||||
// delete ad.edition;
|
||||
return Object.keys(ad).length > 0
|
||||
? JSON.stringify(ad)
|
||||
: JSON.stringify({});
|
||||
},
|
||||
deserialize: (json) => {
|
||||
const ad = JSON.parse(json);
|
||||
delete ad.edition;
|
||||
// delete ad.edition;
|
||||
return ad;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue