AddNewFactorModal.tsx 8.3 KB

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