Files
starr-philproperties/src/modules/auth/components/ForgotPasswordForm.jsx
T
kennethobsequio 244aa607f7 add: more things
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-06 15:21:36 +08:00

350 lines
13 KiB
React

/***********************************************************************************************************************************************************************
* File Name: ForgotPasswordForm.jsx
* Type of Program: Frontend Component
* Description: Password reset flow — same procedure for every acc_type (admin,
* staff, user); only reg_type matters (Google accounts are turned
* away, see LoginForm's "Please log in with Google" precedent).
* Step 1 — Email (checks reg_type server-side, sends OTP if system)
* Step 2 — OTP + new password + confirm, single submit
* Module: User Credentials
* Author: lash0000
* Date Created: Jul. 4, 2026
***********************************************************************************************************************************************************************/
import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useForm, Controller } from 'react-hook-form'
import { useAuth } from '@/contexts/AuthContext'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Eye, EyeOff, LoaderCircle } from 'lucide-react'
const emailSchema = z.object({
email: z.string().email('Invalid email address'),
})
const resetSchema = z
.object({
otp: z.string().length(6, 'Enter all 6 digits').regex(/^\d{6}$/, 'OTP must contain only digits'),
new_password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
.regex(/[0-9]/, 'Must contain at least one number'),
confirm_password: z.string().min(1, 'Please confirm your password'),
})
.refine((d) => d.new_password === d.confirm_password, {
message: 'Passwords do not match',
path: ['confirm_password'],
})
export function ForgotPasswordForm({ className, ...props }) {
const navigate = useNavigate()
const { forgotPassword, resetPassword } = useAuth()
// 0 = email, 1 = otp + new password
const [step, setStep] = useState(0)
const [pendingEmail, setPendingEmail] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [resendCooldown, setResendCooldown] = useState(0)
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [successDialogOpen, setSuccessDialogOpen] = useState(false)
useEffect(() => {
if (resendCooldown <= 0) return
const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000)
return () => clearTimeout(t)
}, [resendCooldown])
// ── Step 1: Email ──────────────────────────────────────────────────────────
const {
register: regEmail,
handleSubmit: submitEmail,
formState: { errors: errEmail, isSubmitting: isRequesting },
} = useForm({
resolver: zodResolver(emailSchema),
defaultValues: { email: '' },
})
const onEmailSubmit = async ({ email }) => {
const result = await forgotPassword({ email })
if (!result.success) {
setErrorMessage(result.message)
setErrorDialogOpen(true)
return
}
setPendingEmail(email)
setResendCooldown(30)
setStep(1)
}
// ── Step 2: OTP + new password ────────────────────────────────────────────
const {
control: resetControl,
register: regReset,
handleSubmit: submitReset,
formState: { errors: errReset, isSubmitting: isResetting },
} = useForm({
resolver: zodResolver(resetSchema),
defaultValues: { otp: '', new_password: '', confirm_password: '' },
})
const onResetSubmit = async ({ otp, new_password }) => {
const result = await resetPassword({ email: pendingEmail, otp, new_password })
if (!result.success) {
setErrorMessage(result.message)
setErrorDialogOpen(true)
return
}
setSuccessDialogOpen(true)
}
const handleResend = async () => {
if (resendCooldown > 0) return
const result = await forgotPassword({ email: pendingEmail })
if (!result.success) {
setErrorMessage(result.message)
setErrorDialogOpen(true)
return
}
setResendCooldown(30)
}
return (
<>
<div className={cn('flex flex-col', className)} {...props}>
{/* ── Step 1: Email ── */}
{step === 0 && (
<form onSubmit={submitEmail(onEmailSubmit)} className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tighter">Forgot your password?</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email and we'll send you a code to reset it.
</p>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="email" className="text-sm font-medium">Email address</label>
<Input
id="email"
type="email"
placeholder="you@example.com"
autoComplete="off"
disabled={isRequesting}
readOnly
onFocus={(e) => e.target.removeAttribute('readonly')}
{...regEmail('email')}
/>
{errEmail.email && (
<p className="text-xs text-destructive">{errEmail.email.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isRequesting}>
{isRequesting ? (
<span className="flex items-center gap-2">
<LoaderCircle className="size-4 animate-spin" />
Sending...
</span>
) : (
'Send reset code'
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
Remembered it?{' '}
<Link to="/login" className="font-medium underline underline-offset-4">
Back to login
</Link>
</p>
</form>
)}
{/* ── Step 2: OTP + new password ── */}
{step === 1 && (
<form onSubmit={submitReset(onResetSubmit)} className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tighter">Reset your password.</h1>
<p className="text-muted-foreground text-sm text-balance">
We sent a 6-digit code to{' '}
<span className="font-medium text-foreground">{pendingEmail}</span>.
Enter it below along with your new password.
</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-center">Verification code</label>
<Controller
control={resetControl}
name="otp"
render={({ field }) => (
<InputOTP
maxLength={6}
value={field.value}
onChange={field.onChange}
containerClassName="justify-center"
>
<InputOTPGroup>
{Array.from({ length: 6 }).map((_, i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>
)}
/>
{errReset.otp && (
<p className="text-xs text-destructive text-center">{errReset.otp.message}</p>
)}
</div>
<div className="flex items-center justify-between text-sm -mt-2">
<span className="text-muted-foreground">
{resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't receive it?"}
</span>
<Button
type="button"
variant="link"
size="sm"
className="p-0 h-auto font-medium"
disabled={resendCooldown > 0}
onClick={handleResend}
>
Resend code
</Button>
</div>
{/* New password */}
<div className="flex flex-col gap-1.5">
<label htmlFor="new_password" className="text-sm font-medium">New password</label>
<div className="relative">
<Input
id="new_password"
type={showPassword ? 'text' : 'password'}
placeholder="Min. 8 chars, 1 uppercase, 1 number"
autoComplete="new-password"
disabled={isResetting}
className="pr-10"
{...regReset('new_password')}
/>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
</div>
{errReset.new_password && (
<p className="text-xs text-destructive">{errReset.new_password.message}</p>
)}
</div>
{/* Confirm password */}
<div className="flex flex-col gap-1.5">
<label htmlFor="confirm_password" className="text-sm font-medium">Confirm new password</label>
<div className="relative">
<Input
id="confirm_password"
type={showConfirm ? 'text' : 'password'}
placeholder="Repeat your new password"
autoComplete="new-password"
disabled={isResetting}
className="pr-10"
{...regReset('confirm_password')}
/>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setShowConfirm((v) => !v)}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showConfirm ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
</div>
{errReset.confirm_password && (
<p className="text-xs text-destructive">{errReset.confirm_password.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isResetting}>
{isResetting ? (
<span className="flex items-center gap-2">
<LoaderCircle className="size-4 animate-spin" />
Resetting...
</span>
) : (
'Submit'
)}
</Button>
<Separator />
<Button
type="button"
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => setStep(0)}
>
← Back
</Button>
</form>
)}
</div>
{/* Error Dialog */}
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Something went wrong</AlertDialogTitle>
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setErrorDialogOpen(false)}>Okay</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Success Dialog */}
<AlertDialog open={successDialogOpen} onOpenChange={setSuccessDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Password changed</AlertDialogTitle>
<AlertDialogDescription>
Your password has been reset successfully. Please log in with your new password.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => navigate('/login')}>Go to login</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}