Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 239 additions & 0 deletions apps/frontend/src/app/components/PasswordResetModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
'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';

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

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<string | null>(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 (
<Dialog.Root
open={open}
onOpenChange={(e) => {
if (!e.open) handleClose();
}}
scrollBehavior="inside"
>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content width="100%" maxWidth="409px" marginX="4">
<Dialog.Header
display="flex"
justifyContent="space-between"
alignItems="center"
minHeight="64px"
paddingX="24px"
paddingY="0"
backgroundColor={CHROME_BG}
>
<Dialog.Title
fontFamily="var(--font-heading)"
fontSize="var(--font-size-heading-3)"
fontWeight={600}
>
Reset Password
</Dialog.Title>
<CloseButton onClick={handleClose} aria-label="Close" />
</Dialog.Header>

<form
onSubmit={(e) => {
e.preventDefault();
void confirmReset();
}}
>
<Dialog.Body paddingX="24px" paddingTop="30px" paddingBottom="24px">
<div className="flex flex-col !gap-4">
<p className="!text-core-black">
We sent a verification code to {email}. Enter it below with
your new password.
</p>
<TextInputField
label="Verification code"
placeholder="Enter verification code"
required
inputMode="numeric"
name="verification-code"
autoComplete="one-time-code"
value={code}
onChange={setCode}
isError={!!codeError}
errorMessage={codeError}
/>
<TextInputField
label="New Password"
placeholder="Enter new password"
required
type={showPassword ? 'text' : 'password'}
name="new-password"
autoComplete="new-password"
value={newPassword}
onChange={setNewPassword}
isError={!!newPasswordError}
errorMessage={newPasswordError}
/>
<TextInputField
label="Confirm Password"
placeholder="Retype password"
required
type={showPassword ? 'text' : 'password'}
name="confirm-password"
autoComplete="new-password"
value={confirmPassword}
onChange={setConfirmPassword}
isError={!!confirmPasswordError}
errorMessage={confirmPasswordError}
/>
<ShowPasswordCheckbox
checked={showPassword}
onChange={setShowPassword}
label="Show passwords"
/>
{error && (
<p role="alert" className="!text-[length:var(--font-size-callout)] !text-error-red">
{error}
</p>
)}
<button
type="button"
className="self-start !font-body !text-[length:var(--font-size-callout)] !font-bold !text-core-green disabled:!text-black-500"
onClick={resendCode}
disabled={isBusy}
>
Resend code
</button>
</div>
</Dialog.Body>

<Dialog.Footer height="64px" paddingX="24px" backgroundColor={CHROME_BG}>
<div className="flex w-full justify-end !gap-6">
<Button variant="secondary" onClick={handleClose} disabled={isBusy}>
Cancel
</Button>
<Button
type="submit"
isLoading={isConfirming}
loadingText="Resetting…"
disabled={isBusy}
>
Reset Password
</Button>
</div>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
);
}
37 changes: 32 additions & 5 deletions apps/frontend/src/app/components/SetPasswordForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,}$/;
Expand All @@ -19,12 +20,13 @@ 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> | void;
onSubmit: (newPassword: string, code?: string) => Promise<void> | void;
heading?: string;
submitLabel?: string;
/** Server-side error surfaced by the caller. */
error?: string | null;
isLoading?: boolean;
includeCode?: boolean;
}

export default function SetPasswordForm({
Expand All @@ -33,7 +35,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('');
Expand All @@ -43,6 +48,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;
Expand All @@ -63,7 +77,7 @@ export default function SetPasswordForm({
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!validate()) return;
await onSubmit(newPassword);
await onSubmit(newPassword, includeCode ? code.trim() : undefined);
}

return (
Expand All @@ -80,6 +94,19 @@ export default function SetPasswordForm({
</p>
)}
<div className="flex flex-col gap-4 w-full !mb-10">
{includeCode && (
<TextInputField
label="Verification code *"
placeholder="Enter verification code"
errorMessage={codeError}
isError={!!codeError}
value={code}
onChange={setCode}
inputMode="numeric"
name="verification-code"
autoComplete="one-time-code"
/>
)}
<TextInputField
label="New Password *"
placeholder="Enter new password"
Expand Down
Loading
Loading