ensured password reset and account verification works as well as support dashboard

This commit is contained in:
2025-12-13 06:50:29 -06:00
parent 173f6ccf6d
commit a6f267492d
14 changed files with 2991 additions and 111 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,8 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html", "predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
"deploy": "gh-pages -d dist" "deploy": "gh-pages -d dist",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.4", "@emotion/react": "^11.11.4",
@@ -43,6 +44,9 @@
}, },
"devDependencies": { "devDependencies": {
"@iconify/react": "^4.1.1", "@iconify/react": "^4.1.1",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",
@@ -52,8 +56,10 @@
"eslint-plugin-prettier": "^5.5.3", "eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-react-refresh": "^0.4.6",
"jsdom": "^24.0.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.3.1"
} }
} }

View File

@@ -72,9 +72,9 @@ const SupportAgentDashboard: React.FC = () => {
text: item.description, text: item.description,
created_at: item.created_at, created_at: item.created_at,
updated_at: item.updated_at, updated_at: item.updated_at,
user: -1, // Placeholder user: item.user_email,
user_first_name: 'System', user_first_name: item.user_first_name,
user_last_name: 'Description' user_last_name: item.user_last_name,
}] }]
})); }));
@@ -129,7 +129,7 @@ const SupportAgentDashboard: React.FC = () => {
result = result.filter(item => result = result.filter(item =>
item.title.toLowerCase().includes(lowerSearch) || item.title.toLowerCase().includes(lowerSearch) ||
item.description.toLowerCase().includes(lowerSearch) item.description.toLowerCase().includes(lowerSearch)
// || item.user?.email?.toLowerCase().includes(lowerSearch) // If user object existed || item.user_email?.toLowerCase().includes(lowerSearch) // If user object existed
); );
} }
@@ -215,6 +215,26 @@ const SupportAgentDashboard: React.FC = () => {
} }
}; };
const handleCloseCase = async () => {
if (!selectedSupportCaseId) return;
try {
const { data }: AxiosResponse<SupportCaseApi> = await axiosInstance.patch(
`/support/cases/${selectedSupportCaseId}/`,
{ status: 'closed' }
);
setSupportCase(prev => prev ? { ...prev, status: data.status, updated_at: data.updated_at } : null);
setSupportCases(prevCases =>
prevCases.map(c =>
c.id === selectedSupportCaseId ? { ...c, status: data.status, updated_at: data.updated_at } : c
)
);
} catch (error) {
console.error("Failed to close case", error);
}
};
if (loading) { if (loading) {
return <PageLoader />; return <PageLoader />;
} }
@@ -233,7 +253,8 @@ const SupportAgentDashboard: React.FC = () => {
return ( return (
<Container <Container
sx={{ py: 4, height: '90vh', width: 'fill', display: 'flex', flexDirection: 'column' }} maxWidth={false}
sx={{ py: 4, height: '90vh', width: '100%', display: 'flex', flexDirection: 'column' }}
> >
<Paper <Paper
elevation={3} elevation={3}
@@ -242,7 +263,9 @@ const SupportAgentDashboard: React.FC = () => {
<Grid container sx={{ height: '100%', width: '100%' }}> <Grid container sx={{ height: '100%', width: '100%' }}>
{/* Left Panel: List & Filters */} {/* Left Panel: List & Filters */}
<Grid <Grid
size={{ xs: 12, md: 4 }} item
xs={12}
md={4}
sx={{ sx={{
borderRight: { md: '1px solid', borderColor: 'grey.200' }, borderRight: { md: '1px solid', borderColor: 'grey.200' },
display: 'flex', display: 'flex',
@@ -337,6 +360,7 @@ const SupportAgentDashboard: React.FC = () => {
</Box> </Box>
} }
/> />
</ListItemButton>
</ListItem> </ListItem>
)) ))
)} )}
@@ -354,12 +378,21 @@ const SupportAgentDashboard: React.FC = () => {
borderColor: 'grey.200', borderColor: 'grey.200',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
gap: 2, gap: 2,
}} }}
> >
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}> <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
{supportCase.title} | {supportCase.category} {supportCase.title} | {supportCase.category}
</Typography> </Typography>
<Button
variant="outlined"
color="error"
onClick={handleCloseCase}
disabled={supportCase.status === 'closed'}
>
{supportCase.status === 'closed' ? 'Closed' : 'Close Case'}
</Button>
</Box> </Box>
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}> <Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>

