confirm-email-form.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  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 {
  6. ChangeEventHandler,
  7. ComponentProps,
  8. FormEvent,
  9. MouseEventHandler,
  10. useState,
  11. } from 'react'
  12. import { Trans, useTranslation } from 'react-i18next'
  13. import LoadingSpinner from '@/shared/components/loading-spinner'
  14. import { sendMB } from '@/infrastructure/event-tracking'
  15. import OLFormLabel from '@/shared/components/ol/ol-form-label'
  16. import OLButton from '@/shared/components/ol/ol-button'
  17. import { useLocation } from '@/shared/hooks/use-location'
  18. import DSFormLabel from '@/shared/components/ds/ds-form-label'
  19. import DSButton from '@/shared/components/ds/ds-button'
  20. import CIAMSixDigitsInput from '@/features/settings/components/emails/ciam-six-digits-input'
  21. import OLFormText from '@/shared/components/ol/ol-form-text'
  22. import DSFormText from '@/shared/components/ds/ds-form-text'
  23. import { CaretRight } from '@phosphor-icons/react'
  24. import DSNotification from '@/shared/components/ds/ds-notification'
  25. type Feedback = {
  26. type: 'input' | 'alert'
  27. style: 'error' | 'info'
  28. message: string
  29. }
  30. type ConfirmEmailFormProps = {
  31. confirmationEndpoint: string
  32. flow: 'registration' | 'resend' | 'secondary'
  33. resendEndpoint: string
  34. successMessage?: React.ReactNode
  35. successButtonText?: string
  36. email?: string
  37. onSuccessfulConfirmation?: () => void
  38. interstitial: boolean
  39. isModal?: boolean
  40. onCancel?: MouseEventHandler<HTMLButtonElement>
  41. outerError?: string
  42. isCiam?: boolean
  43. }
  44. const OLSixDigitsInput = (props: ComponentProps<'input'>) => (
  45. <input
  46. inputMode="numeric"
  47. maxLength={6}
  48. className="form-control"
  49. {...props}
  50. />
  51. )
  52. export function ConfirmEmailForm({
  53. confirmationEndpoint,
  54. flow,
  55. resendEndpoint,
  56. successMessage,
  57. successButtonText,
  58. email = getMeta('ol-email'),
  59. onSuccessfulConfirmation,
  60. interstitial,
  61. isModal,
  62. onCancel,
  63. outerError,
  64. isCiam,
  65. }: ConfirmEmailFormProps) {
  66. const { t } = useTranslation()
  67. const [confirmationCode, setConfirmationCode] = useState('')
  68. const [feedback, setFeedback] = useState<Feedback | null>(null)
  69. const [isConfirming, setIsConfirming] = useState(false)
  70. const [isResending, setIsResending] = useState(false)
  71. const [hasResent, setHasResent] = useState(false)
  72. const [successRedirectPath, setSuccessRedirectPath] = useState('')
  73. const { isReady } = useWaitForI18n()
  74. const outerErrorDisplay = (!hasResent && outerError) || null
  75. const errorHandler = (err: any, actionType?: string) => {
  76. let errorName = err?.data?.message?.key || 'generic_something_went_wrong'
  77. if (err?.response?.status === 429) {
  78. if (actionType === 'confirm') {
  79. errorName = 'too_many_confirm_code_verification_attempts'
  80. } else if (actionType === 'resend') {
  81. errorName = 'too_many_confirm_code_resend_attempts'
  82. }
  83. setFeedback({
  84. type: 'alert',
  85. style: 'error',
  86. message: errorName,
  87. })
  88. } else {
  89. setFeedback({
  90. type: 'input',
  91. style: 'error',
  92. message: errorName,
  93. })
  94. }
  95. sendMB('email-verification-error', {
  96. errorName,
  97. flow,
  98. })
  99. }
  100. const invalidFormHandler = () => {
  101. if (!confirmationCode) {
  102. return setFeedback({
  103. type: 'input',
  104. style: 'error',
  105. message: 'please_enter_confirmation_code',
  106. })
  107. }
  108. }
  109. const submitHandler = async (e: FormEvent<HTMLFormElement>) => {
  110. e.preventDefault()
  111. setIsConfirming(true)
  112. setFeedback(null)
  113. sendMB('email-verification-click', {
  114. button: 'verify',
  115. flow,
  116. })
  117. try {
  118. const data = await postJSON(confirmationEndpoint, {
  119. body: { code: confirmationCode },
  120. })
  121. if (onSuccessfulConfirmation) {
  122. onSuccessfulConfirmation()
  123. } else {
  124. setSuccessRedirectPath(data?.redir || '/')
  125. }
  126. } catch (err) {
  127. errorHandler(err, 'confirm')
  128. } finally {
  129. setIsConfirming(false)
  130. }
  131. }
  132. const resendHandler: MouseEventHandler<HTMLButtonElement> = () => {
  133. setIsResending(true)
  134. setFeedback(null)
  135. postJSON(resendEndpoint)
  136. .then(data => {
  137. if (data?.message?.key) {
  138. setFeedback({
  139. type: 'alert',
  140. style: 'info',
  141. message: data.message.key,
  142. })
  143. }
  144. })
  145. .catch(err => {
  146. errorHandler(err, 'resend')
  147. })
  148. .finally(() => {
  149. setIsResending(false)
  150. setHasResent(true)
  151. })
  152. sendMB('email-verification-click', {
  153. button: 'resend',
  154. flow,
  155. })
  156. }
  157. const changeHandler: ChangeEventHandler<HTMLInputElement> = e => {
  158. setConfirmationCode(e.currentTarget.value)
  159. setFeedback(null)
  160. }
  161. if (!isReady) {
  162. return <LoadingSpinner />
  163. }
  164. if (successRedirectPath && successButtonText && successMessage) {
  165. return (
  166. <ConfirmEmailSuccessfulForm
  167. successMessage={successMessage}
  168. successButtonText={successButtonText}
  169. redirectTo={successRedirectPath}
  170. isCiam={Boolean(isCiam)}
  171. />
  172. )
  173. }
  174. const longLabel = isModal ? (
  175. t('enter_the_code', { email })
  176. ) : (
  177. <Trans
  178. i18nKey="enter_the_confirmation_code"
  179. components={[isCiam ? <strong /> : <span />]}
  180. values={{ email }}
  181. shouldUnescape
  182. tOptions={{ interpolation: { escapeValue: true } }}
  183. />
  184. )
  185. const Button = isCiam ? DSButton : OLButton
  186. const buttonSize = isCiam ? 'lg' : undefined
  187. const SixDigits = isCiam ? CIAMSixDigitsInput : OLSixDigitsInput
  188. const FormText = isCiam ? DSFormText : OLFormText
  189. const NotificationComponent = isCiam ? DSNotification : Notification
  190. const outerErrorEl = (feedback?.type === 'alert' || outerErrorDisplay) && (
  191. <NotificationComponent
  192. ariaLive="polite"
  193. className="confirm-email-alert"
  194. type={outerErrorDisplay ? 'error' : feedback!.style}
  195. content={outerErrorDisplay || <ErrorMessage error={feedback!.message!} />}
  196. />
  197. )
  198. return (
  199. <form
  200. onSubmit={submitHandler}
  201. onInvalid={invalidFormHandler}
  202. className="confirm-email-form"
  203. data-testid="confirm-email-form"
  204. >
  205. <div className="confirm-email-form-inner">
  206. {!isCiam && outerErrorEl}
  207. <Title
  208. isModal={isModal}
  209. interstitial={interstitial}
  210. isCiam={isCiam}
  211. outerErrorDisplay={outerErrorDisplay}
  212. />
  213. {isCiam && outerErrorEl}
  214. {isCiam && <p>{longLabel}</p>}
  215. {isCiam ? (
  216. <DSFormLabel htmlFor="one-time-code">
  217. {t('verification_code')}
  218. </DSFormLabel>
  219. ) : (
  220. <OLFormLabel htmlFor="one-time-code">{longLabel}</OLFormLabel>
  221. )}
  222. <SixDigits
  223. id="one-time-code"
  224. required
  225. value={confirmationCode}
  226. onChange={changeHandler}
  227. data-ol-dirty={feedback ? 'true' : undefined}
  228. autoComplete="one-time-code"
  229. autoFocus // eslint-disable-line jsx-a11y/no-autofocus
  230. disabled={!!outerErrorDisplay}
  231. />
  232. <div aria-live="polite">
  233. {feedback?.type === 'input' && (
  234. <FormText type="error" marginless>
  235. <ErrorMessage error={feedback.message} />
  236. </FormText>
  237. )}
  238. </div>
  239. <div className="form-actions">
  240. <Button
  241. size={buttonSize}
  242. disabled={isResending || !!outerErrorDisplay}
  243. type="submit"
  244. isLoading={isConfirming}
  245. loadingLabel={t('confirming')}
  246. >
  247. {t('confirm')}
  248. </Button>
  249. <Button
  250. variant="secondary"
  251. size={buttonSize}
  252. disabled={isConfirming}
  253. onClick={resendHandler}
  254. isLoading={isResending}
  255. loadingLabel={t('resending_confirmation_code')}
  256. >
  257. {t('resend_confirmation_code')}
  258. </Button>
  259. {onCancel && (
  260. <OLButton
  261. variant="danger-ghost"
  262. disabled={isConfirming || isResending}
  263. onClick={onCancel}
  264. >
  265. {t('cancel')}
  266. </OLButton>
  267. )}
  268. </div>
  269. {isCiam && flow === 'registration' && (
  270. <div className="mt-4 mb-2 text-center ">
  271. <Trans
  272. i18nKey="use_a_different_email"
  273. components={[
  274. // eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
  275. <a
  276. href="/register"
  277. onClick={() =>
  278. sendMB('email-verification-click', {
  279. button: 'change-email',
  280. flow,
  281. })
  282. }
  283. />,
  284. ]}
  285. />
  286. </div>
  287. )}
  288. </div>
  289. </form>
  290. )
  291. }
  292. function Title({
  293. isModal,
  294. interstitial,
  295. outerErrorDisplay,
  296. isCiam,
  297. }: {
  298. isModal?: boolean
  299. interstitial: boolean
  300. isCiam?: boolean
  301. outerErrorDisplay: string | null
  302. }) {
  303. const { t } = useTranslation()
  304. if (isCiam) return <h1>{t('verify_your_email_address')}</h1>
  305. if (isModal)
  306. return outerErrorDisplay ? (
  307. <div className="mt-4" />
  308. ) : (
  309. <h3 className="h5">{t('we_sent_code')}</h3>
  310. )
  311. if (interstitial)
  312. return <h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
  313. return <h5 className="h5">{t('confirm_your_email')}</h5>
  314. }
  315. function ConfirmEmailSuccessfulForm({
  316. successMessage,
  317. successButtonText,
  318. redirectTo,
  319. isCiam,
  320. }: {
  321. successMessage: React.ReactNode
  322. successButtonText: string
  323. redirectTo: string
  324. isCiam: boolean
  325. }) {
  326. const location = useLocation()
  327. const submitHandler = (e: FormEvent<HTMLFormElement>) => {
  328. e.preventDefault()
  329. location.assign(redirectTo)
  330. }
  331. const button = isCiam ? (
  332. <DSButton
  333. type="submit"
  334. variant="primary"
  335. size="lg"
  336. className="w-100"
  337. trailingIcon={<CaretRight size={24} />}
  338. >
  339. {successButtonText}
  340. </DSButton>
  341. ) : (
  342. <OLButton type="submit" variant="primary">
  343. {successButtonText}
  344. </OLButton>
  345. )
  346. return (
  347. <form onSubmit={submitHandler} className="confirm-email-success-form">
  348. <div aria-live="polite">{successMessage}</div>
  349. <div className="form-actions">{button}</div>
  350. </form>
  351. )
  352. }
  353. function ErrorMessage({ error }: { error: string }) {
  354. const { t } = useTranslation()
  355. switch (error) {
  356. case 'invalid_confirmation_code':
  357. return <span>{t('invalid_confirmation_code')}</span>
  358. case 'expired_confirmation_code':
  359. return (
  360. <Trans
  361. i18nKey="expired_confirmation_code"
  362. /* eslint-disable-next-line react/jsx-key */
  363. components={[<strong />]}
  364. />
  365. )
  366. case 'email_already_registered':
  367. return <span>{t('email_already_registered')}</span>
  368. case 'email_does_not_belong_to_university':
  369. return <span>{t('email_does_not_belong_to_university')}</span>
  370. case 'too_many_confirm_code_resend_attempts':
  371. return <span>{t('too_many_confirm_code_resend_attempts')}</span>
  372. case 'too_many_confirm_code_verification_attempts':
  373. return <span>{t('too_many_confirm_code_verification_attempts')}</span>
  374. case 'we_sent_new_code':
  375. return <span>{t('we_sent_new_code')}</span>
  376. case 'please_enter_confirmation_code':
  377. return <span>{t('please_enter_confirmation_code')}</span>
  378. case 'email_already_registered_sso':
  379. return <span>{t('email_already_registered_sso')}</span>
  380. default:
  381. return <span>{t('generic_something_went_wrong')}</span>
  382. }
  383. }