diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx index d72c035224c..2b2170cac67 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx @@ -1,7 +1,5 @@ +import { zodResolver } from '@hookform/resolvers/zod' import { useQueryClient } from '@tanstack/react-query' -import { Dispatch, SetStateAction, useEffect, useState } from 'react' -import { toast } from 'sonner' - import { LOCAL_STORAGE_KEYS } from 'common' import InformationBox from 'components/ui/InformationBox' import { organizationKeys } from 'data/organizations/keys' @@ -9,9 +7,16 @@ import { useMfaChallengeAndVerifyMutation } from 'data/profile/mfa-challenge-and import { useMfaEnrollMutation } from 'data/profile/mfa-enroll-mutation' import { useMfaUnenrollMutation } from 'data/profile/mfa-unenroll-mutation' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { Input } from 'ui' +import { useEffect, useState } from 'react' +import { useForm, type SubmitHandler } from 'react-hook-form' +import { toast } from 'sonner' +import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Input_Shadcn_ } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { z } from 'zod' + +type TOTP = { qr_code: string; secret: string; uri: string } interface AddNewFactorModalProps { visible: boolean @@ -19,32 +24,24 @@ interface AddNewFactorModalProps { } export const AddNewFactorModal = ({ visible, onClose }: AddNewFactorModalProps) => { - // Generate a name with a number between 0 and 1000 - const [name, setName] = useState(`App ${Math.floor(Math.random() * 1000)}`) const { data, mutate: enroll, isPending: isEnrolling, reset } = useMfaEnrollMutation() useEffect(() => { - // reset has to be called because the state is kept between if the process is canceled during - // the second step. - if (!visible) { - setName(`App ${Math.floor(Math.random() * 1000)}`) - reset() - } + if (!visible) reset() }, [reset, visible]) return ( <> } isLoading={isEnrolling} onClose={onClose} @@ -55,14 +52,33 @@ export const AddNewFactorModal = ({ visible, onClose }: AddNewFactorModalProps) interface FirstStepProps { visible: boolean - name: string - setName: Dispatch> - enroll: (params: { factorType: 'totp'; friendlyName?: string }) => void isEnrolling: boolean + reset: () => void + enroll: (params: { factorType: 'totp'; friendlyName?: string }) => void onClose: () => void } -const FirstStep = ({ visible, name, enroll, setName, isEnrolling, onClose }: FirstStepProps) => { +const FirstStep = ({ visible, isEnrolling, reset, enroll, onClose }: FirstStepProps) => { + const FormSchema = z.object({ + name: z.string().min(1, 'Please provide a name to identify this app'), + }) + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { name: '' }, + mode: 'onChange', + }) + + const onSubmit: SubmitHandler> = async (values) => { + enroll({ factorType: 'totp', friendlyName: values.name }) + } + + useEffect(() => { + if (!visible) { + // Generate a name with a number between 0 and 1000 + form.reset({ name: `App ${Math.floor(Math.random() * 1000)}` }) + } + }, [form, visible]) + return ( { - enroll({ - factorType: 'totp', - friendlyName: name, - }) - }} + onConfirm={form.handleSubmit(onSubmit)} > - setName(e.target.value)} - /> + +
+ ( + + + + + + )} + /> + +
) } @@ -96,11 +124,7 @@ interface SecondStepProps { factor?: { id: string type: 'totp' - totp: { - qr_code: string - secret: string - uri: string - } + totp: TOTP } isLoading: boolean onClose: () => void @@ -113,14 +137,23 @@ const SecondStep = ({ isLoading, onClose, }: SecondStepProps) => { - const [code, setCode] = useState('') const queryClient = useQueryClient() - const [lastVisitedOrganization] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION, '' ) + const FormSchema = z.object({ + code: z.string().min(1, 'Please provide a code from your authenticator app'), + }) + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { code: '' }, + mode: 'onChange', + }) + + const [factor, setFactor] = useState<{ id: string; type: 'totp'; totp: TOTP } | null>(null) + const { mutate: unenroll } = useMfaUnenrollMutation({ onSuccess: () => onClose() }) const { mutate: challengeAndVerify, isPending: isVerifying } = useMfaChallengeAndVerifyMutation({ onError: (error) => { @@ -137,15 +170,10 @@ const SecondStep = ({ }, }) - const [factor, setFactor] = useState<{ - id: string - type: 'totp' - totp: { - qr_code: string - secret: string - uri: string - } - } | null>(null) + const onSubmit: SubmitHandler> = async (values) => { + if (!factor) return toast.error('Factor required') + challengeAndVerify({ factorId: factor.id, code: values.code }) + } // this useEffect is to keep the factor until a new one comes. This is a fix to an issue which // happens when closing the modal, the outer factor is reset to null too soon and the modal @@ -153,6 +181,7 @@ const SecondStep = ({ useEffect(() => { if (outerFactor && factor?.id !== outerFactor.id) { setFactor(outerFactor) + form.reset({ code: '' }) } }, [outerFactor]) @@ -160,6 +189,7 @@ const SecondStep = ({ factor && challengeAndVerify({ factorId: factor.id, code })} + onConfirm={form.handleSubmit(onSubmit)} > -
- - Use an authenticator app to scan the following QR code, and provide the code from the app - to complete the enrolment. - -
+

+ Use an authenticator app to scan the following QR code, and provide the code from the app to + complete the enrolment. +

+ {isLoading && (
)} + {factor && ( - <> +
{factor.totp.uri}
-
- - } - /> -
-
- setCode(e.target.value)} - /> -
- + + + } + /> + + +
+ ( + + + + + + )} + /> + +
+
)}
) diff --git a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx index d14f1f645ce..f408cdbaadb 100644 --- a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx @@ -11,7 +11,7 @@ import { getReturnToPath } from 'lib/gotrue' import { Lock } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -34,12 +34,15 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => { const router = useRouter() const signOut = useSignOut() const queryClient = useQueryClient() + const [selectedFactor, setSelectedFactor] = useState(null) const form = useForm>({ resolver: zodResolver(schema), defaultValues: { code: '' }, }) + const { code } = form.watch() + const { data: factors, error: factorsError, @@ -89,6 +92,10 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => { } }, [factors?.totp, isSuccessFactors, router, queryClient]) + useEffect(() => { + if (code.length === 6) form.handleSubmit(onSubmit)() + }, [code]) + const error = useAuthError() if (error) { @@ -134,7 +141,7 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => { { )} /> -
+