mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
201 lines
5.9 KiB
TypeScript
201 lines
5.9 KiB
TypeScript
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { useRouter } from 'next/router'
|
|
import { useRef, useState } from 'react'
|
|
import { SubmitHandler, useForm } from 'react-hook-form'
|
|
import { toast } from 'sonner'
|
|
import * as z from 'zod'
|
|
|
|
import { useResetPasswordMutation } from 'data/misc/reset-password-mutation'
|
|
import { BASE_PATH } from 'lib/constants'
|
|
import { auth } from 'lib/gotrue'
|
|
import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
|
|
const forgotPasswordSchema = z.object({
|
|
email: z.string().email('Must be a valid email').min(1, 'Email is required'),
|
|
})
|
|
|
|
const codeSchema = z.object({
|
|
code: z.string().regex(/^\d{6}$/, 'Code must be 6 digits'),
|
|
})
|
|
|
|
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>
|
|
type CodeFormData = z.infer<typeof codeSchema>
|
|
|
|
export const ForgotPasswordWizard = () => {
|
|
const [email, setEmail] = useState('')
|
|
|
|
if (email) {
|
|
return <ConfirmResetCodeForm email={email} />
|
|
}
|
|
|
|
return <ForgotPasswordForm onSuccess={(email) => setEmail(email)} />
|
|
}
|
|
|
|
const ConfirmResetCodeForm = ({ email }: { email: string }) => {
|
|
const router = useRouter()
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
|
const codeForm = useForm<CodeFormData>({
|
|
resolver: zodResolver(codeSchema),
|
|
defaultValues: { code: '' },
|
|
})
|
|
|
|
const onCodeEntered: SubmitHandler<CodeFormData> = async (data) => {
|
|
setIsLoading(true)
|
|
const {
|
|
data: { user },
|
|
error,
|
|
} = await auth.verifyOtp({ email, token: data.code, type: 'recovery' })
|
|
|
|
// This fixes a race condition where the user is redirected to the reset password page without the session being set
|
|
// which causes the user to be redirected to /sign-in page even though he's signed in
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
|
|
if (error) {
|
|
setIsLoading(false)
|
|
toast.error(`Failed to verify code: ${error.message}`)
|
|
} else {
|
|
if (user?.factors?.length) {
|
|
await router.push({
|
|
pathname: '/forgot-password-mfa',
|
|
query: router.query,
|
|
})
|
|
} else {
|
|
await router.push({
|
|
pathname: '/reset-password',
|
|
query: router.query,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Form_Shadcn_ {...codeForm}>
|
|
<form
|
|
id="code-input-form"
|
|
className="flex flex-col pt-4 space-y-4"
|
|
onSubmit={codeForm.handleSubmit(onCodeEntered)}
|
|
>
|
|
<FormField_Shadcn_
|
|
control={codeForm.control}
|
|
name="code"
|
|
render={({ field }) => (
|
|
<FormItemLayout label="Code">
|
|
<FormControl_Shadcn_>
|
|
<Input_Shadcn_
|
|
{...field}
|
|
placeholder="123456"
|
|
autoComplete="off"
|
|
disabled={isLoading}
|
|
/>
|
|
</FormControl_Shadcn_>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
|
|
<div className="border-t border-overlay-border" />
|
|
|
|
<Button block form="code-input-form" htmlType="submit" size="medium" loading={isLoading}>
|
|
Confirm reset code
|
|
</Button>
|
|
</form>
|
|
</Form_Shadcn_>
|
|
)
|
|
}
|
|
|
|
const ForgotPasswordForm = ({ onSuccess }: { onSuccess: (email: string) => void }) => {
|
|
const captchaRef = useRef<HCaptcha>(null)
|
|
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
|
|
|
const forgotPasswordForm = useForm<ForgotPasswordFormData>({
|
|
resolver: zodResolver(forgotPasswordSchema),
|
|
defaultValues: { email: '' },
|
|
})
|
|
|
|
const { mutate: resetPassword, isLoading } = useResetPasswordMutation({
|
|
onSuccess: () => {
|
|
onSuccess(forgotPasswordForm.getValues('email'))
|
|
},
|
|
onError: (error) => {
|
|
setCaptchaToken(null)
|
|
captchaRef.current?.resetCaptcha()
|
|
toast.error(`Failed to send reset email: ${error.message}`)
|
|
},
|
|
})
|
|
|
|
const onForgotPassword: SubmitHandler<ForgotPasswordFormData> = async (data) => {
|
|
let token = captchaToken
|
|
if (!token) {
|
|
const captchaResponse = await captchaRef.current?.execute({ async: true })
|
|
token = captchaResponse?.response ?? null
|
|
}
|
|
|
|
resetPassword({
|
|
email: data.email,
|
|
hcaptchaToken: token,
|
|
redirectTo: `${
|
|
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
|
? location.origin
|
|
: process.env.NEXT_PUBLIC_SITE_URL
|
|
}${BASE_PATH}/reset-password`,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Form_Shadcn_ {...forgotPasswordForm}>
|
|
<form
|
|
id="forgot-password-form"
|
|
className="flex flex-col pt-4 space-y-4"
|
|
onSubmit={forgotPasswordForm.handleSubmit(onForgotPassword)}
|
|
>
|
|
<FormField_Shadcn_
|
|
control={forgotPasswordForm.control}
|
|
name="email"
|
|
render={({ field }) => (
|
|
<FormItemLayout label="Email">
|
|
<FormControl_Shadcn_>
|
|
<Input_Shadcn_
|
|
{...field}
|
|
type="email"
|
|
placeholder="you@example.com"
|
|
disabled={isLoading}
|
|
autoComplete="email"
|
|
/>
|
|
</FormControl_Shadcn_>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
|
|
<div className="self-center">
|
|
<HCaptcha
|
|
ref={captchaRef}
|
|
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
|
|
size="invisible"
|
|
onVerify={(token) => {
|
|
setCaptchaToken(token)
|
|
}}
|
|
onExpire={() => {
|
|
setCaptchaToken(null)
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="border-t border-overlay-border" />
|
|
|
|
<Button
|
|
block
|
|
form="forgot-password-form"
|
|
htmlType="submit"
|
|
size="medium"
|
|
disabled={isLoading}
|
|
loading={isLoading}
|
|
>
|
|
Send reset code
|
|
</Button>
|
|
</form>
|
|
</Form_Shadcn_>
|
|
)
|
|
}
|