mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,349 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user