init
This commit is contained in:
81
worklenz-frontend/src/pages/auth/authenticating.tsx
Normal file
81
worklenz-frontend/src/pages/auth/authenticating.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Card, Flex, Spin, Typography } from 'antd/es';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { WORKLENZ_REDIRECT_PROJ_KEY } from '@/shared/constants';
|
||||
|
||||
const REDIRECT_DELAY = 500; // Delay in milliseconds before redirecting
|
||||
|
||||
const AuthenticatingPage: React.FC = () => {
|
||||
const { t } = useTranslation('auth/auth-common');
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleSuccessRedirect = () => {
|
||||
const project = localStorage.getItem(WORKLENZ_REDIRECT_PROJ_KEY);
|
||||
if (project) {
|
||||
localStorage.removeItem(WORKLENZ_REDIRECT_PROJ_KEY);
|
||||
window.location.href = `/worklenz/projects/${project}?tab=tasks-list`;
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/worklenz/home');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleAuthentication = async () => {
|
||||
try {
|
||||
const session = await dispatch(verifyAuthentication()).unwrap();
|
||||
|
||||
if (!session.authenticated) {
|
||||
return navigate('/auth/login');
|
||||
}
|
||||
|
||||
// Set user session and state
|
||||
setSession(session.user);
|
||||
dispatch(setUser(session.user));
|
||||
|
||||
if (!session.user.setup_completed) {
|
||||
return navigate('/worklenz/setup');
|
||||
}
|
||||
|
||||
// Redirect based on setup status
|
||||
setTimeout(() => {
|
||||
handleSuccessRedirect();
|
||||
}, REDIRECT_DELAY);
|
||||
} catch (error) {
|
||||
logger.error('Authentication verification failed:', error);
|
||||
navigate('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
void handleAuthentication();
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
const cardStyles = {
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={cardStyles}>
|
||||
<Flex vertical align="center" gap="middle">
|
||||
<Spin size="large" />
|
||||
<Typography.Title level={3}>
|
||||
{t('authenticating', { defaultValue: 'Authenticating...' })}
|
||||
</Typography.Title>
|
||||
<Typography.Text>
|
||||
{t('gettingThingsReady', { defaultValue: 'Getting things ready for you...' })}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticatingPage;
|
||||
158
worklenz-frontend/src/pages/auth/forgot-password-page.tsx
Normal file
158
worklenz-frontend/src/pages/auth/forgot-password-page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import { Form, Card, Input, Flex, Button, Typography, Result } from 'antd/es';
|
||||
|
||||
import PageHeader from '@components/AuthPageHeader';
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { evt_forgot_password_page_visit, evt_reset_password_click } from '@/shared/worklenz-analytics-events';
|
||||
import { resetPassword, verifyAuthentication } from '@features/auth/authSlice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
import { setUser } from '@features/user/userSlice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
const ForgotPasswordPage = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [urlParams, setUrlParams] = useState({
|
||||
teamId: '',
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
useDocumentTitle('Forgot Password');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Localization
|
||||
const { t } = useTranslation('auth/forgot-password');
|
||||
|
||||
// media queries from react-responsive package
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_forgot_password_page_visit);
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
setUrlParams({
|
||||
teamId: searchParams.get('team') || '',
|
||||
});
|
||||
const verifyAuthStatus = async () => {
|
||||
try {
|
||||
const session = await dispatch(verifyAuthentication()).unwrap();
|
||||
if (session?.authenticated) {
|
||||
setSession(session.user);
|
||||
dispatch(setUser(session.user));
|
||||
navigate('/worklenz/home');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify authentication status', error);
|
||||
}
|
||||
};
|
||||
void verifyAuthStatus();
|
||||
}, [dispatch, navigate, trackMixpanelEvent]);
|
||||
|
||||
const onFinish = useCallback(
|
||||
async (values: any) => {
|
||||
if (values.email.trim() === '') return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await dispatch(resetPassword(values.email)).unwrap();
|
||||
if (result.done) {
|
||||
trackMixpanelEvent(evt_reset_password_click);
|
||||
setIsSuccess(true);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset password', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[dispatch, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
paddingInline: isMobile ? 24 : 48,
|
||||
},
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{isSuccess ? (
|
||||
<Result status="success" title={t('successTitle')} subTitle={t('successMessage')} />
|
||||
) : (
|
||||
<>
|
||||
<PageHeader description={t('headerDescription')} />
|
||||
<Form
|
||||
name="forgot-password"
|
||||
form={form}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
requiredMark="optional"
|
||||
initialValues={{ remember: true }}
|
||||
onFinish={onFinish}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'email',
|
||||
message: t('emailRequired'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('emailPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Flex vertical gap={8}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
loading={isLoading}
|
||||
style={{ borderRadius: 4 }}
|
||||
>
|
||||
{t('resetPasswordButton')}
|
||||
</Button>
|
||||
<Typography.Text style={{ textAlign: 'center' }}>{t('orText')}</Typography.Text>
|
||||
<Link to="/auth/login">
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
size="large"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{t('returnToLoginButton')}
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordPage;
|
||||
41
worklenz-frontend/src/pages/auth/logging-out.tsx
Normal file
41
worklenz-frontend/src/pages/auth/logging-out.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Card, Flex, Spin, Typography } from 'antd/es';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
|
||||
const LoggingOutPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuthService();
|
||||
const { t } = useTranslation('auth/auth-common');
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
||||
|
||||
useEffect(() => {
|
||||
const logout = async () => {
|
||||
await auth.signOut();
|
||||
await authApiService.logout();
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
};
|
||||
void logout();
|
||||
}, [auth, navigate]);
|
||||
|
||||
const cardStyles = {
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={cardStyles}>
|
||||
<Flex vertical align="center" justify="center" gap="middle">
|
||||
<Spin size="large" />
|
||||
<Typography.Title level={3}>{t('loggingOut')}</Typography.Title>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoggingOutPage;
|
||||
256
worklenz-frontend/src/pages/auth/login-page.tsx
Normal file
256
worklenz-frontend/src/pages/auth/login-page.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, Input, Flex, Checkbox, Button, Typography, Space, Form, message } from 'antd/es';
|
||||
import { Rule } from 'antd/es/form';
|
||||
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import PageHeader from '@components/AuthPageHeader';
|
||||
import googleIcon from '@assets/images/google-icon.png';
|
||||
import { login, verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
import {
|
||||
evt_login_page_visit,
|
||||
evt_login_with_email_click,
|
||||
evt_login_with_google_click,
|
||||
evt_login_remember_me_click,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { WORKLENZ_REDIRECT_PROJ_KEY } from '@/shared/constants';
|
||||
|
||||
interface LoginFormValues {
|
||||
email: string;
|
||||
password: string;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('auth/login');
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
||||
const dispatch = useAppDispatch();
|
||||
const { isLoading } = useAppSelector(state => state.auth);
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const [form] = Form.useForm<LoginFormValues>();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const [urlParams, setUrlParams] = useState({
|
||||
teamId: '',
|
||||
projectId: '',
|
||||
});
|
||||
|
||||
const enableGoogleLogin = import.meta.env.VITE_ENABLE_GOOGLE_LOGIN === 'true' || false;
|
||||
|
||||
useDocumentTitle('Login');
|
||||
|
||||
const validationRules = {
|
||||
email: [
|
||||
{ required: true, message: t('emailRequired') },
|
||||
{ type: 'email', message: t('validationMessages.email') },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: t('passwordRequired') },
|
||||
{ min: 8, message: t('validationMessages.password') },
|
||||
],
|
||||
};
|
||||
|
||||
const verifyAuthStatus = async () => {
|
||||
try {
|
||||
const session = await dispatch(verifyAuthentication()).unwrap();
|
||||
|
||||
if (session?.authenticated) {
|
||||
setSession(session.user);
|
||||
dispatch(setUser(session.user));
|
||||
navigate('/worklenz/home');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify authentication status', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_login_page_visit);
|
||||
if (currentSession && !currentSession?.setup_completed) {
|
||||
navigate('/worklenz/setup');
|
||||
return;
|
||||
}
|
||||
void verifyAuthStatus();
|
||||
}, [dispatch, navigate, trackMixpanelEvent]);
|
||||
|
||||
const onFinish = useCallback(
|
||||
async (values: LoginFormValues) => {
|
||||
try {
|
||||
trackMixpanelEvent(evt_login_with_email_click);
|
||||
|
||||
// if (teamId) {
|
||||
// localStorage.setItem(WORKLENZ_REDIRECT_PROJ_KEY, teamId);
|
||||
// }
|
||||
|
||||
const result = await dispatch(login(values)).unwrap();
|
||||
if (result.authenticated) {
|
||||
message.success(t('successMessage'));
|
||||
setSession(result.user);
|
||||
dispatch(setUser(result.user));
|
||||
navigate('/auth/authenticating');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Login failed', error);
|
||||
alertService.error(
|
||||
t('errorMessages.loginErrorTitle'),
|
||||
t('errorMessages.loginErrorMessage')
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, navigate, t, trackMixpanelEvent]
|
||||
);
|
||||
|
||||
const handleGoogleLogin = useCallback(() => {
|
||||
try {
|
||||
trackMixpanelEvent(evt_login_with_google_click);
|
||||
window.location.href = `${import.meta.env.VITE_API_URL}/secure/google`;
|
||||
} catch (error) {
|
||||
logger.error('Google login failed', error);
|
||||
}
|
||||
}, [trackMixpanelEvent, t]);
|
||||
|
||||
const handleRememberMeChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
trackMixpanelEvent(evt_login_remember_me_click, { checked });
|
||||
},
|
||||
[trackMixpanelEvent]
|
||||
);
|
||||
|
||||
const styles = {
|
||||
card: {
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
button: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
googleButton: {
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
link: {
|
||||
fontSize: 14,
|
||||
},
|
||||
googleIcon: {
|
||||
maxWidth: 20,
|
||||
marginRight: 8,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={styles.card}
|
||||
styles={{ body: { paddingInline: isMobile ? 24 : 48 } }}
|
||||
variant="outlined"
|
||||
>
|
||||
<PageHeader description={t('headerDescription')} />
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="login"
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
requiredMark="optional"
|
||||
initialValues={{ remember: true }}
|
||||
onFinish={onFinish}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item name="email" rules={validationRules.email as Rule[]}>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('emailPlaceholder')}
|
||||
size="large"
|
||||
style={styles.button}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password" rules={validationRules.password}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
size="large"
|
||||
style={styles.button}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||
<Checkbox onChange={e => handleRememberMeChange(e.target.checked)}>
|
||||
{t('rememberMe')}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Link
|
||||
to="/auth/forgot-password"
|
||||
className="ant-typography ant-typography-link blue-link"
|
||||
style={styles.link}
|
||||
>
|
||||
{t('forgotPasswordButton')}
|
||||
</Link>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Flex vertical gap={8}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
loading={isLoading}
|
||||
style={styles.button}
|
||||
>
|
||||
{t('loginButton')}
|
||||
</Button>
|
||||
|
||||
{enableGoogleLogin && (
|
||||
<>
|
||||
<Typography.Text style={{ textAlign: 'center' }}>{t('orText')}</Typography.Text>
|
||||
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
size="large"
|
||||
onClick={handleGoogleLogin}
|
||||
style={styles.googleButton}
|
||||
>
|
||||
<img src={googleIcon} alt="Google" style={styles.googleIcon} />
|
||||
{t('signInWithGoogleButton')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Typography.Text style={styles.link}>{t('dontHaveAccountText')}</Typography.Text>
|
||||
<Link
|
||||
to="/auth/signup"
|
||||
className="ant-typography ant-typography-link blue-link"
|
||||
style={styles.link}
|
||||
>
|
||||
{t('signupButton')}
|
||||
</Link>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
366
worklenz-frontend/src/pages/auth/signup-page.tsx
Normal file
366
worklenz-frontend/src/pages/auth/signup-page.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
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 '@ant-design/icons';
|
||||
import { Form, Card, Input, Flex, Button, Typography, Space, message } from 'antd/es';
|
||||
import { Rule } from 'antd/es/form';
|
||||
|
||||
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';
|
||||
|
||||
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;
|
||||
|
||||
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(() => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 () => {
|
||||
return new Promise<string>(resolve => {
|
||||
window.grecaptcha?.ready(() => {
|
||||
window.grecaptcha
|
||||
?.execute(import.meta.env.VITE_RECAPTCHA_SITE_KEY, { action: 'signup' })
|
||||
.then((token: string) => {
|
||||
resolve(token);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onFinish = async (values: IUserSignUpRequest) => {
|
||||
try {
|
||||
setValidating(true);
|
||||
const token = await getRecaptchaToken();
|
||||
|
||||
if (!token) {
|
||||
logger.error('Failed to get reCAPTCHA token');
|
||||
alertService.error(t('reCAPTCHAVerificationError'), t('reCAPTCHAVerificationErrorMessage'));
|
||||
return;
|
||||
}
|
||||
|
||||
const veriftToken = await authApiService.verifyRecaptchaToken(token);
|
||||
|
||||
if (!veriftToken.done) {
|
||||
logger.error('Failed to verify reCAPTCHA token');
|
||||
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'),
|
||||
},
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])/,
|
||||
message: t('passwordPatternRequired'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
paddingInline: isMobile ? 24 : 48,
|
||||
},
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
<PageHeader description={t('headerDescription')} />
|
||||
<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')} rules={formRules.name}>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('namePlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="email" label={t('emailLabel')} rules={formRules.email as Rule[]}>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder={t('emailPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password" label={t('passwordLabel')} rules={formRules.password}>
|
||||
<div>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('strongPasswordPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('passwordValidationAltText')}
|
||||
</Typography.Text>
|
||||
</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')}
|
||||
</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;
|
||||
176
worklenz-frontend/src/pages/auth/verify-reset-email.tsx
Normal file
176
worklenz-frontend/src/pages/auth/verify-reset-email.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Form, Card, Input, Flex, Button, Typography, Result } from 'antd/es';
|
||||
import { LockOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import PageHeader from '@components/AuthPageHeader';
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
import { updatePassword } from '@/features/auth/authSlice';
|
||||
import { evt_verify_reset_email_page_visit } from '@/shared/worklenz-analytics-events';
|
||||
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { IUpdatePasswordRequest } from '@/types/auth/verify-reset-email.types';
|
||||
|
||||
const VerifyResetEmailPage = () => {
|
||||
const [form] = Form.useForm();
|
||||
const { hash, user } = useParams();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [urlParams, setUrlParams] = useState({
|
||||
hash: hash || '',
|
||||
user: user || '',
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
useDocumentTitle('Verify Reset Email');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation('auth/verify-reset-email');
|
||||
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_verify_reset_email_page_visit);
|
||||
console.log(urlParams);
|
||||
}, [trackMixpanelEvent]);
|
||||
|
||||
const onFinish = useCallback(
|
||||
async (values: any) => {
|
||||
if (values.newPassword.trim() === '' || values.confirmPassword.trim() === '') return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const body: IUpdatePasswordRequest = {
|
||||
hash: urlParams.hash,
|
||||
user: urlParams.user,
|
||||
password: values.newPassword,
|
||||
confirmPassword: values.confirmPassword,
|
||||
};
|
||||
const result = await dispatch(updatePassword(body)).unwrap();
|
||||
if (result.done) {
|
||||
setIsSuccess(true);
|
||||
navigate('/auth/login');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset password', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[dispatch, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
paddingInline: isMobile ? 24 : 48,
|
||||
},
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{isSuccess ? (
|
||||
<Result status="success" title={t('successTitle')} subTitle={t('successMessage')} />
|
||||
) : (
|
||||
<>
|
||||
<PageHeader description={t('description')} />
|
||||
<Form
|
||||
name="verify-reset-email"
|
||||
form={form}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
requiredMark={true}
|
||||
onFinish={onFinish}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('passwordRequired'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('placeholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
required
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('confirmPasswordRequired'),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t('passwordMismatch')));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
onPaste={e => e.preventDefault()}
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('confirmPasswordPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Flex vertical gap={8}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
loading={isLoading}
|
||||
style={{ borderRadius: 4 }}
|
||||
>
|
||||
{t('resetPasswordButton')}
|
||||
</Button>
|
||||
<Typography.Text style={{ textAlign: 'center' }}>{t('orText')}</Typography.Text>
|
||||
<Link to="/auth/forgot-password">
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
size="large"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{t('resendResetEmail')}
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyResetEmailPage;
|
||||
Reference in New Issue
Block a user