Fix password reset UI (pairs with backend #1) #40

Merged
westfarn merged 1 commits from feature/password-reset-1 into master 2026-07-27 04:42:36 -07:00
5 changed files with 507 additions and 332 deletions
@@ -1,187 +1,263 @@
import { Form, Formik } from 'formik';
import React from 'react';
import CustomPasswordField from '../../components/CustomPasswordField/CustomPasswordField';
import { Stack, Typography } from '@mui/material';
import { axiosInstance } from '../../../axiosApi';
import { Form, Formik, Field } from 'formik';
import React, { useEffect, useState } from 'react';
import { cleanAxiosInstance } from '../../../axiosApi';
import { useNavigate, useSearchParams } from 'react-router-dom';
import CustomToastMessage from '../../components/CustomToastMessage/CustomeToastMessage';
import ParticleBackground from '../ParticleBackground/ParticleBackground';
import styled from 'styled-components';
import * as Yup from 'yup';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import PageWrapperLayout from '../PageWrapperLayout/PageWrapperLayout';
import MDBox from '../../ui-kit/components/MDBox';
import { Card, CardContent } from '@mui/material';
import MDTypography from '../../ui-kit/components/MDTypography';
import MDButton from '../../ui-kit/components/MDButton';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
const PageContainer = styled.div`
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
color: #fff;
font-family: 'Inter', sans-serif;
`;
const ContentWrapper = styled.div`
flex: 1;
display: flex;
justify-content: center;
align-items: center;
z-index: 5;
padding: 2rem;
`;
const GlassCard = styled.div`
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 3rem;
width: 100%;
max-width: 450px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
display: flex;
flex-direction: column;
align-items: center;
`;
const CardTitle = styled.h2`
font-size: 2rem;
margin-bottom: 2rem;
color: #fff;
text-align: center;
`;
const StyledInput = styled.input`
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
padding: 1rem;
color: #fff;
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
margin-bottom: 1.5rem;
&:focus {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(100, 149, 237, 0.5);
box-shadow: 0 0 10px rgba(100, 149, 237, 0.2);
}
&::placeholder {
color: rgba(255, 255, 255, 0.5);
}
`;
const StyledButton = styled.button`
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 0.5rem;
color: #fff;
padding: 1rem;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
width: 100%;
margin-top: 1rem;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.4);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
`;
const HintList = styled.ul`
list-style: none;
padding: 0;
margin: 0 0 0.5rem;
width: 100%;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
`;
const HintItem = styled.li<{ $ok: boolean }>`
margin-bottom: 0.35rem;
color: ${(props) => (props.$ok ? '#7dcea0' : 'rgba(255, 255, 255, 0.55)')};
`;
const ErrorMessage = styled.div`
color: #ff6b6b;
margin-bottom: 1rem;
text-align: center;
background: rgba(255, 107, 107, 0.1);
padding: 0.5rem;
border-radius: 0.5rem;
width: 100%;
`;
export type PasswordResetValues = {
password1: string;
password2: string;
password1: string;
password2: string;
};
const initialValues = { password1: '', password2: '' }
const validationSchema = Yup.object().shape({
password1: Yup.string().min(6, "Passwords have to be at least 6 digits").required(),
password2: Yup.string().min(6, "Passwords have to be at least 6 digits").required().oneOf([Yup.ref('password1')], "Passwords must match"),
})
const contains_number = (item: string): boolean => {
const numbers = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
const hasNumber = numbers.some((character) => item.includes(character));
if (hasNumber) {
return true;
}
return false;
}
const contains_special_character = (item: string): boolean => {
const specialCharacters = ['!', '@', '#', '$', ',%', '^', '&', '*', '(', ')', '-', '_', '=', '+', '/', '*', '\\', '|', '`', '~', '<', '>', '.', '?'];
const hasSpecialChacater = specialCharacters.some((character) => item.includes(character));
if (hasSpecialChacater) {
return true;
}
return false;
}
password1: Yup.string()
.min(8, 'Passwords must be at least 8 characters')
.matches(/[0-9]/, 'Must include a number')
.matches(/[^A-Za-z0-9]/, 'Must include a special character')
.required('Required'),
password2: Yup.string()
.oneOf([Yup.ref('password1')], 'Passwords must match')
.required('Required'),
});
const SetPassword = (): JSX.Element => {
const navigate = useNavigate();
// see if the user is allowed to come here first
const [queryParameters] = useSearchParams()
console.log(queryParameters.get("slug"))
const slug = queryParameters.get("slug");
const navigate = useNavigate();
const [queryParameters] = useSearchParams();
const slug = queryParameters.get('slug');
const [linkValid, setLinkValid] = useState(false);
const [checking, setChecking] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
let cancelled = false;
try {
// make sure it comes back as 200 for a good request. Else go to the homepage
axiosInstance.get(`user/set_password/${slug}`)
} catch {
navigate('/')
}
const handleSetPassword = ({ password1, password2 }: PasswordResetValues): void => {
try {
// verify
if (password1 === password2) {
axiosInstance.post(`user/set_password/${slug}/`, {
'password': password1,
});
navigate('/')
}
} catch (error) {
console.log('catching the error');
<CustomToastMessage message={error as string} />
(async () => {
if (!slug) {
navigate('/signin/');
return;
}
try {
await cleanAxiosInstance.get(`user/set_password/${slug}/`);
if (!cancelled) {
setLinkValid(true);
}
} catch {
if (!cancelled) {
navigate('/signin/');
}
} finally {
if (!cancelled) {
setChecking(false);
}
}
})();
return () => {
cancelled = true;
};
}, [slug, navigate]);
const handleSetPassword = async ({
password1,
}: PasswordResetValues): Promise<void> => {
setErrorMessage('');
try {
await cleanAxiosInstance.post(`user/set_password/${slug}/`, {
password: password1,
});
navigate('/signin/');
} catch {
setErrorMessage('Unable to set password. Please try again.');
}
};
if (checking || !linkValid) {
return (
<PageWrapperLayout>
<MDBox sx={{
height: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
bgcolor: 'background.default',
position: 'relative'
}}>
<ParticleBackground />
<MDBox sx={{ width: '100%', maxWidth: '400px', zIndex: 1, position: 'relative' }}>
<Card>
<CardContent>
<MDTypography variant="h4" textAlign="center">
Set your password
</MDTypography>
</CardContent>
<div className='card-body text-center'>
<Formik
initialValues={initialValues}
onSubmit={handleSetPassword}
validateOnMount
validationSchema={validationSchema}>
{(formik) => (
<Form>
<div className='row'>
<div className='col'>
<CustomPasswordField
label='Password'
name="password1"
changeHandler={(e) => formik.setFieldValue('password1', e.target.value)} />
</div>
</div>
<div className='row'>
<div className='col'>
<CustomPasswordField
label='Confirm Password'
name="password2"
changeHandler={(e) => formik.setFieldValue('password2', e.target.value)} />
</div>
</div>
<div>
<Stack alignItems="center" direction="row" gap={2}>
{formik.values.password1 === formik.values.password2 ? <CheckCircleIcon fontSize='small' color='success' /> : <ErrorIcon fontSize='small' color='warning' />}
<Typography variant="body1">Passwords Match</Typography>
</Stack>
</div>
<div>
<Stack alignItems="center" direction="row" gap={2}>
{formik.values.password1.length > 5 ? <CheckCircleIcon fontSize='small' color='success' /> : <ErrorIcon fontSize='small' color='warning' />}
<Typography variant="body1">At least 6 characters</Typography>
</Stack>
</div>
<div>
<Stack alignItems="center" direction="row" gap={2}>
{contains_special_character(formik.values.password1) ? <CheckCircleIcon fontSize='small' color='success' /> : <ErrorIcon fontSize='small' color='warning' />}
<Typography variant="body1">At least one special character</Typography>
</Stack>
</div>
<div>
<Stack alignItems="center" direction="row" gap={2}>
{contains_number(formik.values.password1) ? <CheckCircleIcon fontSize='small' color='success' /> : <ErrorIcon fontSize='small' color='warning' />}
<Typography variant="body1">At least one number</Typography>
</Stack>
</div>
<div className='row'>
<div className='col'>
<MDButton
type={'submit'}
fullWidth
disabled={!formik.isValid || formik.isSubmitting ||
!contains_special_character(formik.values.password1)
|| !contains_number(formik.values.password1)
}
// type={'submit'}
// loading={formik.isSubmitting}
// disabled={
// !formik.isValid || !formik.dirty || formik.isSubmitting
// }
>
Set password
</MDButton>
</div>
</div>
</Form>
)}
</Formik>
</div>
</Card>
</MDBox>
</MDBox>
</PageWrapperLayout>
<PageContainer>
<ParticleBackground />
<ContentWrapper>
<GlassCard>
<CardTitle>Loading</CardTitle>
</GlassCard>
</ContentWrapper>
</PageContainer>
);
}
return (
<PageContainer>
<ParticleBackground />
<ContentWrapper>
<GlassCard>
<CardTitle>Set Your Password</CardTitle>
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
<Formik
initialValues={{ password1: '', password2: '' }}
onSubmit={handleSetPassword}
validationSchema={validationSchema}
validateOnMount
>
{(formik) => (
<Form style={{ width: '100%' }}>
<Field
as={StyledInput}
name="password1"
placeholder="New Password"
type="password"
/>
<Field
as={StyledInput}
name="password2"
placeholder="Confirm Password"
type="password"
/>
<HintList>
<HintItem $ok={formik.values.password1.length >= 8}>
At least 8 characters
</HintItem>
<HintItem $ok={/[0-9]/.test(formik.values.password1)}>
At least one number
</HintItem>
<HintItem $ok={/[^A-Za-z0-9]/.test(formik.values.password1)}>
At least one special character
</HintItem>
<HintItem
$ok={
formik.values.password1.length > 0 &&
formik.values.password1 === formik.values.password2
}
>
Passwords match
</HintItem>
</HintList>
<StyledButton
type="submit"
disabled={!formik.isValid || formik.isSubmitting}
>
Set Password
</StyledButton>
</Form>
)}
</Formik>
</GlassCard>
</ContentWrapper>
</PageContainer>
);
};
export default SetPassword;
export default SetPassword;
@@ -0,0 +1,86 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import PasswordReset from './PasswordReset';
const mockPost = jest.fn();
const mockExecuteAsync = jest.fn();
const mockReset = jest.fn();
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('react-google-recaptcha', () => {
const ReactLib = require('react');
return ReactLib.forwardRef(
(
_props: unknown,
ref: React.Ref<{ executeAsync: () => Promise<string>; reset: () => void }>
) => {
ReactLib.useImperativeHandle(ref, () => ({
executeAsync: mockExecuteAsync,
reset: mockReset,
}));
return <div data-testid="recaptcha" />;
}
);
});
jest.mock('../../../axiosApi', () => ({
cleanAxiosInstance: {
post: (...args: unknown[]) => mockPost(...args),
},
}));
const renderPasswordReset = () =>
render(
<MemoryRouter initialEntries={['/password_reset/']}>
<Routes>
<Route path="/password_reset/" element={<PasswordReset />} />
<Route
path="/password_reset_confirmation/"
element={<div>Confirmation page</div>}
/>
</Routes>
</MemoryRouter>
);
describe('PasswordReset', () => {
beforeEach(() => {
mockPost.mockReset();
mockExecuteAsync.mockReset();
mockReset.mockReset();
mockExecuteAsync.mockResolvedValue('captcha-token');
mockPost.mockResolvedValue({ status: 200 });
});
it('submits email with captcha token and navigates to confirmation', async () => {
const user = userEvent.setup();
renderPasswordReset();
await user.type(screen.getByPlaceholderText('Email Address'), 'user@example.com');
await user.click(screen.getByRole('button', { name: 'Reset Password' }));
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('user/reset_password/', {
email: 'user@example.com',
recaptchaToken: 'captcha-token',
});
});
expect(await screen.findByText('Confirmation page')).toBeInTheDocument();
});
it('shows error when request fails', async () => {
mockPost.mockRejectedValue(new Error('network'));
const user = userEvent.setup();
renderPasswordReset();
await user.type(screen.getByPlaceholderText('Email Address'), 'user@example.com');
await user.click(screen.getByRole('button', { name: 'Reset Password' }));
expect(
await screen.findByText('Unable to reset password. Please try again.')
).toBeInTheDocument();
expect(screen.queryByText('Confirmation page')).not.toBeInTheDocument();
});
});
@@ -1,12 +1,12 @@
import { Form, Formik, Field } from 'formik';
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import { cleanAxiosInstance } from '../../../axiosApi';
import { useNavigate } from 'react-router-dom';
import ReCAPTCHA from 'react-google-recaptcha';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import styled from 'styled-components';
import * as Yup from 'yup';
// Styled Components
const PageContainer = styled.div`
position: relative;
width: 100vw;
@@ -43,11 +43,19 @@ const GlassCard = styled.div`
const CardTitle = styled.h2`
font-size: 2rem;
margin-bottom: 2rem;
margin-bottom: 1rem;
color: #fff;
text-align: center;
`;
const CardSubtitle = styled.p`
color: rgba(255, 255, 255, 0.65);
text-align: center;
margin-bottom: 2rem;
font-size: 0.95rem;
line-height: 1.4;
`;
const StyledInput = styled.input`
width: 100%;
background: rgba(255, 255, 255, 0.05);
@@ -111,134 +119,92 @@ const BackLink = styled.button`
}
`;
export type PasswordResetValues = {
password1: string;
password2: string;
};
const ErrorMessage = styled.div`
color: #ff6b6b;
margin-bottom: 1rem;
text-align: center;
background: rgba(255, 107, 107, 0.1);
padding: 0.5rem;
border-radius: 0.5rem;
width: 100%;
`;
export type EmailPasswordResetValues = {
email: string;
}
const PasswordReset = (): JSX.Element => {
const navigate = useNavigate();
const recaptchaRef = useRef<ReCAPTCHA>(null);
const handlePasswordResetEmail = ({ email }: EmailPasswordResetValues): void => {
if (recaptchaRef.current) {
const token = recaptchaRef.current.getValue();
if (!token) { // This logic seems inverted in original code? "if (!token)" usually means no token.
// But original code had "if (!token)" then try... catch.
// Wait, if !token, it means user didn't solve captcha?
// Or maybe invisible captcha returns token immediately?
// Let's assume the original logic was trying to say "if token is present" but maybe had a bug or I'm misreading.
// Actually, looking at original code:
// if (recaptchaRef.current) {
// const token = recaptchaRef.current.getValue();
// if (!token) { ... try { post ... } }
// }
// This implies it posts ONLY if token is falsy? That's weird for a captcha.
// Invisible captcha might need execution.
// Let's stick to the logic but maybe fix it if it looks obviously wrong.
// Standard reCAPTCHA flow: execute -> get token -> send token.
// If size="invisible", we might need to execute it manually or it executes on submit.
// I'll keep the structure but assume we want to send the token if we have it.
// Actually, let's look at the original code again.
// if (!token) { ... }
// This is very strange. It sends the request if there is NO token?
// Maybe it was a bypass for dev?
// I will assume the user wants the captcha to work.
// I'll try to get the token, if it exists, send it.
try {
cleanAxiosInstance.post('user/reset_password',
{
'email': email,
'recaptchaToken': token || "dummy_token_if_logic_was_inverted" // preserving original weirdness slightly but making it safer?
}
);
// navigate to another page now
navigate('/password_reset_confirmation')
} catch (error) {
console.log('error')
}
} else {
// If token exists, we should probably send it too?
// The original code ONLY sent if !token.
// I will correct this to send if token exists OR if the original intent was to just send it.
// Let's just send the request.
try {
cleanAxiosInstance.post('user/reset_password',
{
'email': email,
'recaptchaToken': token
}
);
navigate('/password_reset_confirmation')
} catch (error) {
console.log('error')
}
}
} else {
// Fallback if ref is null
try {
cleanAxiosInstance.post('user/reset_password',
{
'email': email,
'recaptchaToken': ''
}
);
navigate('/password_reset_confirmation')
} catch (error) {
console.log('error')
}
}
}
return (
<PageContainer>
<ParticleBackground />
<ContentWrapper>
<GlassCard>
<CardTitle>Reset Password</CardTitle>
<Formik
initialValues={{
email: '',
}}
onSubmit={handlePasswordResetEmail}
>
{(formik) => (
<Form style={{ width: '100%' }}>
<Field
as={StyledInput}
name="email"
placeholder="Email Address"
type="email"
/>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '1rem' }}>
<ReCAPTCHA
ref={recaptchaRef}
sitekey="6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ"
size="invisible"
theme="dark"
/>
</div>
<StyledButton type="submit" disabled={formik.isSubmitting}>
Reset Password
</StyledButton>
</Form>
)}
</Formik>
<BackLink onClick={() => navigate('/sign_in')}>
Back to Sign In
</BackLink>
</GlassCard>
</ContentWrapper>
</PageContainer>
);
email: string;
};
export default PasswordReset;
const validationSchema = Yup.object().shape({
email: Yup.string().email('Invalid email').required('Required'),
});
const PasswordReset = (): JSX.Element => {
const navigate = useNavigate();
const recaptchaRef = useRef<ReCAPTCHA>(null);
const [errorMessage, setErrorMessage] = useState('');
const handlePasswordResetEmail = async ({
email,
}: EmailPasswordResetValues): Promise<void> => {
setErrorMessage('');
try {
let token = '';
if (recaptchaRef.current) {
token = (await recaptchaRef.current.executeAsync()) || '';
recaptchaRef.current.reset();
}
await cleanAxiosInstance.post('user/reset_password/', {
email,
recaptchaToken: token,
});
navigate('/password_reset_confirmation/');
} catch {
setErrorMessage('Unable to reset password. Please try again.');
}
};
return (
<PageContainer>
<ParticleBackground />
<ContentWrapper>
<GlassCard>
<CardTitle>Reset Password</CardTitle>
<CardSubtitle>
Enter your email and we will send a link to set a new password.
</CardSubtitle>
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
<Formik
initialValues={{ email: '' }}
onSubmit={handlePasswordResetEmail}
validationSchema={validationSchema}
>
{(formik) => (
<Form style={{ width: '100%' }}>
<Field
as={StyledInput}
name="email"
placeholder="Email Address"
type="email"
/>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<ReCAPTCHA
ref={recaptchaRef}
sitekey="6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ"
size="invisible"
theme="dark"
/>
</div>
<StyledButton type="submit" disabled={formik.isSubmitting}>
Reset Password
</StyledButton>
</Form>
)}
</Formik>
<BackLink onClick={() => navigate('/signin/')}>Back to Sign In</BackLink>
</GlassCard>
</ContentWrapper>
</PageContainer>
);
};
export default PasswordReset;
@@ -1,40 +1,87 @@
import React from 'react';
import { Card, CardContent, Divider } from '@mui/material';
import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout';
import MDBox from '../../ui-kit/components/MDBox';
import background from '../../../bg.jpeg'
import { Col, Row } from 'react-bootstrap';
import MDTypography from '../../ui-kit/components/MDTypography';
import { useNavigate } from 'react-router-dom';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import styled from 'styled-components';
const PageContainer = styled.div`
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
color: #fff;
font-family: 'Inter', sans-serif;
`;
const ContentWrapper = styled.div`
flex: 1;
display: flex;
justify-content: center;
align-items: center;
z-index: 5;
padding: 2rem;
`;
const GlassCard = styled.div`
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 3rem;
width: 100%;
max-width: 450px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
`;
const CardTitle = styled.h2`
font-size: 2rem;
margin-bottom: 1rem;
color: #fff;
`;
const CardBody = styled.p`
color: rgba(255, 255, 255, 0.75);
font-size: 1rem;
line-height: 1.5;
margin-bottom: 1.5rem;
`;
const BackLink = styled.button`
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
margin-top: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
transition: color 0.2s ease;
&:hover {
color: #fff;
text-decoration: underline;
}
`;
const PasswordResetConfirmation = (): JSX.Element => {
const navigate = useNavigate();
return (
<PageWrapperLayout>
<MDBox sx={{'height': '100%', minHeight: '100vh', display: 'flex', flexDirection: 'column', backgroundImage: `url(${background})`,backgroundSize: "cover",
backgroundRepeat: "no-repeat",}}>
<MDBox sx={{ margin: '0 auto', width: '80%', height: '80%', minHeight: '80%', maxHeight: '80%', align:'center'}}>
<Row>
<Col className='col -lg-4 col-md-8 col-12 mx-auto'>
<Card sx={{mt:30}} >
<CardContent>
<MDTypography variant="h3">
Reset Password Confirmation
</MDTypography>
</CardContent>
<Divider />
<MDTypography>
Check your email for a link to set your password!
</MDTypography>
</Card>
</Col>
</Row>
</MDBox>
</MDBox>
</PageWrapperLayout>
<PageContainer>
<ParticleBackground />
<ContentWrapper>
<GlassCard>
<CardTitle>Check Your Email</CardTitle>
<CardBody>
If an account exists for that address, we sent a link to set a new password.
</CardBody>
<BackLink onClick={() => navigate('/signin/')}>Back to Sign In</BackLink>
</GlassCard>
</ContentWrapper>
</PageContainer>
);
};
+1 -1
View File
@@ -255,7 +255,7 @@ const SignIn = (): JSX.Element => {
</Form>
)}
</Formik>
<ForgotPasswordLink onClick={() => navigate('/password_reset')}>
<ForgotPasswordLink onClick={() => navigate('/password_reset/')}>
Forgot Password?
</ForgotPasswordLink>
{registrationEnabled && (