From 876a431a34516946bc35a013a1d50d247a0e1abe Mon Sep 17 00:00:00 2001
From: nourshoreibah
Date: Sun, 30 Aug 2026 15:45:17 -0400
Subject: [PATCH 1/2] fix(frontend): let users enter password-reset codes
Cognito emails a verification code, not a link. The profile
and forgot-password UIs claimed a link was sent and had
nowhere to type the code.
Co-authored-by: Cursor
---
.../src/app/components/PasswordResetModal.tsx | 246 ++++++++++++++++++
.../src/app/components/SetPasswordForm.tsx | 41 ++-
.../frontend/src/app/forgot-password/page.tsx | 88 +++++--
apps/frontend/src/app/profile/page.tsx | 13 +-
.../components/ForgotPasswordPage.test.tsx | 69 +++++
.../test/components/ProfilePage.test.tsx | 59 ++++-
6 files changed, 482 insertions(+), 34 deletions(-)
create mode 100644 apps/frontend/src/app/components/PasswordResetModal.tsx
create mode 100644 apps/frontend/test/components/ForgotPasswordPage.test.tsx
diff --git a/apps/frontend/src/app/components/PasswordResetModal.tsx b/apps/frontend/src/app/components/PasswordResetModal.tsx
new file mode 100644
index 00000000..99602932
--- /dev/null
+++ b/apps/frontend/src/app/components/PasswordResetModal.tsx
@@ -0,0 +1,246 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { CloseButton, Dialog, Portal } from '@chakra-ui/react';
+import Button from './Button';
+import ShowPasswordCheckbox from './ShowPasswordCheckbox';
+import TextInputField from './TextInputField';
+import { PASSWORD_RULE, PASSWORD_RULE_MESSAGE } from './SetPasswordForm';
+import { useApi } from '@/hooks/useApi';
+
+/** The design tints the header and footer with Core Black/100 at 50%. */
+const CHROME_BG =
+ 'color-mix(in srgb, var(--color-black-100) 50%, var(--color-core-white))';
+
+interface PasswordResetModalProps {
+ open: boolean;
+ email: string;
+ onClose: () => void;
+ onSuccess: () => void;
+}
+
+/**
+ * Completes Cognito's forgot-password flow from the profile page. The pool
+ * emails a code (`CONFIRM_WITH_CODE`), not a link, so this is the only place a
+ * signed-in user can type that code — `/reset-password` is a public route and
+ * AuthGate would bounce them off it.
+ */
+export default function PasswordResetModal({
+ open,
+ email,
+ onClose,
+ onSuccess,
+}: PasswordResetModalProps) {
+ const api = useApi();
+
+ const [code, setCode] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+
+ const [codeError, setCodeError] = useState('');
+ const [newPasswordError, setNewPasswordError] = useState('');
+ const [confirmPasswordError, setConfirmPasswordError] = useState('');
+ const [error, setError] = useState(null);
+ const [isConfirming, setIsConfirming] = useState(false);
+ const [isResending, setIsResending] = useState(false);
+ const isBusy = isConfirming || isResending;
+
+ const reset = useCallback(() => {
+ setCode('');
+ setNewPassword('');
+ setConfirmPassword('');
+ setShowPassword(false);
+ setCodeError('');
+ setNewPasswordError('');
+ setConfirmPasswordError('');
+ setError(null);
+ setIsConfirming(false);
+ setIsResending(false);
+ }, []);
+
+ useEffect(() => {
+ if (!open) reset();
+ }, [open, reset]);
+
+ function handleClose() {
+ reset();
+ onClose();
+ }
+
+ function validate(): boolean {
+ let valid = true;
+ if (!code.trim()) {
+ setCodeError('Please enter the verification code from your email');
+ valid = false;
+ } else {
+ setCodeError('');
+ }
+ if (!newPassword || !PASSWORD_RULE.test(newPassword)) {
+ setNewPasswordError(PASSWORD_RULE_MESSAGE);
+ valid = false;
+ } else {
+ setNewPasswordError('');
+ }
+ if (newPassword !== confirmPassword) {
+ setConfirmPasswordError('Password does not match');
+ valid = false;
+ } else {
+ setConfirmPasswordError('');
+ }
+ return valid;
+ }
+
+ async function confirmReset() {
+ if (!validate()) return;
+ setError(null);
+ setIsConfirming(true);
+ try {
+ await api.post('/auth/reset-password', {
+ email,
+ code: code.trim(),
+ newPassword,
+ });
+ onSuccess();
+ handleClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not reset your password');
+ } finally {
+ setIsConfirming(false);
+ }
+ }
+
+ async function resendCode() {
+ setError(null);
+ setIsResending(true);
+ try {
+ await api.post('/auth/forgot-password', { email });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not resend the code');
+ } finally {
+ setIsResending(false);
+ }
+ }
+
+ return (
+ {
+ if (!e.open) handleClose();
+ }}
+ scrollBehavior="inside"
+ >
+
+
+
+
+
+
+ Reset Password
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/app/components/SetPasswordForm.tsx b/apps/frontend/src/app/components/SetPasswordForm.tsx
index 6f61acf2..d1de4923 100644
--- a/apps/frontend/src/app/components/SetPasswordForm.tsx
+++ b/apps/frontend/src/app/components/SetPasswordForm.tsx
@@ -8,9 +8,10 @@ import { Button } from '@chakra-ui/react';
/**
* Reusable "new password + confirm" pair.
*
- * Used by both the reset-password flow and the NEW_PASSWORD_REQUIRED step of
- * login, so the validation rules live in exactly one place. Replaces the old
- * NewPasswordForm, which was uncontrolled, unvalidated and imported nowhere.
+ * Used by the reset-password and forgot-password flows and the
+ * NEW_PASSWORD_REQUIRED step of login, so the validation rules live in
+ * exactly one place. Replaces the old NewPasswordForm, which was
+ * uncontrolled, unvalidated and imported nowhere.
*/
export const PASSWORD_RULE = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/;
@@ -19,12 +20,17 @@ export const PASSWORD_RULE_MESSAGE =
'Password must be at least 8 characters with uppercase, lowercase, number, and symbol';
interface SetPasswordFormProps {
- onSubmit: (newPassword: string) => Promise | void;
+ onSubmit: (newPassword: string, code?: string) => Promise | void;
heading?: string;
submitLabel?: string;
/** Server-side error surfaced by the caller. */
error?: string | null;
isLoading?: boolean;
+ /**
+ * Cognito's forgot-password email carries a code, not a link. When true, the
+ * form collects that code and passes it as the second argument to `onSubmit`.
+ */
+ includeCode?: boolean;
}
export default function SetPasswordForm({
@@ -33,7 +39,10 @@ export default function SetPasswordForm({
submitLabel = 'Reset Password',
error = null,
isLoading = false,
+ includeCode = false,
}: SetPasswordFormProps) {
+ const [code, setCode] = useState('');
+ const [codeError, setCodeError] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [newPasswordError, setNewPasswordError] = useState('');
@@ -43,6 +52,15 @@ export default function SetPasswordForm({
function validate(): boolean {
let valid = true;
+ if (includeCode) {
+ if (!code.trim()) {
+ setCodeError('Please enter the verification code from your email');
+ valid = false;
+ } else {
+ setCodeError('');
+ }
+ }
+
if (!newPassword || !PASSWORD_RULE.test(newPassword)) {
setNewPasswordError(PASSWORD_RULE_MESSAGE);
valid = false;
@@ -63,7 +81,7 @@ export default function SetPasswordForm({
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
- await onSubmit(newPassword);
+ await onSubmit(newPassword, includeCode ? code.trim() : undefined);
}
return (
@@ -80,6 +98,19 @@ export default function SetPasswordForm({
)}
+ {includeCode && (
+
+ )}
(null);
const [isLoading, setIsLoading] = useState(false);
- const [submitted, setSubmitted] = useState(false);
+ const [step, setStep] = useState<'request' | 'confirm' | 'done'>('request');
function validate(): boolean {
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
@@ -28,7 +30,8 @@ export default function ForgotPasswordPage() {
setIsLoading(true);
try {
await forgotPassword(email);
- setSubmitted(true);
+ setConfirmError(null);
+ setStep('confirm');
} catch {
setEmailError('Something went wrong. Please try again.');
} finally {
@@ -38,38 +41,75 @@ export default function ForgotPasswordPage() {
async function handleResend() {
setIsLoading(true);
+ setConfirmError(null);
try {
await forgotPassword(email);
} catch {
- // Silently fail — user can try again
+ setConfirmError('Could not resend the code. Please try again.');
} finally {
setIsLoading(false);
}
}
- if (submitted) {
+ async function handleConfirm(newPassword: string, code?: string) {
+ setIsLoading(true);
+ setConfirmError(null);
+ try {
+ await resetPassword(email, code ?? '', newPassword);
+ setStep('done');
+ } catch (err) {
+ setConfirmError(
+ err instanceof Error
+ ? err.message
+ : 'Could not reset your password. Please try again.',
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ if (step === 'done') {
return (
-
+
-
- Reset Link Sent!
-
-
- We sent a reset link to {email} with a link to reset your password.
+ Password Changed
+
+ Your password has been successfully changed!
-
-
-
- Back to login
-
-
+
+ Back to login
+
+
+ );
+ }
+
+ if (step === 'confirm') {
+ return (
+
+
+ We sent a verification code to {email}. Enter it below with your new password.
+
+
+
+
+ Back to login
+
);
}
@@ -98,7 +138,7 @@ export default function ForgotPasswordPage() {
onClick={handleRequestReset}
loading={isLoading}
>
- Request reset link
+ Request reset code
Back to login
diff --git a/apps/frontend/src/app/profile/page.tsx b/apps/frontend/src/app/profile/page.tsx
index 8d90f3c3..83e0eb1d 100644
--- a/apps/frontend/src/app/profile/page.tsx
+++ b/apps/frontend/src/app/profile/page.tsx
@@ -12,6 +12,7 @@ import LoadingState from '../components/LoadingState';
import ProfilePhoto from '../components/ProfilePhoto';
import ProjectCard from '../components/ProjectCard';
import TextInputField from '../components/TextInputField';
+import PasswordResetModal from '../components/PasswordResetModal';
import TwoFactorModal from '../components/TwoFactorModal';
import UpdatePhotoModal from '../components/UpdatePhotoModal';
import { useAuth } from '@/context/AuthContext';
@@ -53,6 +54,7 @@ export default function ProfilePage() {
const [resetNotice, setResetNotice] = useState
(null);
const [isSendingReset, setSendingReset] = useState(false);
+ const [isResetOpen, setResetOpen] = useState(false);
const userId = authUser?.userId;
@@ -122,7 +124,7 @@ export default function ProfilePage() {
setSendingReset(true);
try {
await api.post('/auth/forgot-password', { email });
- setResetNotice(`We sent a reset link to ${email}.`);
+ setResetOpen(true);
} catch (err) {
setResetNotice(
err instanceof Error ? err.message : 'Could not send the reset email',
@@ -208,7 +210,7 @@ export default function ProfilePage() {
Password
-
We will email you a link to securely reset your password
+
We will email you a verification code to reset your password
+
setResetOpen(false)}
+ onSuccess={() => setResetNotice('Your password has been changed.')}
+ />
+
({
+ ...jest.requireActual('../../src/context/AuthContext'),
+ useAuth: () => ({
+ forgotPassword: mockForgotPassword,
+ resetPassword: mockResetPassword,
+ }),
+}));
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+async function requestCode(email = 'jane@example.com') {
+ await userEvent.type(screen.getByPlaceholderText('Enter email address'), email);
+ await userEvent.click(screen.getByRole('button', { name: /Request reset/i }));
+}
+
+describe('Forgot Password Page', () => {
+ it('collects the emailed code and a new password after sending', async () => {
+ mockForgotPassword.mockResolvedValue(undefined);
+ mockResetPassword.mockResolvedValue(undefined);
+ render();
+
+ await requestCode();
+
+ expect(
+ await screen.findByText(/We sent a verification code to jane@example.com/),
+ ).toBeInTheDocument();
+ expect(mockForgotPassword).toHaveBeenCalledWith('jane@example.com');
+
+ await userEvent.type(screen.getByPlaceholderText('Enter verification code'), '123456');
+ await userEvent.type(screen.getByPlaceholderText('Enter new password'), 'NewPassword1!');
+ await userEvent.type(screen.getByPlaceholderText('Retype password'), 'NewPassword1!');
+ await userEvent.click(screen.getByRole('button', { name: 'Reset Password' }));
+
+ await waitFor(() =>
+ expect(mockResetPassword).toHaveBeenCalledWith(
+ 'jane@example.com',
+ '123456',
+ 'NewPassword1!',
+ ),
+ );
+ expect(await screen.findByText('Password Changed')).toBeInTheDocument();
+ });
+
+ it('does not claim success when the code is wrong', async () => {
+ mockForgotPassword.mockResolvedValue(undefined);
+ mockResetPassword.mockRejectedValue(new Error('Invalid verification code'));
+ render();
+
+ await requestCode();
+ await screen.findByPlaceholderText('Enter verification code');
+
+ await userEvent.type(screen.getByPlaceholderText('Enter verification code'), '000000');
+ await userEvent.type(screen.getByPlaceholderText('Enter new password'), 'NewPassword1!');
+ await userEvent.type(screen.getByPlaceholderText('Retype password'), 'NewPassword1!');
+ await userEvent.click(screen.getByRole('button', { name: 'Reset Password' }));
+
+ expect(await screen.findByText('Invalid verification code')).toBeInTheDocument();
+ expect(screen.queryByText('Password Changed')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/frontend/test/components/ProfilePage.test.tsx b/apps/frontend/test/components/ProfilePage.test.tsx
index 708e84b2..6c14d877 100644
--- a/apps/frontend/test/components/ProfilePage.test.tsx
+++ b/apps/frontend/test/components/ProfilePage.test.tsx
@@ -169,16 +169,69 @@ describe('Profile Page', () => {
);
});
- it('sends a password reset email', async () => {
- mockLoadedPage({ '/auth/forgot-password': { message: 'sent' } });
+ it('lets the user enter the emailed code and set a new password', async () => {
+ mockLoadedPage({
+ '/auth/forgot-password': { message: 'sent' },
+ '/auth/reset-password': { message: 'ok' },
+ });
render();
await screen.findByRole('heading', { level: 2, name: 'Ada Lovelace' });
await userEvent.click(screen.getByRole('button', { name: 'Send Reset Email' }));
expect(
- await screen.findByText('We sent a reset link to ada@example.com.'),
+ await screen.findByText(/We sent a verification code to ada@example.com/),
).toBeInTheDocument();
+ await waitFor(() =>
+ expect(mockApiFetch).toHaveBeenCalledWith(
+ '/auth/forgot-password',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ email: 'ada@example.com' }),
+ }),
+ ),
+ );
+
+ await userEvent.type(screen.getByPlaceholderText('Enter verification code'), '123456');
+ await userEvent.type(screen.getByPlaceholderText('Enter new password'), 'NewPassword1!');
+ await userEvent.type(screen.getByPlaceholderText('Retype password'), 'NewPassword1!');
+ await userEvent.click(screen.getByRole('button', { name: 'Reset Password' }));
+
+ await waitFor(() =>
+ expect(mockApiFetch).toHaveBeenCalledWith(
+ '/auth/reset-password',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({
+ email: 'ada@example.com',
+ code: '123456',
+ newPassword: 'NewPassword1!',
+ }),
+ }),
+ ),
+ );
+ expect(await screen.findByText('Your password has been changed.')).toBeInTheDocument();
+ });
+
+ it('keeps the reset form open when the verification code is wrong', async () => {
+ mockLoadedPage({
+ '/auth/forgot-password': { message: 'sent' },
+ '/auth/reset-password': new ApiError('Invalid verification code', 400),
+ });
+ render();
+ await screen.findByRole('heading', { level: 2, name: 'Ada Lovelace' });
+
+ await userEvent.click(screen.getByRole('button', { name: 'Send Reset Email' }));
+ await screen.findByPlaceholderText('Enter verification code');
+
+ await userEvent.type(screen.getByPlaceholderText('Enter verification code'), '000000');
+ await userEvent.type(screen.getByPlaceholderText('Enter new password'), 'NewPassword1!');
+ await userEvent.type(screen.getByPlaceholderText('Retype password'), 'NewPassword1!');
+ await userEvent.click(screen.getByRole('button', { name: 'Reset Password' }));
+
+ expect(await screen.findByText('Invalid verification code')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
+ expect(screen.queryByText('Your password has been changed.')).not.toBeInTheDocument();
});
it('surfaces a load failure instead of a blank page', async () => {
From e3233e042fa0b915a3e8c8b6c3cb385a4cd01e2b Mon Sep 17 00:00:00 2001
From: nourshoreibah
Date: Sun, 30 Aug 2026 15:47:28 -0400
Subject: [PATCH 2/2] chore: trim comments
Co-authored-by: Cursor
---
apps/frontend/src/app/components/PasswordResetModal.tsx | 7 -------
apps/frontend/src/app/components/SetPasswordForm.tsx | 4 ----
2 files changed, 11 deletions(-)
diff --git a/apps/frontend/src/app/components/PasswordResetModal.tsx b/apps/frontend/src/app/components/PasswordResetModal.tsx
index 99602932..fd985f7e 100644
--- a/apps/frontend/src/app/components/PasswordResetModal.tsx
+++ b/apps/frontend/src/app/components/PasswordResetModal.tsx
@@ -8,7 +8,6 @@ import TextInputField from './TextInputField';
import { PASSWORD_RULE, PASSWORD_RULE_MESSAGE } from './SetPasswordForm';
import { useApi } from '@/hooks/useApi';
-/** The design tints the header and footer with Core Black/100 at 50%. */
const CHROME_BG =
'color-mix(in srgb, var(--color-black-100) 50%, var(--color-core-white))';
@@ -19,12 +18,6 @@ interface PasswordResetModalProps {
onSuccess: () => void;
}
-/**
- * Completes Cognito's forgot-password flow from the profile page. The pool
- * emails a code (`CONFIRM_WITH_CODE`), not a link, so this is the only place a
- * signed-in user can type that code — `/reset-password` is a public route and
- * AuthGate would bounce them off it.
- */
export default function PasswordResetModal({
open,
email,
diff --git a/apps/frontend/src/app/components/SetPasswordForm.tsx b/apps/frontend/src/app/components/SetPasswordForm.tsx
index d1de4923..d7193319 100644
--- a/apps/frontend/src/app/components/SetPasswordForm.tsx
+++ b/apps/frontend/src/app/components/SetPasswordForm.tsx
@@ -26,10 +26,6 @@ interface SetPasswordFormProps {
/** Server-side error surfaced by the caller. */
error?: string | null;
isLoading?: boolean;
- /**
- * Cognito's forgot-password email carries a code, not a link. When true, the
- * form collects that code and passes it as the second argument to `onSubmit`.
- */
includeCode?: boolean;
}