SignInForm.tsx 7.1 KB

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