ForgotPasswordWizard.tsx 5.9 KB

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