From 400549268c67ad1c31c4ea975170da7cc3418fa7 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 27 Jul 2026 04:42:36 -0700 Subject: [PATCH] Fix password reset UI (pairs with backend #1) (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Pair with chat_backend password reset (#1) - Fix forgot-password request: execute invisible reCAPTCHA, POST `user/reset_password/` with await, correct `/signin/` back link - Restyle confirmation + set-password pages to match Sign In glass UI - Set-password: validate link via GET, await POST, enforce ≥8 chars + number + special char - Sign In “Forgot Password?” navigates to `/password_reset/` ## Test plan - [ ] `npm test -- --watchAll=false --testPathPattern=PasswordReset.test` - [ ] From Sign In → Forgot Password → submit email → confirmation page - [ ] Open email link `/set_password/?slug=…` → set password → redirected to Sign In - [ ] Invalid/expired slug redirects to Sign In - [ ] End-to-end with backend `feature/password-reset-1`Reviewed-on: https://git.aimloperations.com/ai_ml_operations/chat_web_app/pulls/40 --- .../components/SetPassword/SetPassword.tsx | 412 +++++++++++------- .../PasswordReset/PasswordReset.test.tsx | 86 ++++ .../pages/PasswordReset/PasswordReset.tsx | 226 ++++------ .../PasswordReset.tsx | 113 +++-- llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx | 2 +- 5 files changed, 507 insertions(+), 332 deletions(-) create mode 100644 llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.test.tsx diff --git a/llm-fe/src/llm-fe/components/SetPassword/SetPassword.tsx b/llm-fe/src/llm-fe/components/SetPassword/SetPassword.tsx index 8c5b3e5..010e3a6 100644 --- a/llm-fe/src/llm-fe/components/SetPassword/SetPassword.tsx +++ b/llm-fe/src/llm-fe/components/SetPassword/SetPassword.tsx @@ -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'); - - + (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 => { + 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 ( - - - - - - - - - Set your password - - -
- - {(formik) => ( -
-
-
- formik.setFieldValue('password1', e.target.value)} /> - -
-
-
-
- formik.setFieldValue('password2', e.target.value)} /> - -
-
-
- - {formik.values.password1 === formik.values.password2 ? : } - - Passwords Match - -
-
- - {formik.values.password1.length > 5 ? : } - At least 6 characters - -
-
- - {contains_special_character(formik.values.password1) ? : } - At least one special character - -
-
- - - {contains_number(formik.values.password1) ? : } - At least one number - -
-
-
- - Set password - - - -
-
- -
- )} - -
-
-
-
-
-
+ + + + + Loading… + + + ); + } + + return ( + + + + + Set Your Password + {errorMessage && {errorMessage}} + + {(formik) => ( +
+ + + + = 8}> + At least 8 characters + + + At least one number + + + At least one special character + + 0 && + formik.values.password1 === formik.values.password2 + } + > + Passwords match + + + + Set Password + + + )} +
+
+
+
+ ); }; -export default SetPassword; \ No newline at end of file +export default SetPassword; diff --git a/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.test.tsx b/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.test.tsx new file mode 100644 index 0000000..2691d90 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.test.tsx @@ -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; reset: () => void }> + ) => { + ReactLib.useImperativeHandle(ref, () => ({ + executeAsync: mockExecuteAsync, + reset: mockReset, + })); + return
; + } + ); +}); + +jest.mock('../../../axiosApi', () => ({ + cleanAxiosInstance: { + post: (...args: unknown[]) => mockPost(...args), + }, +})); + +const renderPasswordReset = () => + render( + + + } /> + Confirmation page
} + /> + + + ); + +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(); + }); +}); diff --git a/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.tsx b/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.tsx index 9a6d7f6..2435fa2 100644 --- a/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.tsx +++ b/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.tsx @@ -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(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 ( - - - - - Reset Password - - {(formik) => ( -
- -
- -
- - Reset Password - - - )} -
- navigate('/sign_in')}> - Back to Sign In - -
-
-
- ); + email: string; }; -export default PasswordReset; \ No newline at end of file +const validationSchema = Yup.object().shape({ + email: Yup.string().email('Invalid email').required('Required'), +}); + +const PasswordReset = (): JSX.Element => { + const navigate = useNavigate(); + const recaptchaRef = useRef(null); + const [errorMessage, setErrorMessage] = useState(''); + + const handlePasswordResetEmail = async ({ + email, + }: EmailPasswordResetValues): Promise => { + 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 ( + + + + + Reset Password + + Enter your email and we will send a link to set a new password. + + {errorMessage && {errorMessage}} + + {(formik) => ( +
+ +
+ +
+ + Reset Password + + + )} +
+ navigate('/signin/')}>Back to Sign In +
+
+
+ ); +}; + +export default PasswordReset; diff --git a/llm-fe/src/llm-fe/pages/PasswordResetConfirmation/PasswordReset.tsx b/llm-fe/src/llm-fe/pages/PasswordResetConfirmation/PasswordReset.tsx index 7c5ebba..6a4b731 100644 --- a/llm-fe/src/llm-fe/pages/PasswordResetConfirmation/PasswordReset.tsx +++ b/llm-fe/src/llm-fe/pages/PasswordResetConfirmation/PasswordReset.tsx @@ -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 ( - - - - - - - - - - Reset Password Confirmation - - - - - Check your email for a link to set your password! - - - - - - - - - + + + + + Check Your Email + + If an account exists for that address, we sent a link to set a new password. + + navigate('/signin/')}>Back to Sign In + + + ); }; diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx index 533e917..1d2c394 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx @@ -255,7 +255,7 @@ const SignIn = (): JSX.Element => { )} - navigate('/password_reset')}> + navigate('/password_reset/')}> Forgot Password? {registrationEnabled && (