SignInForm.tsx209 lines · main
1// @ts-nocheck
2import HCaptcha from '@hcaptcha/react-hcaptcha'
3import { zodResolver } from '@hookform/resolvers/zod'
4import type { AuthError } from '@supabase/supabase-js'
5import { useQueryClient } from '@tanstack/react-query'
6import { Eye, EyeOff } from 'lucide-react'
7import Link from 'next/link'
8import { useRouter } from 'next/router'
9import { useEffect, useRef, useState } from 'react'
10import { useForm, type SubmitHandler } from 'react-hook-form'
11import { toast } from 'sonner'
12import { Button, Form, FormControl, FormField, Input } from 'ui'
13import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
14import z from 'zod'
15
16import { LastSignInWrapper } from './LastSignInWrapper'
17import { useAddLoginEvent } from '@/data/misc/audit-login-mutation'
18import { getMfaAuthenticatorAssuranceLevel } from '@/data/profile/mfa-authenticator-assurance-level-query'
19import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
20import { useLastSignIn } from '@/hooks/misc/useLastSignIn'
21import { captureCriticalError } from '@/lib/error-reporting'
22import { auth, buildPathWithParams, getReturnToPath } from '@/lib/gotrue'
23
24const schema = z.object({
25 email: z.string().min(1, 'Email is required').email('Must be a valid email'),
26 password: z.string().min(1, 'Password is required'),
27})
28
29const formId = 'sign-in-form'
30
31export const SignInForm = () => {
32 const router = useRouter()
33 const queryClient = useQueryClient()
34 const [_, setLastSignIn] = useLastSignIn()
35
36 const [passwordHidden, setPasswordHidden] = useState(true)
37
38 const [captchaToken, setCaptchaToken] = useState<string | null>(null)
39 const captchaRef = useRef<HCaptcha>(null)
40 const [returnTo, setReturnTo] = useState<string | null>(null)
41 const form = useForm<z.infer<typeof schema>>({
42 resolver: zodResolver(schema as any),
43 defaultValues: { email: '', password: '' },
44 })
45 const isSubmitting = form.formState.isSubmitting
46
47 useEffect(() => {
48 // Only call getReturnToPath after component mounts client-side
49 setReturnTo(getReturnToPath())
50 }, [])
51
52 const { mutate: sendEvent } = useSendEventMutation()
53 const { mutate: addLoginEvent } = useAddLoginEvent()
54
55 let forgotPasswordUrl = `/forgot-password`
56
57 if (returnTo && !returnTo.includes('/forgot-password')) {
58 forgotPasswordUrl = `${forgotPasswordUrl}?returnTo=${encodeURIComponent(returnTo)}`
59 }
60
61 const onSubmit: SubmitHandler<z.infer<typeof schema>> = async ({ email, password }) => {
62 const toastId = toast.loading('Signing in...')
63
64 let token = captchaToken
65 if (!token) {
66 const captchaResponse = await captchaRef.current?.execute({ async: true })
67 token = captchaResponse?.response ?? null
68 }
69
70 const { error } = await auth.signInWithPassword({
71 email,
72 password,
73 options: { captchaToken: token ?? undefined },
74 })
75
76 if (!error) {
77 setLastSignIn('email')
78 try {
79 const data = await getMfaAuthenticatorAssuranceLevel()
80 if (data) {
81 if (data.currentLevel !== data.nextLevel) {
82 toast.success(`You need to provide your second factor authentication`, { id: toastId })
83 const url = buildPathWithParams('/sign-in-mfa')
84 router.replace(url)
85 return
86 }
87 }
88
89 toast.success(`Signed in successfully!`, { id: toastId })
90 sendEvent({
91 action: 'sign_in',
92 properties: { category: 'account', method: 'email' },
93 })
94 addLoginEvent({})
95
96 await queryClient.resetQueries()
97 // since we're already on the /sign-in page, prevent redirect loops
98 let redirectPath = '/organizations'
99 if (returnTo && returnTo !== '/sign-in') {
100 redirectPath = returnTo
101 }
102 router.push(redirectPath)
103 } catch (error: any) {
104 toast.error(`Failed to sign in: ${(error as AuthError).message}`, { id: toastId })
105 captureCriticalError(error, 'sign in via EP')
106 }
107 } else {
108 setCaptchaToken(null)
109 captchaRef.current?.resetCaptcha()
110
111 if (error.message.toLowerCase() === 'email not confirmed') {
112 return toast.error(
113 'Account has not been verified, please check the link sent to your email',
114 { id: toastId }
115 )
116 }
117
118 toast.error(error.message, { id: toastId })
119 }
120 }
121
122 return (
123 <Form {...form}>
124 <form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
125 <FormField
126 key="email"
127 name="email"
128 control={form.control}
129 render={({ field }) => (
130 <FormItemLayout name="email" label="Email">
131 <FormControl>
132 <Input
133 id="email"
134 type="email"
135 autoComplete="email"
136 {...field}
137 placeholder="you@example.com"
138 disabled={isSubmitting}
139 />
140 </FormControl>
141 </FormItemLayout>
142 )}
143 />
144
145 <div className="relative">
146 <FormField
147 key="password"
148 name="password"
149 control={form.control}
150 render={({ field }) => (
151 <FormItemLayout name="password" label="Password">
152 <FormControl>
153 <div className="relative">
154 <Input
155 id="password"
156 type={passwordHidden ? 'password' : 'text'}
157 autoComplete="current-password"
158 {...field}
159 placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
160 disabled={isSubmitting}
161 className="pr-10"
162 />
163 <Button
164 type="default"
165 title={passwordHidden ? `Show password` : `Hide password`}
166 aria-label={passwordHidden ? `Show password` : `Hide password`}
167 className="absolute right-1 top-1 px-1.5"
168 icon={passwordHidden ? <Eye /> : <EyeOff />}
169 disabled={isSubmitting}
170 onClick={() => setPasswordHidden((prev) => !prev)}
171 />
172 </div>
173 </FormControl>
174 </FormItemLayout>
175 )}
176 />
177
178 {/* positioned using absolute instead of labelOptional prop so tabbing between inputs works smoothly */}
179 <Link
180 href={forgotPasswordUrl}
181 className="absolute top-0 right-0 text-sm text-foreground-lighter"
182 >
183 Forgot password?
184 </Link>
185 </div>
186
187 <div className="self-center">
188 <HCaptcha
189 ref={captchaRef}
190 sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
191 size="invisible"
192 onVerify={(token) => {
193 setCaptchaToken(token)
194 }}
195 onExpire={() => {
196 setCaptchaToken(null)
197 }}
198 />
199 </div>
200
201 <LastSignInWrapper type="email">
202 <Button block form={formId} htmlType="submit" size="large" loading={isSubmitting}>
203 Sign in
204 </Button>
205 </LastSignInWrapper>
206 </form>
207 </Form>
208 )
209}