| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { useQueryClient } from '@tanstack/react-query'
- import { LOCAL_STORAGE_KEYS } from 'common'
- import { useEffect, useState } from 'react'
- import { useForm, type SubmitHandler } from 'react-hook-form'
- import { toast } from 'sonner'
- import { Form, FormControl, FormField, Input } from 'ui'
- import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
- 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'
- import InformationBox from '@/components/ui/InformationBox'
- import { organizationKeys } from '@/data/organizations/keys'
- import { useMfaChallengeAndVerifyMutation } from '@/data/profile/mfa-challenge-and-verify-mutation'
- import { useMfaEnrollMutation } from '@/data/profile/mfa-enroll-mutation'
- import { useMfaUnenrollMutation } from '@/data/profile/mfa-unenroll-mutation'
- import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
- type TOTP = { qr_code: string; secret: string; uri: string }
- interface AddNewFactorModalProps {
- visible: boolean
- onClose: () => void
- }
- export const AddNewFactorModal = ({ visible, onClose }: AddNewFactorModalProps) => {
- const { data, mutate: enroll, isPending: isEnrolling, reset } = useMfaEnrollMutation()
- useEffect(() => {
- if (!visible) reset()
- }, [reset, visible])
- return (
- <>
- <FirstStep
- visible={visible && !Boolean(data)}
- isEnrolling={isEnrolling}
- enroll={enroll}
- reset={reset}
- onClose={onClose}
- />
- <SecondStep
- visible={visible && Boolean(data)}
- factorName={data?.friendly_name ?? ''}
- factor={data as Extract<typeof data, { type: 'totp' }>}
- isLoading={isEnrolling}
- onClose={onClose}
- />
- </>
- )
- }
- interface FirstStepProps {
- visible: boolean
- isEnrolling: boolean
- reset: () => void
- enroll: (params: { factorType: 'totp'; friendlyName?: string }) => void
- onClose: () => void
- }
- const FirstStep = ({ visible, isEnrolling, enroll, onClose }: FirstStepProps) => {
- const FormSchema = z.object({
- name: z.string().min(1, 'Please provide a name to identify this app'),
- })
- const form = useForm<z.infer<typeof FormSchema>>({
- resolver: zodResolver(FormSchema as any),
- defaultValues: { name: '' },
- mode: 'onChange',
- })
- const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = 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 (
- <ConfirmationModal
- size="medium"
- visible={visible}
- title="Add a new authenticator app as a factor"
- confirmLabel="Generate QR"
- confirmLabelLoading="Generating QR"
- loading={isEnrolling}
- onCancel={onClose}
- onConfirm={form.handleSubmit(onSubmit)}
- >
- <Form {...form}>
- <form
- id="verify-otp-form"
- className="flex flex-col gap-4"
- onSubmit={form.handleSubmit(onSubmit)}
- >
- <FormField
- key="name"
- name="name"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout
- name="name"
- label="Provide a name to identify this app"
- description="A string will be randomly generated if a name is not provided"
- >
- <FormControl>
- <Input id="name" {...field} />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </form>
- </Form>
- </ConfirmationModal>
- )
- }
- interface SecondStepProps {
- visible: boolean
- factorName: string
- factor?: {
- id: string
- type: 'totp'
- totp: TOTP
- }
- isLoading: boolean
- onClose: () => void
- }
- const SecondStep = ({
- visible,
- factorName,
- factor: outerFactor,
- isLoading,
- onClose,
- }: SecondStepProps) => {
- 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<z.infer<typeof FormSchema>>({
- resolver: zodResolver(FormSchema as any),
- 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) => {
- toast.error(`Failed to add a second factor authentication: ${error?.message}`)
- },
- onSuccess: async () => {
- if (lastVisitedOrganization) {
- await queryClient.invalidateQueries({
- queryKey: organizationKeys.members(lastVisitedOrganization),
- })
- }
- toast.success(`Successfully added a second factor authentication`)
- onClose()
- },
- })
- const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = 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
- // removes a big div mid transition.
- useEffect(() => {
- if (outerFactor && factor?.id !== outerFactor.id) {
- setFactor(outerFactor)
- form.reset({ code: '' })
- }
- }, [outerFactor])
- return (
- <ConfirmationModal
- size="medium"
- visible={visible}
- className="py-5"
- title={`Verify new factor ${factorName}`}
- confirmLabel="Confirm"
- confirmLabelLoading="Confirming"
- loading={isVerifying}
- onCancel={() => {
- // If a factor has been created (but not verified), unenroll it. This will be run as a
- // side effect so that it's not confusing to the user why the modal stays open while
- // unenrolling.
- if (factor) unenroll({ factorId: factor.id })
- }}
- onConfirm={form.handleSubmit(onSubmit)}
- >
- <p className="text-sm">
- Use an authenticator app to scan the following QR code, and provide the code from the app to
- complete the enrolment.
- </p>
- {isLoading && (
- <div className="pb-4 px-4">
- <GenericSkeletonLoader />
- </div>
- )}
- {factor && (
- <div className="flex flex-col gap-y-4">
- <div className="flex justify-center py-6">
- <div className="h-48 w-48 bg-white rounded-sm">
- <img width={190} height={190} src={factor.totp.qr_code} alt={factor.totp.uri} />
- </div>
- </div>
- <InformationBox
- title="Unable to scan?"
- description={
- <FormItemLayout
- isReactForm={false}
- label="You can also enter this secret key into your authenticator app"
- >
- <PasswordInput copy disabled id="ref" size="small" value={factor.totp.secret} />
- </FormItemLayout>
- }
- />
- <Form {...form}>
- <form
- id="verify-otp-form"
- className="flex flex-col gap-4"
- onSubmit={form.handleSubmit(onSubmit)}
- >
- <FormField
- key="code"
- name="code"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout name="code" label="Authentication code">
- <FormControl>
- <Input
- id="code"
- autoFocus
- {...field}
- placeholder="XXXXXX"
- className="font-mono"
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </form>
- </Form>
- </div>
- )}
- </ConfirmationModal>
- )
- }
|