hydrate-form.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. import classNames from 'classnames'
  2. import { FetchError, postJSON } from '../../infrastructure/fetch-json'
  3. import { canSkipCaptcha, validateCaptchaV2 } from './captcha'
  4. import inputValidator from './input-validator'
  5. import { disableElement, enableElement } from '../utils/disableElement'
  6. import { materialIcon as createMaterialIcon } from '@/features/utils/material-icon'
  7. import { ciamIcon } from '@/features/utils/ciam-icon'
  8. // Form helper(s) to handle:
  9. // - Attaching to the relevant form elements
  10. // - Listening for submit event
  11. // - Validating captcha
  12. // - Sending fetch request
  13. // - Redirect handling
  14. // - Showing errors
  15. // - Disabled state
  16. interface FormResponse {
  17. redir?: string
  18. redirect?: string
  19. message?:
  20. | {
  21. text?: string
  22. }
  23. | string
  24. }
  25. interface ErrorWithData {
  26. data?: {
  27. message?: {
  28. key?: string
  29. hints?: string[]
  30. }
  31. }
  32. }
  33. interface MessageBagItem {
  34. type: 'error' | 'message' | 'success' | 'warning' | 'info'
  35. key?: string
  36. text: string
  37. hints?: string[]
  38. }
  39. function formSubmitHelper(formEl: HTMLFormElement) {
  40. formEl.addEventListener('submit', async (e: Event) => {
  41. e.preventDefault()
  42. formEl.dispatchEvent(new Event('pending'))
  43. const messageBag: MessageBagItem[] = []
  44. try {
  45. let data: FormResponse
  46. try {
  47. const captchaResponse = await validateCaptcha(formEl)
  48. data = await sendFormRequest(formEl, captchaResponse)
  49. } catch (e) {
  50. if (
  51. e instanceof FetchError &&
  52. e.data?.errorReason === 'cannot_verify_user_not_robot'
  53. ) {
  54. // Trigger captcha unconditionally.
  55. const captchaResponse = await validateCaptchaV2()
  56. if (!captchaResponse) {
  57. throw e
  58. }
  59. data = await sendFormRequest(formEl, captchaResponse)
  60. } else {
  61. throw e
  62. }
  63. }
  64. formEl.dispatchEvent(new Event('sent'))
  65. // Handle redirects
  66. if (data.redir || data.redirect) {
  67. window.location.href = data.redir || data.redirect!
  68. return
  69. }
  70. // Show a success message (e.g. used on 2FA page)
  71. if (data.message) {
  72. messageBag.push({
  73. type: 'message',
  74. text:
  75. typeof data.message === 'string'
  76. ? data.message
  77. : data.message.text || '',
  78. })
  79. }
  80. // Handle reloads
  81. if (formEl.hasAttribute('data-ol-reload-on-success')) {
  82. window.setTimeout(window.location.reload.bind(window.location), 1000)
  83. return
  84. }
  85. // Let the user re-submit the form.
  86. formEl.dispatchEvent(new Event('idle'))
  87. } catch (error) {
  88. let text = (error as Error).message
  89. let key: string | undefined
  90. let hints: string[] | undefined
  91. if (error instanceof FetchError) {
  92. text = error.getUserFacingMessage()
  93. }
  94. const errorWithData = error as ErrorWithData
  95. if (errorWithData.data?.message) {
  96. key = errorWithData.data.message.key
  97. hints = errorWithData.data.message.hints
  98. }
  99. messageBag.push({
  100. type: 'error',
  101. key,
  102. text,
  103. hints,
  104. })
  105. // Let the user re-submit the form.
  106. formEl.dispatchEvent(new Event('idle'))
  107. } finally {
  108. // call old and new notification builder functions
  109. // but only one will be rendered
  110. showMessages(formEl, messageBag)
  111. showMessagesNewStyle(formEl, messageBag)
  112. }
  113. })
  114. }
  115. async function validateCaptcha(
  116. formEl: HTMLFormElement
  117. ): Promise<string | undefined> {
  118. let captchaResponse: string | undefined
  119. if (
  120. formEl.hasAttribute('captcha') &&
  121. // Disable captcha for E2E tests in dev-env.
  122. !(process.env.NODE_ENV === 'development' && window.Cypress)
  123. ) {
  124. if (
  125. formEl.getAttribute('action') === '/login' &&
  126. (await canSkipCaptcha(new FormData(formEl).get('email') as string))
  127. ) {
  128. // The email is present in the deviceHistory, and we can skip the display
  129. // of a captcha challenge.
  130. // The actual login POST request will be checked against the deviceHistory
  131. // again and the server can trigger the display of a captcha if needed by
  132. // sending a 400 with errorReason set to 'cannot_verify_user_not_robot'.
  133. return ''
  134. }
  135. captchaResponse = await validateCaptchaV2()
  136. }
  137. return captchaResponse
  138. }
  139. async function sendFormRequest(
  140. formEl: HTMLFormElement,
  141. captchaResponse?: string
  142. ): Promise<FormResponse> {
  143. const formData = new FormData(formEl)
  144. if (captchaResponse) {
  145. formData.set('g-recaptcha-response', captchaResponse)
  146. }
  147. const body = Object.fromEntries(
  148. Array.from(formData.keys(), key => {
  149. // forms may have multiple keys with the same name, eg: checkboxes
  150. const val = formData.getAll(key)
  151. return [key, val.length > 1 ? val : val.pop()]
  152. })
  153. )
  154. const url = formEl.getAttribute('action')!
  155. return postJSON<FormResponse>(url, { body })
  156. }
  157. function hideFormElements(formEl: HTMLFormElement) {
  158. for (const element of formEl.elements) {
  159. if (element instanceof HTMLElement) {
  160. element.hidden = true
  161. }
  162. }
  163. }
  164. /**
  165. * Creates a notification element from a message object.
  166. */
  167. function createNotificationFromMessage(
  168. message: MessageBagItem
  169. ): HTMLDivElement {
  170. const messageEl = document.createElement('div')
  171. messageEl.className = classNames('mb-3 notification', {
  172. 'notification-type-error': message.type === 'error',
  173. 'notification-type-success': message.type === 'success',
  174. 'notification-type-warning': message.type === 'warning',
  175. 'notification-type-info': message.type === 'info',
  176. })
  177. messageEl.setAttribute('aria-live', 'assertive')
  178. messageEl.setAttribute('role', message.type === 'error' ? 'alert' : 'status')
  179. const materialIconLookup: Record<string, string> = {
  180. info: 'info',
  181. success: 'check_circle',
  182. error: 'error',
  183. warning: 'warning',
  184. }
  185. const materialIcon = materialIconLookup[message.type]
  186. if (materialIcon) {
  187. const iconEl = document.createElement('div')
  188. iconEl.className = 'notification-icon'
  189. const iconSpan = createMaterialIcon(materialIcon)
  190. iconEl.append(iconSpan)
  191. messageEl.append(iconEl)
  192. }
  193. const contentAndCtaEl = document.createElement('div')
  194. contentAndCtaEl.className = 'notification-content-and-cta'
  195. const contentEl = document.createElement('div')
  196. contentEl.className = 'notification-content'
  197. contentEl.append(message.text || `Error: ${message.key}`)
  198. if (message.hints && message.hints.length) {
  199. const listEl = document.createElement('ul')
  200. message.hints.forEach(hint => {
  201. const listItemEl = document.createElement('li')
  202. listItemEl.textContent = hint
  203. listEl.append(listItemEl)
  204. })
  205. contentEl.append(listEl)
  206. }
  207. contentAndCtaEl.append(contentEl)
  208. messageEl.append(contentAndCtaEl)
  209. return messageEl
  210. }
  211. // TODO: remove the showMessages function after every form alerts are updated to use the new style
  212. // TODO: rename showMessagesNewStyle to showMessages after the above is done
  213. function showMessages(formEl: HTMLFormElement, messageBag: MessageBagItem[]) {
  214. const messagesEl = formEl.querySelector('[data-ol-form-messages]')
  215. if (!messagesEl) return
  216. // Clear content
  217. messagesEl.textContent = ''
  218. formEl
  219. .querySelectorAll<HTMLElement>('[data-ol-custom-form-message]')
  220. .forEach(el => {
  221. el.hidden = true
  222. })
  223. // Render messages
  224. messageBag.forEach(message => {
  225. const customErrorElements = message.key
  226. ? formEl.querySelectorAll<HTMLElement>(
  227. `[data-ol-custom-form-message="${message.key}"]`
  228. )
  229. : []
  230. if (message.key && customErrorElements.length > 0) {
  231. // Found at least one custom error element for key, show them
  232. customErrorElements.forEach(el => {
  233. el.hidden = false
  234. })
  235. } else {
  236. const notification = createNotificationFromMessage(message)
  237. messagesEl.append(notification)
  238. }
  239. if (message.key) {
  240. // Hide the form elements on specific message types
  241. const hideOnError = formEl.attributes.getNamedItem(
  242. 'data-ol-hide-on-error'
  243. )
  244. if (
  245. hideOnError &&
  246. hideOnError.value &&
  247. hideOnError.value.match(message.key)
  248. ) {
  249. hideFormElements(formEl)
  250. }
  251. // Hide any elements with specific `data-ol-hide-on-error-message` message
  252. document
  253. .querySelectorAll<HTMLElement>(
  254. `[data-ol-hide-on-error-message="${message.key}"]`
  255. )
  256. .forEach(el => {
  257. el.hidden = true
  258. })
  259. }
  260. })
  261. }
  262. function showMessagesNewStyle(
  263. formEl: HTMLFormElement,
  264. messageBag: MessageBagItem[]
  265. ) {
  266. const messagesEl = formEl.querySelector('[data-ol-form-messages-new-style]')
  267. if (!messagesEl) return
  268. // Clear content
  269. messagesEl.textContent = ''
  270. formEl
  271. .querySelectorAll<HTMLElement>('[data-ol-custom-form-message]')
  272. .forEach(el => {
  273. el.hidden = true
  274. })
  275. const isDsBranded = formEl.dataset.ciamForm !== undefined
  276. // Render messages
  277. messageBag.forEach(message => {
  278. const customErrorElements = message.key
  279. ? formEl.querySelectorAll<HTMLElement>(
  280. `[data-ol-custom-form-message="${message.key}"]`
  281. )
  282. : []
  283. if (message.key && customErrorElements.length > 0) {
  284. // Found at least one custom error element for key, show them
  285. customErrorElements.forEach(el => {
  286. el.hidden = false
  287. })
  288. } else {
  289. // No custom error element for key on page, append a new error message
  290. const messageElContainer = document.createElement('div')
  291. messageElContainer.className = classNames('notification', {
  292. 'notification-type-error': message.type === 'error',
  293. 'notification-type-success': message.type !== 'error',
  294. 'notification-ds': isDsBranded,
  295. })
  296. const messageEl = document.createElement('div')
  297. // create the message text
  298. messageEl.className = 'notification-content text-left'
  299. messageEl.textContent = message.text || `Error: ${message.key}`
  300. messageEl.setAttribute('aria-live', 'assertive')
  301. messageEl.setAttribute(
  302. 'role',
  303. message.type === 'error' ? 'alert' : 'status'
  304. )
  305. if (message.hints && message.hints.length) {
  306. const listEl = document.createElement('ul')
  307. message.hints.forEach(hint => {
  308. const listItemEl = document.createElement('li')
  309. listItemEl.textContent = hint
  310. listEl.append(listItemEl)
  311. })
  312. messageEl.append(listEl)
  313. }
  314. // create the left icon
  315. const messageIcon = document.createElement('div')
  316. messageIcon.className = 'notification-icon'
  317. if (
  318. isDsBranded &&
  319. (message.type === 'error' || message.type === 'info')
  320. ) {
  321. messageIcon.append(ciamIcon(message.type))
  322. } else {
  323. const icon = createMaterialIcon(
  324. message.type === 'error' ? 'error' : 'check_circle'
  325. )
  326. messageIcon.appendChild(icon)
  327. }
  328. // append icon first so it's on the left
  329. messageElContainer.appendChild(messageIcon)
  330. messageElContainer.appendChild(messageEl)
  331. messagesEl.append(messageElContainer)
  332. }
  333. if (message.key) {
  334. // Hide the form elements on specific message types
  335. const hideOnError = formEl.attributes.getNamedItem(
  336. 'data-ol-hide-on-error'
  337. )
  338. if (
  339. hideOnError &&
  340. hideOnError.value &&
  341. hideOnError.value.match(message.key)
  342. ) {
  343. hideFormElements(formEl)
  344. }
  345. // Hide any elements with specific `data-ol-hide-on-error-message` message
  346. document
  347. .querySelectorAll<HTMLElement>(
  348. `[data-ol-hide-on-error-message="${message.key}"]`
  349. )
  350. .forEach(el => {
  351. el.hidden = true
  352. })
  353. }
  354. })
  355. }
  356. function querySelectorAllWithSelf(el: HTMLElement, selector: string) {
  357. const nodeList = el.querySelectorAll<HTMLElement>(selector)
  358. return el.matches(selector) ? [el, ...nodeList] : Array.from(nodeList)
  359. }
  360. export function inflightHelper(el: HTMLElement) {
  361. const disabledInflight = querySelectorAllWithSelf(
  362. el,
  363. '[data-ol-disabled-inflight]'
  364. )
  365. const showWhenNotInflight = el.querySelectorAll<HTMLElement>(
  366. '[data-ol-inflight="idle"]'
  367. )
  368. const showWhenInflight = el.querySelectorAll<HTMLElement>(
  369. '[data-ol-inflight="pending"]'
  370. )
  371. const spinnerInflight = querySelectorAllWithSelf(
  372. el,
  373. '[data-ol-spinner-inflight]'
  374. )
  375. el.addEventListener('pending', () => {
  376. disabledInflight.forEach(disableElement)
  377. toggleDisplay(showWhenNotInflight, showWhenInflight)
  378. spinnerInflight.forEach(loadingEl => {
  379. loadingEl.setAttribute('data-ol-loading', 'true')
  380. loadingEl.classList.add('button-loading')
  381. })
  382. })
  383. el.addEventListener('idle', () => {
  384. disabledInflight.forEach(enableElement)
  385. toggleDisplay(showWhenInflight, showWhenNotInflight)
  386. spinnerInflight.forEach(loadingEl => {
  387. loadingEl.removeAttribute('data-ol-loading')
  388. loadingEl.classList.remove('button-loading')
  389. })
  390. })
  391. }
  392. function formSentHelper(el: HTMLElement) {
  393. const showWhenPending = el.querySelectorAll<HTMLElement>('[data-ol-not-sent]')
  394. const showWhenDone = el.querySelectorAll<HTMLElement>('[data-ol-sent]')
  395. if (showWhenDone.length === 0) return
  396. el.addEventListener('sent', () => {
  397. toggleDisplay(showWhenPending, showWhenDone)
  398. })
  399. }
  400. function formValidationHelper(el: HTMLFormElement) {
  401. el.querySelectorAll('input, textarea').forEach(inputEl => {
  402. const element = inputEl as HTMLInputElement | HTMLTextAreaElement
  403. if (
  404. element.willValidate &&
  405. !inputEl.hasAttribute('data-ol-no-custom-form-validation-messages')
  406. ) {
  407. inputValidator(element)
  408. }
  409. })
  410. }
  411. function formAutoSubmitHelper(el: HTMLFormElement) {
  412. if (el.hasAttribute('data-ol-auto-submit')) {
  413. setTimeout(() => {
  414. const submitButton =
  415. el.querySelector<HTMLButtonElement>('[type="submit"]')
  416. submitButton?.click()
  417. }, 0)
  418. }
  419. }
  420. export function toggleDisplay(
  421. hide: NodeListOf<HTMLElement>,
  422. show: NodeListOf<HTMLElement>
  423. ) {
  424. hide.forEach(el => {
  425. el.hidden = true
  426. })
  427. show.forEach(el => {
  428. el.hidden = false
  429. })
  430. }
  431. function hydrateAsyncForm(el: HTMLFormElement) {
  432. formSubmitHelper(el)
  433. inflightHelper(el)
  434. formSentHelper(el)
  435. formValidationHelper(el)
  436. formAutoSubmitHelper(el)
  437. }
  438. function hydrateRegularForm(el: HTMLFormElement) {
  439. inflightHelper(el)
  440. formValidationHelper(el)
  441. el.addEventListener('submit', () => {
  442. el.dispatchEvent(new Event('pending'))
  443. })
  444. formAutoSubmitHelper(el)
  445. }
  446. document
  447. .querySelectorAll<HTMLFormElement>('[data-ol-async-form]')
  448. .forEach(hydrateAsyncForm)
  449. document
  450. .querySelectorAll<HTMLFormElement>('[data-ol-regular-form]')
  451. .forEach(hydrateRegularForm)