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