SignInMfaForm.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { SupportCategories } from '@supabase/shared-types/out/constants'
  3. import type { Factor } from '@supabase/supabase-js'
  4. import { useQueryClient } from '@tanstack/react-query'
  5. import { useAuthError } from 'common'
  6. import { Lock } from 'lucide-react'
  7. import Link from 'next/link'
  8. import { useRouter } from 'next/router'
  9. import { useEffect, useState } from 'react'
  10. import { SubmitHandler, useForm } from 'react-hook-form'
  11. import { Button, Form, FormControl, FormField, Input } from 'ui'
  12. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  13. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  14. import z from 'zod'
  15. import { SupportLink } from '../Support/SupportLink'
  16. import AlertError from '@/components/ui/AlertError'
  17. import { useMfaChallengeAndVerifyMutation } from '@/data/profile/mfa-challenge-and-verify-mutation'
  18. import { useMfaListFactorsQuery } from '@/data/profile/mfa-list-factors-query'
  19. import { useSignOut } from '@/lib/auth'
  20. import { getReturnToPath } from '@/lib/gotrue'
  21. const schema = z.object({
  22. code: z.string().min(1, 'MFA Code is required'),
  23. })
  24. const formId = 'sign-in-mfa-form'
  25. interface SignInMfaFormProps {
  26. context?: 'forgot-password' | 'sign-in'
  27. }
  28. export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
  29. const router = useRouter()
  30. const signOut = useSignOut()
  31. const queryClient = useQueryClient()
  32. const [selectedFactor, setSelectedFactor] = useState<Factor | null>(null)
  33. const form = useForm<z.infer<typeof schema>>({
  34. resolver: zodResolver(schema as any),
  35. defaultValues: { code: '' },
  36. })
  37. const { code } = form.watch()
  38. const {
  39. data: factors,
  40. error: factorsError,
  41. isError: isErrorFactors,
  42. isSuccess: isSuccessFactors,
  43. isPending: isLoadingFactors,
  44. } = useMfaListFactorsQuery()
  45. const {
  46. mutate: mfaChallengeAndVerify,
  47. isPending: isVerifying,
  48. isSuccess,
  49. } = useMfaChallengeAndVerifyMutation({
  50. onSuccess: async () => {
  51. await queryClient.resetQueries()
  52. if (context === 'forgot-password') {
  53. router.push({
  54. pathname: '/reset-password',
  55. query: router.query,
  56. })
  57. } else {
  58. router.push(getReturnToPath())
  59. }
  60. },
  61. })
  62. const onClickLogout = async () => {
  63. await signOut()
  64. await router.replace('/sign-in')
  65. }
  66. const onSubmit: SubmitHandler<z.infer<typeof schema>> = async ({ code }) => {
  67. if (selectedFactor) {
  68. mfaChallengeAndVerify({ factorId: selectedFactor.id, code, refreshFactors: false })
  69. }
  70. }
  71. useEffect(() => {
  72. if (isSuccessFactors) {
  73. // if the user wanders into this page and he has no MFA setup, send the user to the next screen
  74. if (factors.totp.length === 0) {
  75. queryClient.resetQueries().then(() => router.push(getReturnToPath()))
  76. }
  77. if (factors.totp.length > 0) {
  78. setSelectedFactor(factors.totp[0])
  79. }
  80. }
  81. }, [factors?.totp, isSuccessFactors, router, queryClient])
  82. useEffect(() => {
  83. if (code.length === 6) form.handleSubmit(onSubmit)()
  84. }, [code])
  85. const error = useAuthError()
  86. if (error) {
  87. return (
  88. <AlertError
  89. error={error}
  90. subject="Error while signing in"
  91. additionalActions={
  92. <Button asChild type="default">
  93. <Link href="/sign-in">Back to sign in</Link>
  94. </Button>
  95. }
  96. />
  97. )
  98. }
  99. return (
  100. <>
  101. {isLoadingFactors && <GenericSkeletonLoader />}
  102. {isErrorFactors && <AlertError error={factorsError} subject="Failed to retrieve factors" />}
  103. {isSuccessFactors && (
  104. <Form {...form}>
  105. <form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
  106. <FormField
  107. key="code"
  108. name="code"
  109. control={form.control}
  110. render={({ field }) => (
  111. <FormItemLayout
  112. name="code"
  113. label={
  114. selectedFactor && factors?.totp.length === 2
  115. ? `Code generated by ${selectedFactor.friendly_name}`
  116. : null
  117. }
  118. >
  119. <FormControl>
  120. <div className="relative">
  121. <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-foreground-light [&_svg]:stroke-[1.5] [&_svg]:h-[20px] [&_svg]:w-[20px]">
  122. <Lock />
  123. </div>
  124. <Input
  125. id="code"
  126. className="pl-10 font-mono"
  127. {...field}
  128. autoFocus
  129. autoComplete="off"
  130. autoCorrect="off"
  131. autoCapitalize="none"
  132. spellCheck="false"
  133. placeholder="XXXXXX"
  134. disabled={isVerifying}
  135. />
  136. </div>
  137. </FormControl>
  138. </FormItemLayout>
  139. )}
  140. />
  141. <div className="flex items-center justify-between gap-x-2">
  142. <Button
  143. block
  144. type="outline"
  145. size="large"
  146. disabled={isVerifying || isSuccess}
  147. onClick={onClickLogout}
  148. className="opacity-80 hover:opacity-100 transition"
  149. >
  150. Cancel
  151. </Button>
  152. <Button
  153. block
  154. form={formId}
  155. htmlType="submit"
  156. size="large"
  157. disabled={isVerifying || isSuccess}
  158. loading={isVerifying || isSuccess}
  159. >
  160. {isVerifying ? 'Verifying' : isSuccess ? 'Signing in' : 'Verify'}
  161. </Button>
  162. </div>
  163. </form>
  164. </Form>
  165. )}
  166. <div className="my-8">
  167. <div className="text-sm">
  168. <span className="text-foreground-light">Unable to sign in?</span>{' '}
  169. </div>
  170. <ul className="list-disc pl-6">
  171. {factors?.totp.length === 2 && (
  172. <li>
  173. <a
  174. className="text-sm text-foreground-light hover:text-foreground cursor-pointer"
  175. onClick={() =>
  176. setSelectedFactor(factors.totp.find((f) => f.id !== selectedFactor?.id)!)
  177. }
  178. >{`Authenticate using ${
  179. factors.totp.find((f) => f.id !== selectedFactor?.id)?.friendly_name
  180. }?`}</a>
  181. </li>
  182. )}
  183. <li>
  184. <Link
  185. href="/logout"
  186. className="text-sm transition text-foreground-light hover:text-foreground"
  187. >
  188. Force sign out and clear cookies
  189. </Link>
  190. </li>
  191. <li>
  192. <SupportLink
  193. className="text-sm transition text-foreground-light hover:text-foreground"
  194. queryParams={{
  195. subject: 'Unable to sign in via MFA',
  196. category: SupportCategories.LOGIN_ISSUES,
  197. }}
  198. >
  199. Reach out to us via support
  200. </SupportLink>
  201. </li>
  202. </ul>
  203. </div>
  204. </>
  205. )
  206. }