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 { 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));
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
);
|
||||||
|
|
|
||||||
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]
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
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
|
# 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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue