ForgotPasswordWizard.tsx201 lines · main
1import HCaptcha from '@hcaptcha/react-hcaptcha'
2import { zodResolver } from '@hookform/resolvers/zod'
3import { useRouter } from 'next/router'
4import { useRef, useState } from 'react'
5import { SubmitHandler, useForm } from 'react-hook-form'
6import { toast } from 'sonner'
7import { Button, Form, FormControl, FormField, Input } from 'ui'
8import { Admonition } from 'ui-patterns'
9import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
10import * as z from 'zod'
11
12import { useResetPasswordMutation } from '@/data/misc/reset-password-mutation'
13import { BASE_PATH } from '@/lib/constants'
14import { auth } from '@/lib/gotrue'
15
16const forgotPasswordSchema = z.object({
17 email: z.string().min(1, 'Please provide an email address').email('Must be a valid email'),
18})
19
20const codeSchema = z.object({
21 code: z.string().regex(/^\d{6}$/, 'Code must be 6 digits'),
22})
23
24type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>
25type CodeFormData = z.infer<typeof codeSchema>
26
27export const ForgotPasswordWizard = () => {
28 const [email, setEmail] = useState('')
29
30 if (email) {
31 return <ConfirmResetCodeForm email={email} />
32 }
33
34 return <ForgotPasswordForm onSuccess={(email) => setEmail(email)} />
35}
36
37const ConfirmResetCodeForm = ({ email }: { email: string }) => {
38 const router = useRouter()
39 const [isLoading, setIsLoading] = useState(false)
40
41 const codeForm = useForm<CodeFormData>({
42 resolver: zodResolver(codeSchema as any),
43 defaultValues: { code: '' },
44 })
45
46 const onCodeEntered: SubmitHandler<CodeFormData> = async (data) => {
47 setIsLoading(true)
48 const {
49 data: { user },
50 error,
51 } = await auth.verifyOtp({ email, token: data.code, type: 'recovery' })
52
53 // This fixes a race condition where the user is redirected to the reset password page without the session being set
54 // which causes the user to be redirected to /sign-in page even though he's signed in
55 await new Promise((resolve) => setTimeout(resolve, 1000))
56
57 if (error) {
58 setIsLoading(false)
59 toast.error(`Failed to verify code: ${error.message}`)
60 } else {
61 if (user?.factors?.length) {
62 await router.push({
63 pathname: '/forgot-password-mfa',
64 query: router.query,
65 })
66 } else {
67 await router.push({
68 pathname: '/reset-password',
69 query: router.query,
70 })
71 }
72 }
73 }
74
75 return (
76 <Form {...codeForm}>
77 <form
78 id="code-input-form"
79 className="flex flex-col pt-4 space-y-4"
80 onSubmit={codeForm.handleSubmit(onCodeEntered)}
81 >
82 <Admonition
83 type="default"
84 title="Check your email for a reset code"
85 description="You'll receive an email if an account associated with the email address exists"
86 />
87 <FormField
88 control={codeForm.control}
89 name="code"
90 render={({ field }) => (
91 <FormItemLayout label="Code">
92 <FormControl>
93 <Input {...field} placeholder="123456" autoComplete="off" disabled={isLoading} />
94 </FormControl>
95 </FormItemLayout>
96 )}
97 />
98
99 <div className="border-t border-overlay-border" />
100
101 <Button block form="code-input-form" htmlType="submit" size="medium" loading={isLoading}>
102 Confirm reset code
103 </Button>
104 </form>
105 </Form>
106 )
107}
108
109const ForgotPasswordForm = ({ onSuccess }: { onSuccess: (email: string) => void }) => {
110 const captchaRef = useRef<HCaptcha>(null)
111 const [captchaToken, setCaptchaToken] = useState<string | null>(null)
112
113 const forgotPasswordForm = useForm<ForgotPasswordFormData>({
114 resolver: zodResolver(forgotPasswordSchema as any),
115 defaultValues: { email: '' },
116 })
117
118 const { mutate: resetPassword, isPending } = useResetPasswordMutation({
119 onSuccess: () => {
120 onSuccess(forgotPasswordForm.getValues('email'))
121 },
122 onError: (error) => {
123 setCaptchaToken(null)
124 captchaRef.current?.resetCaptcha()
125 toast.error(`Failed to send reset email: ${error.message}`)
126 },
127 })
128
129 const onForgotPassword: SubmitHandler<ForgotPasswordFormData> = async (data) => {
130 let token = captchaToken
131 if (!token) {
132 const captchaResponse = await captchaRef.current?.execute({ async: true })
133 token = captchaResponse?.response ?? null
134 }
135
136 resetPassword({
137 email: data.email,
138 hcaptchaToken: token,
139 redirectTo: `${
140 process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
141 ? location.origin
142 : process.env.NEXT_PUBLIC_SITE_URL
143 }${BASE_PATH}/reset-password`,
144 })
145 }
146
147 return (
148 <Form {...forgotPasswordForm}>
149 <form
150 id="forgot-password-form"
151 className="flex flex-col pt-4 space-y-4"
152 onSubmit={forgotPasswordForm.handleSubmit(onForgotPassword)}
153 >
154 <FormField
155 control={forgotPasswordForm.control}
156 name="email"
157 render={({ field }) => (
158 <FormItemLayout label="Email">
159 <FormControl>
160 <Input
161 {...field}
162 type="email"
163 placeholder="you@example.com"
164 disabled={isPending}
165 autoComplete="email"
166 />
167 </FormControl>
168 </FormItemLayout>
169 )}
170 />
171
172 <div className="self-center">
173 <HCaptcha
174 ref={captchaRef}
175 sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
176 size="invisible"
177 onVerify={(token) => {
178 setCaptchaToken(token)
179 }}
180 onExpire={() => {
181 setCaptchaToken(null)
182 }}
183 />
184 </div>
185
186 <div className="border-t border-overlay-border" />
187
188 <Button
189 block
190 form="forgot-password-form"
191 htmlType="submit"
192 size="medium"
193 disabled={isPending}
194 loading={isPending}
195 >
196 Send reset code
197 </Button>
198 </form>
199 </Form>
200 )
201}