confirm-email-form.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import { postJSON } from '@/infrastructure/fetch-json'
  2. import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
  3. import Notification from '@/shared/components/notification'
  4. import getMeta from '@/utils/meta'
  5. import { FormEvent, MouseEventHandler, ReactNode, useState } from 'react'
  6. import { Trans, useTranslation } from 'react-i18next'
  7. import LoadingSpinner from '@/shared/components/loading-spinner'
  8. import MaterialIcon from '@/shared/components/material-icon'
  9. import { sendMB } from '@/infrastructure/event-tracking'
  10. import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
  11. import OLButton from '@/features/ui/components/ol/ol-button'
  12. import { useLocation } from '@/shared/hooks/use-location'
  13. type Feedback = {
  14. type: 'input' | 'alert'
  15. style: 'error' | 'info'
  16. message: string
  17. }
  18. type ConfirmEmailFormProps = {
  19. confirmationEndpoint: string
  20. flow: string
  21. resendEndpoint: string
  22. successMessage?: React.ReactNode
  23. successButtonText?: string
  24. email?: string
  25. onSuccessfulConfirmation?: () => void
  26. interstitial: boolean
  27. isModal?: boolean
  28. onCancel?: () => void
  29. outerError?: string
  30. }
  31. export function ConfirmEmailForm({
  32. confirmationEndpoint,
  33. flow,
  34. resendEndpoint,
  35. successMessage,
  36. successButtonText,
  37. email = getMeta('ol-email'),
  38. onSuccessfulConfirmation,
  39. interstitial,
  40. isModal,
  41. onCancel,
  42. outerError,
  43. }: ConfirmEmailFormProps) {
  44. const { t } = useTranslation()
  45. const [confirmationCode, setConfirmationCode] = useState('')
  46. const [feedback, setFeedback] = useState<Feedback | null>(null)
  47. const [isConfirming, setIsConfirming] = useState(false)
  48. const [isResending, setIsResending] = useState(false)
  49. const [hasResent, setHasResent] = useState(false)
  50. const [successRedirectPath, setSuccessRedirectPath] = useState('')
  51. const { isReady } = useWaitForI18n()
  52. const outerErrorDisplay = (!hasResent && outerError) || null
  53. const errorHandler = (err: any, actionType?: string) => {
  54. let errorName = err?.data?.message?.key || 'generic_something_went_wrong'
  55. if (err?.response?.status === 429) {
  56. if (actionType === 'confirm') {
  57. errorName = 'too_many_confirm_code_verification_attempts'
  58. } else if (actionType === 'resend') {
  59. errorName = 'too_many_confirm_code_resend_attempts'
  60. }
  61. setFeedback({
  62. type: 'alert',
  63. style: 'error',
  64. message: errorName,
  65. })
  66. } else {
  67. setFeedback({
  68. type: 'input',
  69. style: 'error',
  70. message: errorName,
  71. })
  72. }
  73. sendMB('email-verification-error', {
  74. errorName,
  75. flow,
  76. })
  77. }
  78. const invalidFormHandler = () => {
  79. if (!confirmationCode) {
  80. return setFeedback({
  81. type: 'input',
  82. style: 'error',
  83. message: 'please_enter_confirmation_code',
  84. })
  85. }
  86. }
  87. const submitHandler = async (e: FormEvent<HTMLFormElement>) => {
  88. e.preventDefault()
  89. setIsConfirming(true)
  90. setFeedback(null)
  91. sendMB('email-verification-click', {
  92. button: 'verify',
  93. flow,
  94. })
  95. try {
  96. const data = await postJSON(confirmationEndpoint, {
  97. body: { code: confirmationCode },
  98. })
  99. if (onSuccessfulConfirmation) {
  100. onSuccessfulConfirmation()
  101. } else {
  102. setSuccessRedirectPath(data?.redir || '/')
  103. }
  104. } catch (err) {
  105. errorHandler(err, 'confirm')
  106. } finally {
  107. setIsConfirming(false)
  108. }
  109. }
  110. const resendHandler: MouseEventHandler<HTMLButtonElement> = () => {
  111. setIsResending(true)
  112. setFeedback(null)
  113. postJSON(resendEndpoint)
  114. .then(data => {
  115. setIsResending(false)
  116. if (data?.message?.key) {
  117. setFeedback({
  118. type: 'alert',
  119. style: 'info',
  120. message: data.message.key,
  121. })
  122. }
  123. })
  124. .catch(err => {
  125. errorHandler(err, 'resend')
  126. })
  127. .finally(() => {
  128. setIsResending(false)
  129. setHasResent(true)
  130. })
  131. sendMB('email-verification-click', {
  132. button: 'resend',
  133. flow,
  134. })
  135. }
  136. const changeHandler = (e: FormEvent<HTMLInputElement>) => {
  137. setConfirmationCode(e.currentTarget.value)
  138. setFeedback(null)
  139. }
  140. if (!isReady) {
  141. return <LoadingSpinner />
  142. }
  143. if (successRedirectPath && successButtonText && successMessage) {
  144. return (
  145. <ConfirmEmailSuccessfullForm
  146. successMessage={successMessage}
  147. successButtonText={successButtonText}
  148. redirectTo={successRedirectPath}
  149. />
  150. )
  151. }
  152. let intro: ReactNode | null = (
  153. <h5 className="h5">{t('confirm_your_email')}</h5>
  154. )
  155. if (isModal)
  156. intro = outerErrorDisplay ? (
  157. <div className="mt-4" />
  158. ) : (
  159. <h3 className="h5">{outerErrorDisplay ? null : t('we_sent_code')}</h3>
  160. )
  161. if (interstitial)
  162. intro = (
  163. <h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
  164. )
  165. return (
  166. <form
  167. onSubmit={submitHandler}
  168. onInvalid={invalidFormHandler}
  169. className="confirm-email-form"
  170. data-testid="confirm-email-form"
  171. >
  172. <div className="confirm-email-form-inner">
  173. {(feedback?.type === 'alert' || outerErrorDisplay) && (
  174. <Notification
  175. ariaLive="polite"
  176. className="confirm-email-alert"
  177. type={outerErrorDisplay ? 'error' : feedback!.style}
  178. content={
  179. outerErrorDisplay || <ErrorMessage error={feedback!.message!} />
  180. }
  181. />
  182. )}
  183. {intro}
  184. <OLFormLabel htmlFor="one-time-code">
  185. {isModal
  186. ? t('enter_the_code', { email })
  187. : t('enter_the_confirmation_code', { email })}
  188. </OLFormLabel>
  189. <input
  190. id="one-time-code"
  191. className="form-control"
  192. inputMode="numeric"
  193. required
  194. value={confirmationCode}
  195. onChange={changeHandler}
  196. data-ol-dirty={feedback ? 'true' : undefined}
  197. maxLength={6}
  198. autoComplete="one-time-code"
  199. autoFocus // eslint-disable-line jsx-a11y/no-autofocus
  200. disabled={!!outerErrorDisplay}
  201. />
  202. <div aria-live="polite">
  203. {feedback?.type === 'input' && (
  204. <div className="small text-danger">
  205. <MaterialIcon className="icon" type="error" />
  206. <div>
  207. <ErrorMessage error={feedback.message} />
  208. </div>
  209. </div>
  210. )}
  211. </div>
  212. <div className="form-actions">
  213. <OLButton
  214. disabled={isResending || !!outerErrorDisplay}
  215. type="submit"
  216. isLoading={isConfirming}
  217. loadingLabel={t('confirming')}
  218. >
  219. {t('confirm')}
  220. </OLButton>
  221. <OLButton
  222. variant="secondary"
  223. disabled={isConfirming}
  224. onClick={resendHandler}
  225. isLoading={isResending}
  226. loadingLabel={t('resending_confirmation_code')}
  227. >
  228. {t('resend_confirmation_code')}
  229. </OLButton>
  230. {onCancel && (
  231. <OLButton
  232. variant="danger-ghost"
  233. disabled={isConfirming || isResending}
  234. onClick={onCancel}
  235. >
  236. {t('cancel')}
  237. </OLButton>
  238. )}
  239. </div>
  240. </div>
  241. </form>
  242. )
  243. }
  244. function ConfirmEmailSuccessfullForm({
  245. successMessage,
  246. successButtonText,
  247. redirectTo,
  248. }: {
  249. successMessage: React.ReactNode
  250. successButtonText: string
  251. redirectTo: string
  252. }) {
  253. const location = useLocation()
  254. const submitHandler = (e: FormEvent<HTMLFormElement>) => {
  255. e.preventDefault()
  256. location.assign(redirectTo)
  257. }
  258. return (
  259. <form onSubmit={submitHandler}>
  260. <div aria-live="polite">{successMessage}</div>
  261. <div className="form-actions">
  262. <OLButton type="submit" variant="primary">
  263. {successButtonText}
  264. </OLButton>
  265. </div>
  266. </form>
  267. )
  268. }
  269. function ErrorMessage({ error }: { error: string }) {
  270. const { t } = useTranslation()
  271. switch (error) {
  272. case 'invalid_confirmation_code':
  273. return <span>{t('invalid_confirmation_code')}</span>
  274. case 'expired_confirmation_code':
  275. return (
  276. <Trans
  277. i18nKey="expired_confirmation_code"
  278. /* eslint-disable-next-line react/jsx-key */
  279. components={[<strong />]}
  280. />
  281. )
  282. case 'email_already_registered':
  283. return <span>{t('email_already_registered')}</span>
  284. case 'too_many_confirm_code_resend_attempts':
  285. return <span>{t('too_many_confirm_code_resend_attempts')}</span>
  286. case 'too_many_confirm_code_verification_attempts':
  287. return <span>{t('too_many_confirm_code_verification_attempts')}</span>
  288. case 'we_sent_new_code':
  289. return <span>{t('we_sent_new_code')}</span>
  290. case 'please_enter_confirmation_code':
  291. return <span>{t('please_enter_confirmation_code')}</span>
  292. default:
  293. return <span>{t('generic_something_went_wrong')}</span>
  294. }
  295. }