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