View File

@@ -0,0 +1,73 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import ForgotPassword from './ForgotPassword';
import { axiosInstance } from '../../axiosApi.js';
// Mock axiosInstance
vi.mock('../../axiosApi.js', () => ({
axiosInstance: {
post: vi.fn(),
},
}));
describe('ForgotPassword', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders correctly', () => {
render(<ForgotPassword />);
expect(screen.getByText('Forgot Password')).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Send Password Reset Link/i })).toBeInTheDocument();
});
it('updates email input', () => {
render(<ForgotPassword />);
const emailInput = screen.getByLabelText('Email');
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
expect(emailInput).toHaveValue('test@example.com');
});
it('calls API and shows success message on valid submission', async () => {
(axiosInstance.post as any).mockResolvedValue({ status: 200 });
render(<ForgotPassword />);
const emailInput = screen.getByLabelText('Email');
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
const submitButton = screen.getByRole('button', { name: /Send Password Reset Link/i });
fireEvent.click(submitButton);
expect(submitButton).toBeDisabled();
expect(screen.getByText('Sending...')).toBeInTheDocument();
await waitFor(() => {
expect(axiosInstance.post).toHaveBeenCalledWith('password-reset/', { email: 'test@example.com' });
expect(screen.getByText('Password reset link has been sent to your email.')).toBeInTheDocument();
});
});
it('shows error message on API failure', async () => {
const errorMessage = 'User not found';
(axiosInstance.post as any).mockRejectedValue({
response: {
data: {
detail: errorMessage,
},
},
});
render(<ForgotPassword />);
const emailInput = screen.getByLabelText('Email');
fireEvent.change(emailInput, { target: { value: 'wrong@example.com' } });
const submitButton = screen.getByRole('button', { name: /Send Password Reset Link/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
});

View File

@@ -1,4 +1,5 @@
import { import {
Alert,
Button, Button,
FormControl, FormControl,
InputAdornment, InputAdornment,
@@ -10,12 +11,36 @@ import {
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import Image from 'components/base/Image'; import Image from 'components/base/Image';
import { Suspense } from 'react'; import { Suspense, useState } from 'react';
import forgotPassword from 'assets/authentication-banners/green.png'; import forgotPassword from 'assets/authentication-banners/green.png';
import IconifyIcon from 'components/base/IconifyIcon'; import IconifyIcon from 'components/base/IconifyIcon';
import logo from 'assets/logo/favicon-logo.png'; import logo from 'assets/logo/favicon-logo.png';
import { axiosInstance } from '../../axiosApi.js';
const ForgotPassword = () => { const ForgotPassword = () => {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleResetPassword = async () => {
setLoading(true);
setSuccessMessage(null);
setErrorMessage(null);
try {
await axiosInstance.post('password-reset/', { email });
setSuccessMessage('Password reset link has been sent to your email.');
} catch (error: any) {
setErrorMessage(
error.response?.data?.email?.[0] ||
error.response?.data?.detail ||
'An error occurred. Please try again.',
);
} finally {
setLoading(false);
}
};
return ( return (
<Stack <Stack
direction="row" direction="row"
@@ -30,14 +55,18 @@ const ForgotPassword = () => {
</Link> </Link>
<Stack alignItems="center" gap={6.5} width={330} mx="auto"> <Stack alignItems="center" gap={6.5} width={330} mx="auto">
<Typography variant="h3">Forgot Password</Typography> <Typography variant="h3">Forgot Password</Typography>
{successMessage && <Alert severity="success">{successMessage}</Alert>}
{errorMessage && <Alert severity="error">{errorMessage}</Alert>}
<FormControl variant="standard" fullWidth> <FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="new-password"> <InputLabel shrink htmlFor="email">
Email Email
</InputLabel> </InputLabel>
<TextField <TextField
variant="filled" variant="filled"
placeholder="Enter your email" placeholder="Enter your email"
id="email" id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
@@ -47,8 +76,13 @@ const ForgotPassword = () => {
}} }}
/> />
</FormControl> </FormControl>
<Button variant="contained" fullWidth> <Button
Send Password Reset Link variant="contained"
fullWidth
onClick={handleResetPassword}
disabled={loading || !email}
>
{loading ? 'Sending...' : 'Send Password Reset Link'}
</Button> </Button>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Back to{' '} Back to{' '}

View File

@@ -20,6 +20,7 @@ import { axiosInstance } from '../../axiosApi.js';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Form, Formik } from 'formik'; import { Form, Formik } from 'formik';
import { AuthContext } from 'contexts/AuthContext.js'; import { AuthContext } from 'contexts/AuthContext.js';
import paths from 'routes/paths';
type loginValues = { type loginValues = {
email: string; email: string;
@@ -50,7 +51,15 @@ const Login = (): ReactElement => {
navigate('/dashboard'); navigate('/dashboard');
} catch (error) { } catch (error) {
const hasErrors = Object.keys(error.response.data).length > 0; if (
error.response &&
error.response.status === 401 &&
error.response.data.detail === 'No active account found with the given credentials'
) {
navigate(paths.verifyAccount, { state: { email } });
return;
}
const hasErrors = error.response && Object.keys(error.response.data).length > 0;
if (hasErrors) { if (hasErrors) {
setErrorMessage(error.response.data); setErrorMessage(error.response.data);
} else { } else {

View File

@@ -0,0 +1,157 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import ResetPassword from './ResetPassword';
import { axiosInstance } from '../../axiosApi.js';
import { MemoryRouter } from 'react-router-dom';
// Mock axiosInstance
vi.mock('../../axiosApi.js', () => ({
axiosInstance: {
post: vi.fn(),
},
}));
// Mock useSearchParams
const mockGet = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useSearchParams: () => [{ get: mockGet }],
};
});
describe('ResetPassword', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGet.mockImplementation((key) => {
if (key === 'email') return 'test@example.com';
if (key === 'code') return '123456';
return null;
});
});
const renderComponent = () => {
render(
<MemoryRouter>
<ResetPassword />
</MemoryRouter>
);
};
it('renders correctly', () => {
renderComponent();
expect(screen.getByRole('heading', { name: /Reset Password/i })).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Reset Code')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Reset Password/i })).toBeInTheDocument();
});
it('updates inputs', () => {
renderComponent();
const emailInput = screen.getByLabelText('Email');
const codeInput = screen.getByLabelText('Reset Code');
const passwordInput = screen.getByLabelText('Password');
const confirmInput = screen.getByLabelText('Confirm Password');
fireEvent.change(emailInput, { target: { value: 'new@example.com' } });
fireEvent.change(codeInput, { target: { value: '654321' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.change(confirmInput, { target: { value: 'password123' } });
expect(emailInput).toHaveValue('new@example.com');
expect(codeInput).toHaveValue('654321');
expect(passwordInput).toHaveValue('password123');
expect(confirmInput).toHaveValue('password123');
});
it('shows error if passwords do not match', async () => {
renderComponent();
const passwordInput = screen.getByLabelText('Password');
const confirmInput = screen.getByLabelText('Confirm Password');
const submitButton = screen.getByRole('button', { name: /Reset Password/i });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.change(confirmInput, { target: { value: 'password456' } });
fireEvent.click(submitButton);
expect(screen.getByText("Passwords don't match")).toBeInTheDocument();
expect(axiosInstance.post).not.toHaveBeenCalled();
});
it('calls API and shows success message on valid submission', async () => {
(axiosInstance.post as any).mockResolvedValue({ status: 200 });
renderComponent();
// Email and Code are pre-filled from mock
const passwordInput = screen.getByLabelText('Password');
const confirmInput = screen.getByLabelText('Confirm Password');
const submitButton = screen.getByRole('button', { name: /Reset Password/i });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.change(confirmInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
expect(submitButton).toBeDisabled();
expect(screen.getByText('Resetting...')).toBeInTheDocument();
await waitFor(() => {
expect(axiosInstance.post).toHaveBeenCalledWith('password-reset/confirm/', {
email: 'test@example.com',
code: '123456',
new_password: 'password123',
new_password2: 'password123',
});
expect(screen.getByText('Reset Successfully')).toBeInTheDocument();
});
});
it('shows error message on API failure', async () => {
const errorMessage = 'Invalid token';
(axiosInstance.post as any).mockRejectedValue({
response: {
data: {
detail: errorMessage,
},
},
});
renderComponent();
const passwordInput = screen.getByLabelText('Password');
const confirmInput = screen.getByLabelText('Confirm Password');
const submitButton = screen.getByRole('button', { name: /Reset Password/i });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.change(confirmInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it('shows error if email or code is missing', async () => {
mockGet.mockReturnValue(null); // No URL params
renderComponent();
// Inputs should be empty
const emailInput = screen.getByLabelText('Email');
const codeInput = screen.getByLabelText('Reset Code');
expect(emailInput).toHaveValue('');
expect(codeInput).toHaveValue('');
const passwordInput = screen.getByLabelText('Password');
const confirmInput = screen.getByLabelText('Confirm Password');
const submitButton = screen.getByRole('button', { name: /Reset Password/i });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.change(confirmInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
expect(screen.getByText('Please provide both email and reset code.')).toBeInTheDocument();
expect(axiosInstance.post).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import { ReactElement, Suspense, useState } from 'react'; import { ReactElement, Suspense, useState } from 'react';
import { import {
Alert,
Button, Button,
FormControl, FormControl,
IconButton, IconButton,
@@ -17,30 +18,55 @@ import passwordUpdated from 'assets/authentication-banners/password-updated.png'
import successTick from 'assets/authentication-banners/successTick.png'; import successTick from 'assets/authentication-banners/successTick.png';
import Image from 'components/base/Image'; import Image from 'components/base/Image';
import IconifyIcon from 'components/base/IconifyIcon'; import IconifyIcon from 'components/base/IconifyIcon';
import PasswordStrengthChecker from '../../components/PasswordStrengthChecker'; import { useSearchParams } from 'react-router-dom';
import { axiosInstance } from '../../axiosApi.js';
const ResetPassword = (): ReactElement => { const ResetPassword = (): ReactElement => {
const [searchParams] = useSearchParams();
const [showNewPassword, setShowNewPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [email, setEmail] = useState(searchParams.get('email') || '');
const [code, setCode] = useState(searchParams.get('code') || '');
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [resetSuccessful, setResetSuccessful] = useState(false);
const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword); const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword);
const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword); const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword);
const [resetSuccessful, setResetSuccessful] = useState(false);
const handleResetPassword = () => { const handleResetPassword = async () => {
const passwordField: HTMLInputElement = document.getElementById( if (password !== confirmPassword) {
'new-password', setErrorMessage("Passwords don't match");
) as HTMLInputElement;
const confirmPasswordField: HTMLInputElement = document.getElementById(
'confirm-password',
) as HTMLInputElement;
if (passwordField.value !== confirmPasswordField.value) {
alert("Passwords don't match");
return; return;
} }
setResetSuccessful(true);
if (!email || !code) {
setErrorMessage('Please provide both email and reset code.');
return;
}
setLoading(true);
setErrorMessage(null);
try {
await axiosInstance.post('password-reset/confirm/', {
email,
code,
new_password: password,
new_password2: confirmPassword,
});
setResetSuccessful(true);
} catch (error: any) {
setErrorMessage(
error.response?.data?.detail ||
error.response?.data?.non_field_errors?.[0] ||
'An error occurred. Please try again.',
);
} finally {
setLoading(false);
}
}; };
return ( return (
@@ -58,6 +84,45 @@ const ResetPassword = (): ReactElement => {
{!resetSuccessful ? ( {!resetSuccessful ? (
<Stack alignItems="center" gap={3.75} width={330} mx="auto"> <Stack alignItems="center" gap={3.75} width={330} mx="auto">
<Typography variant="h3">Reset Password</Typography> <Typography variant="h3">Reset Password</Typography>
{errorMessage && <Alert severity="error">{errorMessage}</Alert>}
<FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="email">
Email
</InputLabel>
<TextField
variant="filled"
placeholder="Enter your email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconifyIcon icon="ic:baseline-email" />
</InputAdornment>
),
}}
/>
</FormControl>
<FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="code">
Reset Code
</InputLabel>
<TextField
variant="filled"
placeholder="Enter reset code"
id="code"
value={code}
onChange={(e) => setCode(e.target.value)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconifyIcon icon="ic:baseline-lock" />
</InputAdornment>
),
}}
/>
</FormControl>
<FormControl variant="standard" fullWidth> <FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="new-password"> <InputLabel shrink htmlFor="new-password">
Password Password
@@ -67,6 +132,7 @@ const ResetPassword = (): ReactElement => {
placeholder="Enter new password" placeholder="Enter new password"
type={showNewPassword ? 'text' : 'password'} type={showNewPassword ? 'text' : 'password'}
id="new-password" id="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
@@ -89,17 +155,18 @@ const ResetPassword = (): ReactElement => {
), ),
}} }}
/> />
{/*<PasswordStrengthChecker password={password} />*/}
</FormControl> </FormControl>
<FormControl variant="standard" fullWidth> <FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="confirm-password"> <InputLabel shrink htmlFor="confirm-password">
Password Confirm Password
</InputLabel> </InputLabel>
<TextField <TextField
variant="filled" variant="filled"
placeholder="Confirm password" placeholder="Confirm password"
type={showConfirmPassword ? 'text' : 'password'} type={showConfirmPassword ? 'text' : 'password'}
id="confirm-password" id="confirm-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
@@ -122,8 +189,13 @@ const ResetPassword = (): ReactElement => {
}} }}
/> />
</FormControl> </FormControl>
<Button variant="contained" fullWidth onClick={handleResetPassword}> <Button
Reset Password variant="contained"
fullWidth
onClick={handleResetPassword}
disabled={loading || !password || !confirmPassword}
>
{loading ? 'Resetting...' : 'Reset Password'}
</Button> </Button>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Back to{' '} Back to{' '}
@@ -140,7 +212,7 @@ const ResetPassword = (): ReactElement => {
<Stack alignItems="center" gap={3.75} width={330} mx="auto"> <Stack alignItems="center" gap={3.75} width={330} mx="auto">
<Image src={successTick} /> <Image src={successTick} />
<Typography variant="h3">Reset Successfully</Typography> <Typography variant="h3">Reset Successfully</Typography>
<Typography variant="body1" textAlign="center" color="text.secndary"> <Typography variant="body1" textAlign="center" color="text.secondary">
Your Ditch the Agent log in password has been updated successfully Your Ditch the Agent log in password has been updated successfully
</Typography> </Typography>
<Button variant="contained" fullWidth LinkComponent={Link} href="/authentication/login"> <Button variant="contained" fullWidth LinkComponent={Link} href="/authentication/login">

View File

@@ -0,0 +1,203 @@
import { ReactElement, Suspense, useState } from 'react';
import {
Alert,
Button,
FormControl,
InputAdornment,
InputLabel,
Link,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import loginBanner from 'assets/authentication-banners/green.png';
import IconifyIcon from 'components/base/IconifyIcon';
import logo from 'assets/logo/favicon-logo.png';
import Image from 'components/base/Image';
import { axiosInstance } from '../../axiosApi.js';
import { useNavigate, useLocation } from 'react-router-dom';
import { Form, Formik } from 'formik';
import paths from 'routes/paths';
type verifyValues = {
email: string;
code: string;
};
const VerifyAccount = (): ReactElement => {
const [errorMessage, setErrorMessage] = useState<any | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const navigate = useNavigate();
const location = useLocation();
const initialEmail = location.state?.email || '';
const handleVerify = async ({ email, code }: verifyValues): Promise<void> => {
try {
await axiosInstance.post('/check-passcode/', {
email: email,
code: code,
});
navigate('/');
} catch (error) {
const hasErrors = error.response && Object.keys(error.response.data).length > 0;
if (hasErrors) {
setErrorMessage(error.response.data);
} else {
setErrorMessage('An unexpected error occurred.');
}
}
};
return (
<Stack
direction="row"
bgcolor="background.paper"
boxShadow={(theme) => theme.shadows[3]}
height={560}
width={{ md: 960 }}
>
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
<Link href="/" height="fit-content">
<Image src={logo} width={82.6} />
</Link>
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
<Typography variant="h3">Verify Account</Typography>
<Formik
initialValues={{
email: initialEmail,
code: '',
}}
onSubmit={handleVerify}
enableReinitialize
>
{({ setFieldValue, values }) => (
<Form>
<FormControl variant="standard" fullWidth>
{successMessage ? (
<Alert severity="success" sx={{ mb: 2 }}>
<Typography>{successMessage}</Typography>
</Alert>
) : null}
{errorMessage ? (
<Alert severity="error">
{errorMessage.detail ? (
<Typography>{errorMessage.detail}</Typography>
) : (
<ul>
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
<li key={fieldName}>
<strong>{fieldName}</strong>
{Array.isArray(errorMessages) ? (
<ul>
{errorMessages.map((message, index) => (
<li key={`${fieldName}-${index}`}>{message}</li>
))}
</ul>
) : (
<span>: {String(errorMessages)}</span>
)}
</li>
))}
</ul>
)}
</Alert>
) : null}
<InputLabel shrink htmlFor="email">
Email
</InputLabel>
<TextField
variant="filled"
placeholder="Enter your email"
id="email"
value={values.email}
onChange={(event) => setFieldValue('email', event.target.value)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconifyIcon icon="ic:baseline-email" />
</InputAdornment>
),
}}
/>
</FormControl>
<FormControl variant="standard" fullWidth sx={{ mt: 2 }}>
<InputLabel shrink htmlFor="code">
Verification Code
</InputLabel>
<TextField
variant="filled"
placeholder="Enter verification code"
id="code"
value={values.code}
onChange={(event) => setFieldValue('code', event.target.value)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconifyIcon icon="ic:baseline-lock" />
</InputAdornment>
),
}}
/>
</FormControl>
<Button variant="contained" type={'submit'} fullWidth sx={{ mt: 3 }}>
Verify
</Button>
<Button
variant="text"
fullWidth
sx={{ mt: 1 }}
onClick={async () => {
setErrorMessage(null);
setSuccessMessage(null);
if (!values.email) {
setErrorMessage({ detail: 'Email is required to resend code.' });
return;
}
try {
await axiosInstance.post('/resend-registration-email/', {
email: values.email,
});
setSuccessMessage('Registration email sent.');
} catch (error) {
setErrorMessage({ detail: 'There was an error sending the verification link.' });
}
}}
>
Resend Verification Code
</Button>
</Form>
)}
</Formik>
<Typography variant="body2" color="text.secondary">
Back to{' '}
<Link
href={paths.login}
underline="hover"
fontSize={(theme) => theme.typography.body1.fontSize}
>
Login
</Link>
</Typography>
</Stack>
</Stack>
<Suspense
fallback={
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
}
>
<Image
alt="Login banner"
src={loginBanner}
sx={{
width: 0.5,
display: { xs: 'none', md: 'block' },
}}
/>
</Suspense>
</Stack>
);
};
export default VerifyAccount;

View File

@@ -29,6 +29,7 @@ export default {
home: `/${rootPaths.homeRoot}`, home: `/${rootPaths.homeRoot}`,
dashboard: `/${rootPaths.dashboardRoot}`, dashboard: `/${rootPaths.dashboardRoot}`,
login: `/${rootPaths.authRoot}/login`, login: `/${rootPaths.authRoot}/login`,
verifyAccount: `/${rootPaths.authRoot}/verify-account`,
signup: `/${rootPaths.authRoot}/sign-up`, signup: `/${rootPaths.authRoot}/sign-up`,
resetPassword: `/${rootPaths.authRoot}/reset-password`, resetPassword: `/${rootPaths.authRoot}/reset-password`,
forgotPassword: `/${rootPaths.authRoot}/forgot-password`, forgotPassword: `/${rootPaths.authRoot}/forgot-password`,

View File

@@ -76,6 +76,7 @@ const Error404 = lazy(async () => {
}); });
const Login = lazy(async () => import('pages/authentication/Login')); const Login = lazy(async () => import('pages/authentication/Login'));
const VerifyAccount = lazy(async () => import('pages/authentication/VerifyAccount'));
const SignUp = lazy(async () => import('pages/authentication/SignUp')); const SignUp = lazy(async () => import('pages/authentication/SignUp'));
const ResetPassword = lazy(async () => import('pages/authentication/ResetPassword')); const ResetPassword = lazy(async () => import('pages/authentication/ResetPassword'));
@@ -179,6 +180,10 @@ const routes: RouteObject[] = [
path: paths.login, path: paths.login,
element: <Login />, element: <Login />,
}, },
{
path: paths.verifyAccount,
element: <VerifyAccount />,
},
{ {
path: paths.signup, path: paths.signup,
element: <SignUp />, element: <SignUp />,

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@@ -786,5 +786,8 @@ export interface SupportCaseApi {
messages: SupportMessageApi[]; messages: SupportMessageApi[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
user_email: string;
user_first_name: string;
user_last_name: string;
} }
// Walk Score API Type Definitions // Walk Score API Type Definitions

View File

@@ -16,4 +16,9 @@ export default defineConfig({
port: 3000, port: 3000,
}, },
base: '/', base: '/',
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
},
}); });