| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- import HCaptcha from '@hcaptcha/react-hcaptcha'
- import { zodResolver } from '@hookform/resolvers/zod'
- import { motion } from 'framer-motion'
- import { CheckCircle, Eye, EyeOff } from 'lucide-react'
- import { useRouter } from 'next/router'
- import { parseAsString, useQueryStates } from 'nuqs'
- import { useRef, useState } from 'react'
- import { SubmitHandler, useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Alert,
- AlertDescription,
- AlertTitle,
- Button,
- cn,
- Form,
- FormControl,
- FormField,
- Input,
- } from 'ui'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import z from 'zod'
- import PasswordConditionsHelper from './PasswordConditionsHelper'
- import { useSignUpMutation } from '@/data/misc/signup-mutation'
- import { BASE_PATH } from '@/lib/constants'
- import { buildPathWithParams } from '@/lib/gotrue'
- const schema = z.object({
- email: z.string().min(1, 'Email is required').email('Must be a valid email'),
- password: z
- .string()
- .min(1, 'Password is required')
- .max(72, 'Password cannot exceed 72 characters')
- .refine((password) => password.length >= 8, 'Password must be at least 8 characters')
- .refine(
- (password) => /[A-Z]/.test(password),
- 'Password must contain at least 1 uppercase character'
- )
- .refine(
- (password) => /[a-z]/.test(password),
- 'Password must contain at least 1 lowercase character'
- )
- .refine((password) => /[0-9]/.test(password), 'Password must contain at least 1 number')
- .refine(
- (password) => /[!@#$%^&*()_+\-=\[\]{};`':"\\|,.<>\/?]/.test(password),
- 'Password must contain at least 1 symbol'
- ),
- })
- const formId = 'sign-up-form'
- export const SignUpForm = () => {
- const captchaRef = useRef<HCaptcha>(null)
- const [showConditions, setShowConditions] = useState(false)
- const [isSubmitted, setIsSubmitted] = useState(false)
- const [passwordHidden, setPasswordHidden] = useState(true)
- const [captchaToken, setCaptchaToken] = useState<string | null>(null)
- const router = useRouter()
- const form = useForm<z.infer<typeof schema>>({
- resolver: zodResolver(schema as any),
- defaultValues: { email: '', password: '' },
- })
- const [searchParams] = useQueryStates({
- auth_id: parseAsString.withDefault(''),
- token: parseAsString.withDefault(''),
- })
- const { mutate: signup, isPending: isSigningUp } = useSignUpMutation({
- onSuccess: () => {
- toast.success(`Signed up successfully!`)
- setIsSubmitted(true)
- },
- onError: (error) => {
- setCaptchaToken(null)
- captchaRef.current?.resetCaptcha()
- toast.error(`Failed to sign up: ${error.message}`)
- },
- })
- const onSubmit: SubmitHandler<z.infer<typeof schema>> = async ({ email, password }) => {
- // [Joshen] Separate submitting state as there's 2 async processes here
- let token = captchaToken
- if (!token) {
- const captchaResponse = await captchaRef.current?.execute({ async: true })
- token = captchaResponse?.response ?? null
- }
- const isInsideOAuthFlow = !!searchParams.auth_id
- const redirectUrlBase = `${
- process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
- ? location.origin
- : process.env.NEXT_PUBLIC_SITE_URL
- }${BASE_PATH}`
- let redirectTo: string
- if (isInsideOAuthFlow) {
- redirectTo = `${redirectUrlBase}/authorize?auth_id=${searchParams.auth_id}${searchParams.token && `&token=${searchParams.token}`}`
- } else {
- // Use getRedirectToPath to handle redirect_to parameter and other query params
- const { returnTo } = router.query
- const basePath = returnTo || '/sign-in'
- const fullPath = buildPathWithParams(basePath as string)
- const fullRedirectUrl = `${redirectUrlBase}${fullPath}`
- redirectTo = fullRedirectUrl
- }
- signup({
- email,
- password,
- hcaptchaToken: token ?? null,
- redirectTo,
- })
- }
- const password = form.watch('password')
- const isSubmitting = form.formState.isSubmitting || isSigningUp
- return (
- <div className="relative">
- {isSubmitted && (
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- transition={{ duration: 0.5, delay: 0.3 }}
- className="absolute top-0 w-full"
- >
- <Alert variant="default">
- <CheckCircle />
- <AlertTitle>Check your email to confirm</AlertTitle>
- <AlertDescription className="text-xs">
- You've successfully signed up. Please check your email to confirm your account before
- signing in to the Briven dashboard. The confirmation link expires in 10 minutes.
- </AlertDescription>
- </Alert>
- </motion.div>
- )}
- <div
- className={cn(
- 'w-full py-1 transition-all duration-500',
- isSubmitted ? 'max-h-[100px] opacity-0 pointer-events-none' : 'max-h-[1000px] opacity-100'
- )}
- >
- <Form {...form}>
- <form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
- <FormField
- key="email"
- name="email"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout name="email" label="Email">
- <FormControl>
- <Input
- id="email"
- autoComplete="email"
- disabled={isSubmitting}
- {...field}
- placeholder="you@example.com"
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- key="password"
- name="password"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout name="password" label="Password">
- <FormControl>
- <div className="relative">
- <Input
- id="password"
- type={passwordHidden ? 'password' : 'text'}
- autoComplete="new-password"
- placeholder="••••••••"
- {...field}
- onFocus={() => setShowConditions(true)}
- disabled={isSubmitting}
- />
- <Button
- type="default"
- title={passwordHidden ? `Show password` : `Hide password`}
- aria-label={passwordHidden ? `Show password` : `Hide password`}
- className="absolute right-1 top-1 px-1.5"
- icon={passwordHidden ? <Eye /> : <EyeOff />}
- disabled={isSubmitting}
- onClick={() => setPasswordHidden((prev) => !prev)}
- />
- </div>
- </FormControl>
- </FormItemLayout>
- )}
- />
- <div
- className={`${
- showConditions ? 'max-h-[500px]' : 'max-h-0'
- } transition-all duration-400 overflow-y-hidden`}
- >
- <PasswordConditionsHelper password={password} />
- </div>
- <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>
- <Button
- block
- form={formId}
- htmlType="submit"
- size="large"
- disabled={password.length === 0 || isSubmitting}
- loading={isSubmitting}
- >
- Sign up
- </Button>
- </form>
- </Form>
- </div>
- </div>
- )
- }
|