- Replaced direct import of '@ant-design/icons' with centralized import from '@/shared/antd-imports'
512 lines
16 KiB
TypeScript
512 lines
16 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useMediaQuery } from 'react-responsive';
|
|
import { LockOutlined, MailOutlined, UserOutlined } from '@/shared/antd-imports';
|
|
import { Form, Card, Input, Flex, Button, Typography, Space, message } from 'antd/es';
|
|
import { Rule } from 'antd/es/form';
|
|
import { CheckCircleTwoTone, CloseCircleTwoTone } from '@/shared/antd-imports';
|
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
|
|
import googleIcon from '@/assets/images/google-icon.png';
|
|
import PageHeader from '@components/AuthPageHeader';
|
|
|
|
import { authApiService } from '@/api/auth/auth.api.service';
|
|
import { IUserSignUpRequest } from '@/types/auth/signup.types';
|
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
import { signUp } from '@/features/auth/authSlice';
|
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
|
import {
|
|
evt_signup_page_visit,
|
|
evt_signup_with_email_click,
|
|
evt_signup_with_google_click,
|
|
} from '@/shared/worklenz-analytics-events';
|
|
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
|
import logger from '@/utils/errorLogger';
|
|
import alertService from '@/services/alerts/alertService';
|
|
import { WORKLENZ_REDIRECT_PROJ_KEY } from '@/shared/constants';
|
|
|
|
// Define the global grecaptcha type
|
|
declare global {
|
|
interface Window {
|
|
grecaptcha?: {
|
|
ready: (callback: () => void) => void;
|
|
execute: (siteKey: string, options: { action: string }) => Promise<string>;
|
|
};
|
|
}
|
|
}
|
|
|
|
const SignupPage = () => {
|
|
const [form] = Form.useForm();
|
|
const navigate = useNavigate();
|
|
const dispatch = useAppDispatch();
|
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
|
|
|
const { t } = useTranslation('auth/signup');
|
|
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
|
|
|
useDocumentTitle('Signup');
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
const [validating, setValidating] = useState(false);
|
|
const [urlParams, setUrlParams] = useState({
|
|
email: '',
|
|
name: '',
|
|
teamId: '',
|
|
teamMemberId: '',
|
|
projectId: '',
|
|
});
|
|
|
|
const setProjectId = (projectId: string) => {
|
|
if (!projectId) {
|
|
localStorage.removeItem(WORKLENZ_REDIRECT_PROJ_KEY);
|
|
return;
|
|
}
|
|
localStorage.setItem(WORKLENZ_REDIRECT_PROJ_KEY, projectId);
|
|
};
|
|
|
|
const getProjectId = () => {
|
|
return localStorage.getItem(WORKLENZ_REDIRECT_PROJ_KEY);
|
|
};
|
|
|
|
const enableGoogleLogin = import.meta.env.VITE_ENABLE_GOOGLE_LOGIN === 'true' || false;
|
|
const enableRecaptcha =
|
|
import.meta.env.VITE_ENABLE_RECAPTCHA === 'true' &&
|
|
import.meta.env.VITE_RECAPTCHA_SITE_KEY &&
|
|
import.meta.env.VITE_RECAPTCHA_SITE_KEY !== 'recaptcha-site-key';
|
|
|
|
useEffect(() => {
|
|
trackMixpanelEvent(evt_signup_page_visit);
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
setUrlParams({
|
|
email: searchParams.get('email') || '',
|
|
name: searchParams.get('name') || '',
|
|
teamId: searchParams.get('team') || '',
|
|
teamMemberId: searchParams.get('user') || '',
|
|
projectId: searchParams.get('project') || '',
|
|
});
|
|
|
|
setProjectId(searchParams.get('project') || '');
|
|
|
|
form.setFieldsValue({
|
|
email: searchParams.get('email') || '',
|
|
name: searchParams.get('name') || '',
|
|
});
|
|
}, [trackMixpanelEvent]);
|
|
|
|
useEffect(() => {
|
|
// Only load recaptcha script if recaptcha is enabled and site key is valid
|
|
if (enableRecaptcha && import.meta.env.VITE_RECAPTCHA_SITE_KEY) {
|
|
// Check if site key is not the placeholder value
|
|
if (import.meta.env.VITE_RECAPTCHA_SITE_KEY === 'recaptcha-site-key') {
|
|
console.warn(
|
|
'Using placeholder reCAPTCHA site key. Please set a valid key in your environment variables.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
const script = document.createElement('script');
|
|
script.src = `https://www.google.com/recaptcha/api.js?render=${import.meta.env.VITE_RECAPTCHA_SITE_KEY}`;
|
|
script.async = true;
|
|
script.defer = true;
|
|
document.body.appendChild(script);
|
|
|
|
return () => {
|
|
if (script && script.parentNode) {
|
|
script.parentNode.removeChild(script);
|
|
}
|
|
|
|
const recaptchaElements = document.getElementsByClassName('grecaptcha-badge');
|
|
while (recaptchaElements.length > 0) {
|
|
const element = recaptchaElements[0];
|
|
if (element.parentNode) {
|
|
element.parentNode.removeChild(element);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}, [enableRecaptcha]);
|
|
|
|
const getInvitationQueryParams = () => {
|
|
const params = [`team=${urlParams.teamId}`, `teamMember=${urlParams.teamMemberId}`];
|
|
if (getProjectId()) {
|
|
params.push(`project=${getProjectId()}`);
|
|
}
|
|
return urlParams.teamId && urlParams.teamMemberId ? `?${params.join('&')}` : '';
|
|
};
|
|
|
|
const getRecaptchaToken = async () => {
|
|
if (!enableRecaptcha) return '';
|
|
|
|
// Check if site key is valid
|
|
if (
|
|
!import.meta.env.VITE_RECAPTCHA_SITE_KEY ||
|
|
import.meta.env.VITE_RECAPTCHA_SITE_KEY === 'recaptcha-site-key'
|
|
) {
|
|
console.warn('Invalid reCAPTCHA site key. Skipping reCAPTCHA verification.');
|
|
return 'skip-verification';
|
|
}
|
|
|
|
try {
|
|
return new Promise<string>((resolve, reject) => {
|
|
if (!window.grecaptcha) {
|
|
reject('reCAPTCHA not loaded');
|
|
return;
|
|
}
|
|
|
|
window.grecaptcha.ready(() => {
|
|
window
|
|
.grecaptcha!.execute(import.meta.env.VITE_RECAPTCHA_SITE_KEY, { action: 'signup' })
|
|
.then((token: string) => {
|
|
resolve(token);
|
|
})
|
|
.catch((error: any) => {
|
|
console.error('reCAPTCHA execution error:', error);
|
|
reject(error);
|
|
});
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting reCAPTCHA token:', error);
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const onFinish = async (values: IUserSignUpRequest) => {
|
|
try {
|
|
setValidating(true);
|
|
|
|
if (enableRecaptcha) {
|
|
try {
|
|
const token = await getRecaptchaToken();
|
|
|
|
if (!token) {
|
|
logger.error('Failed to get reCAPTCHA token');
|
|
alertService.error(
|
|
t('reCAPTCHAVerificationError'),
|
|
t('reCAPTCHAVerificationErrorMessage')
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Skip verification if we're using the special token due to invalid site key
|
|
if (token !== 'skip-verification') {
|
|
const verifyToken = await authApiService.verifyRecaptchaToken(token);
|
|
|
|
if (!verifyToken.done) {
|
|
logger.error('Failed to verify reCAPTCHA token');
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('reCAPTCHA error:', error);
|
|
// Continue with sign up even if reCAPTCHA fails in development
|
|
if (import.meta.env.DEV) {
|
|
console.warn('Continuing signup despite reCAPTCHA error in development mode');
|
|
} else {
|
|
alertService.error(
|
|
t('reCAPTCHAVerificationError'),
|
|
t('reCAPTCHAVerificationErrorMessage')
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const body = {
|
|
name: values.name,
|
|
email: values.email,
|
|
password: values.password,
|
|
};
|
|
|
|
const res = await authApiService.signUpCheck(body);
|
|
if (res.done) {
|
|
await signUpWithEmail(body);
|
|
}
|
|
} catch (error: any) {
|
|
message.error(error?.response?.data?.message || 'Failed to validate signup details');
|
|
} finally {
|
|
setValidating(false);
|
|
}
|
|
};
|
|
|
|
const signUpWithEmail = async (body: IUserSignUpRequest) => {
|
|
try {
|
|
setLoading(true);
|
|
trackMixpanelEvent(evt_signup_with_email_click, {
|
|
email: body.email,
|
|
name: body.name,
|
|
});
|
|
if (urlParams.teamId) {
|
|
body.team_id = urlParams.teamId;
|
|
}
|
|
if (urlParams.teamMemberId) {
|
|
body.team_member_id = urlParams.teamMemberId;
|
|
}
|
|
if (urlParams.projectId) {
|
|
body.project_id = urlParams.projectId;
|
|
}
|
|
const result = await dispatch(signUp(body)).unwrap();
|
|
if (result?.authenticated) {
|
|
message.success('Successfully signed up!');
|
|
setTimeout(() => {
|
|
navigate('/auth/authenticating');
|
|
}, 1000);
|
|
}
|
|
} catch (error: any) {
|
|
message.error(error?.response?.data?.message || 'Failed to sign up');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const onGoogleSignUpClick = () => {
|
|
try {
|
|
trackMixpanelEvent(evt_signup_with_google_click);
|
|
const queryParams = getInvitationQueryParams();
|
|
const url = `${import.meta.env.VITE_API_URL}/secure/google${queryParams ? `?${queryParams}` : ''}`;
|
|
window.location.href = url;
|
|
} catch (error) {
|
|
message.error('Failed to redirect to Google sign up');
|
|
}
|
|
};
|
|
|
|
const formRules = {
|
|
name: [
|
|
{
|
|
required: true,
|
|
message: t('nameRequired'),
|
|
whitespace: true,
|
|
},
|
|
{
|
|
min: 4,
|
|
message: t('nameMinCharacterRequired'),
|
|
},
|
|
],
|
|
email: [
|
|
{
|
|
required: true,
|
|
type: 'email',
|
|
message: t('emailRequired'),
|
|
},
|
|
],
|
|
password: [
|
|
{
|
|
required: true,
|
|
message: t('passwordRequired'),
|
|
},
|
|
{
|
|
min: 8,
|
|
message: t('passwordMinCharacterRequired'),
|
|
},
|
|
{
|
|
max: 32,
|
|
message: t('passwordMaxCharacterRequired'),
|
|
},
|
|
{
|
|
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])/,
|
|
message: t('passwordPatternRequired'),
|
|
},
|
|
],
|
|
};
|
|
|
|
const passwordChecklistItems = [
|
|
{
|
|
key: 'minLength',
|
|
test: (v: string) => v.length >= 8,
|
|
label: t('passwordChecklist.minLength', { defaultValue: 'At least 8 characters' }),
|
|
},
|
|
{
|
|
key: 'uppercase',
|
|
test: (v: string) => /[A-Z]/.test(v),
|
|
label: t('passwordChecklist.uppercase', { defaultValue: 'One uppercase letter' }),
|
|
},
|
|
{
|
|
key: 'lowercase',
|
|
test: (v: string) => /[a-z]/.test(v),
|
|
label: t('passwordChecklist.lowercase', { defaultValue: 'One lowercase letter' }),
|
|
},
|
|
{
|
|
key: 'number',
|
|
test: (v: string) => /\d/.test(v),
|
|
label: t('passwordChecklist.number', { defaultValue: 'One number' }),
|
|
},
|
|
{
|
|
key: 'special',
|
|
test: (v: string) => /[@$!%*?&#]/.test(v),
|
|
label: t('passwordChecklist.special', { defaultValue: 'One special character' }),
|
|
},
|
|
];
|
|
|
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
|
const [passwordValue, setPasswordValue] = useState('');
|
|
const [passwordActive, setPasswordActive] = useState(false);
|
|
|
|
return (
|
|
<Card
|
|
style={{
|
|
width: '100%',
|
|
boxShadow: 'none',
|
|
}}
|
|
styles={{
|
|
body: {
|
|
paddingInline: isMobile ? 24 : 48,
|
|
},
|
|
}}
|
|
variant="outlined"
|
|
>
|
|
<PageHeader description={t('headerDescription', {defaultValue: 'Sign up to get started'})} />
|
|
<Form
|
|
form={form}
|
|
name="signup"
|
|
layout="vertical"
|
|
autoComplete="off"
|
|
requiredMark="optional"
|
|
onFinish={onFinish}
|
|
style={{ width: '100%' }}
|
|
initialValues={{
|
|
email: urlParams.email,
|
|
name: urlParams.name,
|
|
}}
|
|
>
|
|
<Form.Item name="name" label={t('nameLabel', {defaultValue: 'Full Name'})} rules={formRules.name}>
|
|
<Input
|
|
prefix={<UserOutlined />}
|
|
placeholder={t('namePlaceholder', {defaultValue: 'Enter your full name'})}
|
|
size="large"
|
|
style={{ borderRadius: 4 }}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item name="email" label={t('emailLabel', {defaultValue: 'Email'})} rules={formRules.email as Rule[]}>
|
|
<Input
|
|
prefix={<MailOutlined />}
|
|
placeholder={t('emailPlaceholder', {defaultValue: 'Enter your email'})}
|
|
size="large"
|
|
style={{ borderRadius: 4 }}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="password"
|
|
label={t('passwordLabel', {defaultValue: 'Password'})}
|
|
rules={formRules.password}
|
|
validateTrigger={['onBlur', 'onSubmit']}
|
|
>
|
|
<div>
|
|
<Input.Password
|
|
prefix={<LockOutlined />}
|
|
placeholder={t('strongPasswordPlaceholder', {defaultValue: 'Enter a strong password'})}
|
|
size="large"
|
|
style={{ borderRadius: 4 }}
|
|
value={passwordValue}
|
|
onFocus={() => setPasswordActive(true)}
|
|
onChange={e => {
|
|
setPasswordValue(e.target.value);
|
|
setPasswordActive(true);
|
|
}}
|
|
onBlur={() => {
|
|
if (!passwordValue) setPasswordActive(false);
|
|
}}
|
|
/>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12, marginTop: 4, marginBottom: 0, display: 'block' }}>
|
|
{t('passwordGuideline', {
|
|
defaultValue: 'Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.'
|
|
})}
|
|
</Typography.Text>
|
|
{passwordActive && (
|
|
<div style={{ marginTop: 8, marginBottom: 4 }}>
|
|
{passwordChecklistItems.map(item => {
|
|
const passed = item.test(passwordValue);
|
|
// Only green if passed, otherwise neutral (never red)
|
|
let color = passed
|
|
? (themeMode === 'dark' ? '#52c41a' : '#389e0d')
|
|
: (themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf');
|
|
return (
|
|
<Flex key={item.key} align="center" gap={8} style={{ color, fontSize: 13 }}>
|
|
{passed ? (
|
|
<CheckCircleTwoTone twoToneColor={themeMode === 'dark' ? '#52c41a' : '#52c41a'} />
|
|
) : (
|
|
<CloseCircleTwoTone twoToneColor={themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf'} />
|
|
)}
|
|
<span>{item.label}</span>
|
|
</Flex>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Form.Item>
|
|
|
|
<Form.Item>
|
|
<Typography.Paragraph style={{ fontSize: 14 }}>
|
|
{t('bySigningUpText')}{' '}
|
|
<a href="https://worklenz.com/privacy/" target="_blank" rel="noopener noreferrer">
|
|
{t('privacyPolicyLink')}
|
|
</a>{' '}
|
|
{t('andText')}{' '}
|
|
<a href="https://worklenz.com/terms/" target="_blank" rel="noopener noreferrer">
|
|
{t('termsOfUseLink')}
|
|
</a>
|
|
.
|
|
</Typography.Paragraph>
|
|
</Form.Item>
|
|
|
|
<Form.Item>
|
|
<Flex vertical gap={8}>
|
|
<Button
|
|
block
|
|
type="primary"
|
|
htmlType="submit"
|
|
size="large"
|
|
loading={loading || validating}
|
|
style={{ borderRadius: 4 }}
|
|
>
|
|
{t('signupButton')}
|
|
</Button>
|
|
|
|
{enableGoogleLogin && (
|
|
<>
|
|
<Typography.Text style={{ textAlign: 'center' }}>{t('orText')}</Typography.Text>
|
|
|
|
<Button
|
|
block
|
|
type="default"
|
|
size="large"
|
|
onClick={onGoogleSignUpClick}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<img src={googleIcon} alt="google icon" style={{ maxWidth: 20, width: '100%' }} />
|
|
{t('signInWithGoogleButton')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Flex>
|
|
</Form.Item>
|
|
|
|
<Form.Item>
|
|
<Space>
|
|
<Typography.Text style={{ fontSize: 14 }}>
|
|
{t('alreadyHaveAccountText', {defaultValue: 'Already have an account?'})}
|
|
</Typography.Text>
|
|
|
|
<Link
|
|
to="/auth/login"
|
|
className="ant-typography ant-typography-link blue-link"
|
|
style={{ fontSize: 14 }}
|
|
>
|
|
{t('loginButton')}
|
|
</Link>
|
|
</Space>
|
|
</Form.Item>
|
|
</Form>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default SignupPage;
|