SignUpForm.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import HCaptcha from '@hcaptcha/react-hcaptcha'
  2. import { zodResolver } from '@hookform/resolvers/zod'
  3. import { motion } from 'framer-motion'
  4. import { CheckCircle, Eye, EyeOff } from 'lucide-react'
  5. import { useRouter } from 'next/router'
  6. import { parseAsString, useQueryStates } from 'nuqs'
  7. import { useRef, useState } from 'react'
  8. import { SubmitHandler, useForm } from 'react-hook-form'
  9. import { toast } from 'sonner'
  10. import {
  11. Alert,
  12. AlertDescription,
  13. AlertTitle,
  14. Button,
  15. cn,
  16. Form,
  17. FormControl,
  18. FormField,
  19. Input,
  20. } from 'ui'
  21. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  22. import z from 'zod'
  23. import PasswordConditionsHelper from './PasswordConditionsHelper'
  24. import { useSignUpMutation } from '@/data/misc/signup-mutation'
  25. import { BASE_PATH } from '@/lib/constants'
  26. import { buildPathWithParams } from '@/lib/gotrue'
  27. const schema = z.object({
  28. email: z.string().min(1, 'Email is required').email('Must be a valid email'),
  29. password: z
  30. .string()
  31. .min(1, 'Password is required')
  32. .max(72, 'Password cannot exceed 72 characters')
  33. .refine((password) => password.length >= 8, 'Password must be at least 8 characters')
  34. .refine(
  35. (password) => /[A-Z]/.test(password),
  36. 'Password must contain at least 1 uppercase character'
  37. )
  38. .refine(
  39. (password) => /[a-z]/.test(password),
  40. 'Password must contain at least 1 lowercase character'
  41. )
  42. .refine((password) => /[0-9]/.test(password), 'Password must contain at least 1 number')
  43. .refine(
  44. (password) => /[!@#$%^&*()_+\-=\[\]{};`':"\\|,.<>\/?]/.test(password),
  45. 'Password must contain at least 1 symbol'
  46. ),
  47. })
  48. const formId = 'sign-up-form'
  49. export const SignUpForm = () => {
  50. const captchaRef = useRef<HCaptcha>(null)
  51. const [showConditions, setShowConditions] = useState(false)
  52. const [isSubmitted, setIsSubmitted] = useState(false)
  53. const [passwordHidden, setPasswordHidden] = useState(true)
  54. const [captchaToken, setCaptchaToken] = useState<string | null>(null)
  55. const router = useRouter()
  56. const form = useForm<z.infer<typeof schema>>({
  57. resolver: zodResolver(schema as any),
  58. defaultValues: { email: '', password: '' },
  59. })
  60. const [searchParams] = useQueryStates({
  61. auth_id: parseAsString.withDefault(''),
  62. token: parseAsString.withDefault(''),
  63. })
  64. const { mutate: signup, isPending: isSigningUp } = useSignUpMutation({
  65. onSuccess: () => {
  66. toast.success(`Signed up successfully!`)
  67. setIsSubmitted(true)
  68. },
  69. onError: (error) => {
  70. setCaptchaToken(null)
  71. captchaRef.current?.resetCaptcha()
  72. toast.error(`Failed to sign up: ${error.message}`)
  73. },
  74. })
  75. const onSubmit: SubmitHandler<z.infer<typeof schema>> = async ({ email, password }) => {
  76. // [Joshen] Separate submitting state as there's 2 async processes here
  77. let token = captchaToken
  78. if (!token) {
  79. const captchaResponse = await captchaRef.current?.execute({ async: true })
  80. token = captchaResponse?.response ?? null
  81. }
  82. const isInsideOAuthFlow = !!searchParams.auth_id
  83. const redirectUrlBase = `${
  84. process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
  85. ? location.origin
  86. : process.env.NEXT_PUBLIC_SITE_URL
  87. }${BASE_PATH}`
  88. let redirectTo: string
  89. if (isInsideOAuthFlow) {
  90. redirectTo = `${redirectUrlBase}/authorize?auth_id=${searchParams.auth_id}${searchParams.token && `&token=${searchParams.token}`}`
  91. } else {
  92. // Use getRedirectToPath to handle redirect_to parameter and other query params
  93. const { returnTo } = router.query
  94. const basePath = returnTo || '/sign-in'
  95. const fullPath = buildPathWithParams(basePath as string)
  96. const fullRedirectUrl = `${redirectUrlBase}${fullPath}`
  97. redirectTo = fullRedirectUrl
  98. }
  99. signup({
  100. email,
  101. password,
  102. hcaptchaToken: token ?? null,
  103. redirectTo,
  104. })
  105. }
  106. const password = form.watch('password')
  107. const isSubmitting = form.formState.isSubmitting || isSigningUp
  108. return (
  109. <div className="relative">
  110. {isSubmitted && (
  111. <motion.div
  112. initial={{ opacity: 0 }}
  113. animate={{ opacity: 1 }}
  114. transition={{ duration: 0.5, delay: 0.3 }}
  115. className="absolute top-0 w-full"
  116. >
  117. <Alert variant="default">
  118. <CheckCircle />
  119. <AlertTitle>Check your email to confirm</AlertTitle>
  120. <AlertDescription className="text-xs">
  121. You've successfully signed up. Please check your email to confirm your account before
  122. signing in to the Briven dashboard. The confirmation link expires in 10 minutes.
  123. </AlertDescription>
  124. </Alert>
  125. </motion.div>
  126. )}
  127. <div
  128. className={cn(
  129. 'w-full py-1 transition-all duration-500',
  130. isSubmitted ? 'max-h-[100px] opacity-0 pointer-events-none' : 'max-h-[1000px] opacity-100'
  131. )}
  132. >
  133. <Form {...form}>
  134. <form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
  135. <FormField
  136. key="email"
  137. name="email"
  138. control={form.control}
  139. render={({ field }) => (
  140. <FormItemLayout name="email" label="Email">
  141. <FormControl>
  142. <Input
  143. id="email"
  144. autoComplete="email"
  145. disabled={isSubmitting}
  146. {...field}
  147. placeholder="you@example.com"
  148. />
  149. </FormControl>
  150. </FormItemLayout>
  151. )}
  152. />
  153. <FormField
  154. key="password"
  155. name="password"
  156. control={form.control}
  157. render={({ field }) => (
  158. <FormItemLayout name="password" label="Password">
  159. <FormControl>
  160. <div className="relative">
  161. <Input
  162. id="password"
  163. type={passwordHidden ? 'password' : 'text'}
  164. autoComplete="new-password"
  165. placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
  166. {...field}
  167. onFocus={() => setShowConditions(true)}
  168. disabled={isSubmitting}
  169. />
  170. <Button
  171. type="default"
  172. title={passwordHidden ? `Show password` : `Hide password`}
  173. aria-label={passwordHidden ? `Show password` : `Hide password`}
  174. className="absolute right-1 top-1 px-1.5"
  175. icon={passwordHidden ? <Eye /> : <EyeOff />}
  176. disabled={isSubmitting}
  177. onClick={() => setPasswordHidden((prev) => !prev)}
  178. />
  179. </div>
  180. </FormControl>
  181. </FormItemLayout>
  182. )}
  183. />
  184. <div
  185. className={`${
  186. showConditions ? 'max-h-[500px]' : 'max-h-0'
  187. } transition-all duration-400 overflow-y-hidden`}
  188. >
  189. <PasswordConditionsHelper password={password} />
  190. </div>
  191. <div className="self-center">
  192. <HCaptcha
  193. ref={captchaRef}
  194. sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
  195. size="invisible"
  196. onVerify={(token) => setCaptchaToken(token)}
  197. onExpire={() => setCaptchaToken(null)}
  198. />
  199. </div>
  200. <Button
  201. block
  202. form={formId}
  203. htmlType="submit"
  204. size="large"
  205. disabled={password.length === 0 || isSubmitting}
  206. loading={isSubmitting}
  207. >
  208. Sign up
  209. </Button>
  210. </form>
  211. </Form>
  212. </div>
  213. </div>
  214. )
  215. }