AddNewFactorModal.tsx268 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { useQueryClient } from '@tanstack/react-query'
3import { LOCAL_STORAGE_KEYS } from 'common'
4import { useEffect, useState } from 'react'
5import { useForm, type SubmitHandler } from 'react-hook-form'
6import { toast } from 'sonner'
7import { Form, FormControl, FormField, Input } from 'ui'
8import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
9import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
10import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
11import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
12import { z } from 'zod'
13
14import InformationBox from '@/components/ui/InformationBox'
15import { organizationKeys } from '@/data/organizations/keys'
16import { useMfaChallengeAndVerifyMutation } from '@/data/profile/mfa-challenge-and-verify-mutation'
17import { useMfaEnrollMutation } from '@/data/profile/mfa-enroll-mutation'
18import { useMfaUnenrollMutation } from '@/data/profile/mfa-unenroll-mutation'
19import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
20
21type TOTP = { qr_code: string; secret: string; uri: string }
22
23interface AddNewFactorModalProps {
24 visible: boolean
25 onClose: () => void
26}
27
28export const AddNewFactorModal = ({ visible, onClose }: AddNewFactorModalProps) => {
29 const { data, mutate: enroll, isPending: isEnrolling, reset } = useMfaEnrollMutation()
30
31 useEffect(() => {
32 if (!visible) reset()
33 }, [reset, visible])
34
35 return (
36 <>
37 <FirstStep
38 visible={visible && !Boolean(data)}
39 isEnrolling={isEnrolling}
40 enroll={enroll}
41 reset={reset}
42 onClose={onClose}
43 />
44 <SecondStep
45 visible={visible && Boolean(data)}
46 factorName={data?.friendly_name ?? ''}
47 factor={data as Extract<typeof data, { type: 'totp' }>}
48 isLoading={isEnrolling}
49 onClose={onClose}
50 />
51 </>
52 )
53}
54
55interface FirstStepProps {
56 visible: boolean
57 isEnrolling: boolean
58 reset: () => void
59 enroll: (params: { factorType: 'totp'; friendlyName?: string }) => void
60 onClose: () => void
61}
62
63const FirstStep = ({ visible, isEnrolling, enroll, onClose }: FirstStepProps) => {
64 const FormSchema = z.object({
65 name: z.string().min(1, 'Please provide a name to identify this app'),
66 })
67 const form = useForm<z.infer<typeof FormSchema>>({
68 resolver: zodResolver(FormSchema as any),
69 defaultValues: { name: '' },
70 mode: 'onChange',
71 })
72
73 const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
74 enroll({ factorType: 'totp', friendlyName: values.name })
75 }
76
77 useEffect(() => {
78 if (!visible) {
79 // Generate a name with a number between 0 and 1000
80 form.reset({ name: `App ${Math.floor(Math.random() * 1000)}` })
81 }
82 }, [form, visible])
83
84 return (
85 <ConfirmationModal
86 size="medium"
87 visible={visible}
88 title="Add a new authenticator app as a factor"
89 confirmLabel="Generate QR"
90 confirmLabelLoading="Generating QR"
91 loading={isEnrolling}
92 onCancel={onClose}
93 onConfirm={form.handleSubmit(onSubmit)}
94 >
95 <Form {...form}>
96 <form
97 id="verify-otp-form"
98 className="flex flex-col gap-4"
99 onSubmit={form.handleSubmit(onSubmit)}
100 >
101 <FormField
102 key="name"
103 name="name"
104 control={form.control}
105 render={({ field }) => (
106 <FormItemLayout
107 name="name"
108 label="Provide a name to identify this app"
109 description="A string will be randomly generated if a name is not provided"
110 >
111 <FormControl>
112 <Input id="name" {...field} />
113 </FormControl>
114 </FormItemLayout>
115 )}
116 />
117 </form>
118 </Form>
119 </ConfirmationModal>
120 )
121}
122
123interface SecondStepProps {
124 visible: boolean
125 factorName: string
126 factor?: {
127 id: string
128 type: 'totp'
129 totp: TOTP
130 }
131 isLoading: boolean
132 onClose: () => void
133}
134
135const SecondStep = ({
136 visible,
137 factorName,
138 factor: outerFactor,
139 isLoading,
140 onClose,
141}: SecondStepProps) => {
142 const queryClient = useQueryClient()
143 const [lastVisitedOrganization] = useLocalStorageQuery(
144 LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
145 ''
146 )
147
148 const FormSchema = z.object({
149 code: z.string().min(1, 'Please provide a code from your authenticator app'),
150 })
151 const form = useForm<z.infer<typeof FormSchema>>({
152 resolver: zodResolver(FormSchema as any),
153 defaultValues: { code: '' },
154 mode: 'onChange',
155 })
156
157 const [factor, setFactor] = useState<{ id: string; type: 'totp'; totp: TOTP } | null>(null)
158
159 const { mutate: unenroll } = useMfaUnenrollMutation({ onSuccess: () => onClose() })
160 const { mutate: challengeAndVerify, isPending: isVerifying } = useMfaChallengeAndVerifyMutation({
161 onError: (error) => {
162 toast.error(`Failed to add a second factor authentication: ${error?.message}`)
163 },
164 onSuccess: async () => {
165 if (lastVisitedOrganization) {
166 await queryClient.invalidateQueries({
167 queryKey: organizationKeys.members(lastVisitedOrganization),
168 })
169 }
170 toast.success(`Successfully added a second factor authentication`)
171 onClose()
172 },
173 })
174
175 const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
176 if (!factor) return toast.error('Factor required')
177 challengeAndVerify({ factorId: factor.id, code: values.code })
178 }
179
180 // this useEffect is to keep the factor until a new one comes. This is a fix to an issue which
181 // happens when closing the modal, the outer factor is reset to null too soon and the modal
182 // removes a big div mid transition.
183 useEffect(() => {
184 if (outerFactor && factor?.id !== outerFactor.id) {
185 setFactor(outerFactor)
186 form.reset({ code: '' })
187 }
188 }, [outerFactor])
189
190 return (
191 <ConfirmationModal
192 size="medium"
193 visible={visible}
194 className="py-5"
195 title={`Verify new factor ${factorName}`}
196 confirmLabel="Confirm"
197 confirmLabelLoading="Confirming"
198 loading={isVerifying}
199 onCancel={() => {
200 // If a factor has been created (but not verified), unenroll it. This will be run as a
201 // side effect so that it's not confusing to the user why the modal stays open while
202 // unenrolling.
203 if (factor) unenroll({ factorId: factor.id })
204 }}
205 onConfirm={form.handleSubmit(onSubmit)}
206 >
207 <p className="text-sm">
208 Use an authenticator app to scan the following QR code, and provide the code from the app to
209 complete the enrolment.
210 </p>
211
212 {isLoading && (
213 <div className="pb-4 px-4">
214 <GenericSkeletonLoader />
215 </div>
216 )}
217
218 {factor && (
219 <div className="flex flex-col gap-y-4">
220 <div className="flex justify-center py-6">
221 <div className="h-48 w-48 bg-white rounded-sm">
222 <img width={190} height={190} src={factor.totp.qr_code} alt={factor.totp.uri} />
223 </div>
224 </div>
225
226 <InformationBox
227 title="Unable to scan?"
228 description={
229 <FormItemLayout
230 isReactForm={false}
231 label="You can also enter this secret key into your authenticator app"
232 >
233 <PasswordInput copy disabled id="ref" size="small" value={factor.totp.secret} />
234 </FormItemLayout>
235 }
236 />
237
238 <Form {...form}>
239 <form
240 id="verify-otp-form"
241 className="flex flex-col gap-4"
242 onSubmit={form.handleSubmit(onSubmit)}
243 >
244 <FormField
245 key="code"
246 name="code"
247 control={form.control}
248 render={({ field }) => (
249 <FormItemLayout name="code" label="Authentication code">
250 <FormControl>
251 <Input
252 id="code"
253 autoFocus
254 {...field}
255 placeholder="XXXXXX"
256 className="font-mono"
257 />
258 </FormControl>
259 </FormItemLayout>
260 )}
261 />
262 </form>
263 </Form>
264 </div>
265 )}
266 </ConfirmationModal>
267 )
268}