confirm-email-form.tsx 9.3 KB

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