AddNewFactorModal.tsx268 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { useQueryClient } from '@tanstack/react-query' |
| 3 | import { LOCAL_STORAGE_KEYS } from 'common' |
| 4 | import { useEffect, useState } from 'react' |
| 5 | import { useForm, type SubmitHandler } from 'react-hook-form' |
| 6 | import { toast } from 'sonner' |
| 7 | import { Form, FormControl, FormField, Input } from 'ui' |
| 8 | import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' |
| 9 | import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' |
| 10 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 11 | import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 12 | import { z } from 'zod' |
| 13 | |
| 14 | import InformationBox from '@/components/ui/InformationBox' |
| 15 | import { organizationKeys } from '@/data/organizations/keys' |
| 16 | import { useMfaChallengeAndVerifyMutation } from '@/data/profile/mfa-challenge-and-verify-mutation' |
| 17 | import { useMfaEnrollMutation } from '@/data/profile/mfa-enroll-mutation' |
| 18 | import { useMfaUnenrollMutation } from '@/data/profile/mfa-unenroll-mutation' |
| 19 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 20 | |
| 21 | type TOTP = { qr_code: string; secret: string; uri: string } |
| 22 | |
| 23 | interface AddNewFactorModalProps { |
| 24 | visible: boolean |
| 25 | onClose: () => void |
| 26 | } |
| 27 | |
| 28 | export 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 | |
| 55 | interface FirstStepProps { |
| 56 | visible: boolean |
| 57 | isEnrolling: boolean |
| 58 | reset: () => void |
| 59 | enroll: (params: { factorType: 'totp'; friendlyName?: string }) => void |
| 60 | onClose: () => void |
| 61 | } |
| 62 | |
| 63 | const 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 | |
| 123 | interface 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 | |
| 135 | const 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 | } |