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 + + + + +
{ + e.preventDefault(); + void confirmReset(); + }} + > + +
+

+ We sent a verification code to {email}. Enter it below with + your new password. +

+ + + + + {error && ( +

+ {error} +

+ )} + +
+
+ + +
+ + +
+
+
+
+
+
+
+ ); +} 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