hydrate-form.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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. // Form helper(s) to handle:
  7. // - Attaching to the relevant form elements
  8. // - Listening for submit event
  9. // - Validating captcha
  10. // - Sending fetch request
  11. // - Redirect handling
  12. // - Showing errors
  13. // - Disabled state
  14. function formSubmitHelper(formEl) {
  15. formEl.addEventListener('submit', async e => {
  16. e.preventDefault()
  17. formEl.dispatchEvent(new Event('pending'))
  18. const messageBag = []
  19. try {
  20. let data
  21. try {
  22. const captchaResponse = await validateCaptcha(formEl)
  23. data = await sendFormRequest(formEl, captchaResponse)
  24. } catch (e) {
  25. if (
  26. e instanceof FetchError &&
  27. e.data?.errorReason === 'cannot_verify_user_not_robot'
  28. ) {
  29. // Trigger captcha unconditionally.
  30. const captchaResponse = await validateCaptchaV2()
  31. if (!captchaResponse) {
  32. throw e
  33. }
  34. data = await sendFormRequest(formEl, captchaResponse)
  35. } else {
  36. throw e
  37. }
  38. }
  39. formEl.dispatchEvent(new Event('sent'))
  40. // Handle redirects
  41. if (data.redir) {
  42. window.location = data.redir
  43. return
  44. }
  45. // Show a success message (e.g. used on 2FA page)
  46. if (data.message) {
  47. messageBag.push({
  48. type: 'message',
  49. text: data.message.text || data.message,
  50. })
  51. }
  52. // Let the user re-submit the form.
  53. formEl.dispatchEvent(new Event('idle'))
  54. } catch (error) {
  55. let text = error.message
  56. if (error instanceof FetchError) {
  57. text = error.getUserFacingMessage()
  58. }
  59. messageBag.push({
  60. type: 'error',
  61. key: error.data?.message?.key,
  62. text,
  63. })
  64. // Let the user re-submit the form.
  65. formEl.dispatchEvent(new Event('idle'))
  66. } finally {
  67. showMessages(formEl, messageBag)
  68. }
  69. })
  70. }
  71. async function validateCaptcha(formEl) {
  72. let captchaResponse
  73. if (formEl.hasAttribute('captcha')) {
  74. if (
  75. formEl.getAttribute('action') === '/login' &&
  76. (await canSkipCaptcha(new FormData(formEl).get('email')))
  77. ) {
  78. // The email is present in the deviceHistory, and we can skip the display
  79. // of a captcha challenge.
  80. // The actual login POST request will be checked against the deviceHistory
  81. // again and the server can trigger the display of a captcha if needed by
  82. // sending a 400 with errorReason set to 'cannot_verify_user_not_robot'.
  83. return ''
  84. }
  85. captchaResponse = await validateCaptchaV2()
  86. }
  87. return captchaResponse
  88. }
  89. async function sendFormRequest(formEl, captchaResponse) {
  90. const formData = new FormData(formEl)
  91. if (captchaResponse) {
  92. formData.set('g-recaptcha-response', captchaResponse)
  93. }
  94. const body = Object.fromEntries(formData.entries())
  95. const url = formEl.getAttribute('action')
  96. return postJSON(url, { body })
  97. }
  98. function showMessages(formEl, messageBag) {
  99. const messagesEl = formEl.querySelector('[data-ol-form-messages]')
  100. if (!messagesEl) return
  101. // Clear content
  102. messagesEl.textContent = ''
  103. formEl.querySelectorAll('[data-ol-custom-form-message]').forEach(el => {
  104. el.hidden = true
  105. })
  106. // Render messages
  107. messageBag.forEach(message => {
  108. if (message.key) {
  109. formEl
  110. .querySelectorAll(`[data-ol-custom-form-message="${message.key}"]`)
  111. .forEach(el => {
  112. el.hidden = false
  113. })
  114. return
  115. }
  116. const messageEl = document.createElement('div')
  117. messageEl.className = classNames('alert', {
  118. 'alert-danger': message.type === 'error',
  119. 'alert-success': message.type !== 'error',
  120. })
  121. messageEl.textContent = message.text
  122. messageEl.setAttribute('aria-live', 'assertive')
  123. messageEl.setAttribute(
  124. 'role',
  125. message.type === 'error' ? 'alert' : 'status'
  126. )
  127. messagesEl.append(messageEl)
  128. })
  129. }
  130. export function inflightHelper(el) {
  131. const disabledInflight = el.querySelectorAll('[data-ol-disabled-inflight]')
  132. const showWhenNotInflight = el.querySelectorAll('[data-ol-inflight="idle"]')
  133. const showWhenInflight = el.querySelectorAll('[data-ol-inflight="pending"]')
  134. el.addEventListener('pending', () => {
  135. disabledInflight.forEach(disableElement)
  136. toggleDisplay(showWhenNotInflight, showWhenInflight)
  137. })
  138. el.addEventListener('idle', () => {
  139. disabledInflight.forEach(enableElement)
  140. toggleDisplay(showWhenInflight, showWhenNotInflight)
  141. })
  142. }
  143. function formSentHelper(el) {
  144. const showWhenPending = el.querySelectorAll('[data-ol-not-sent]')
  145. const showWhenDone = el.querySelectorAll('[data-ol-sent]')
  146. if (showWhenDone.length === 0) return
  147. el.addEventListener('sent', () => {
  148. toggleDisplay(showWhenPending, showWhenDone)
  149. })
  150. }
  151. function formValidationHelper(el) {
  152. el.querySelectorAll('input').forEach(inputEl => {
  153. if (
  154. inputEl.willValidate &&
  155. !inputEl.hasAttribute('data-ol-no-custom-form-validation-messages')
  156. ) {
  157. inputValidator(inputEl)
  158. }
  159. })
  160. }
  161. function formAutoSubmitHelper(el) {
  162. if (el.hasAttribute('data-ol-auto-submit')) {
  163. setTimeout(() => {
  164. el.querySelector('[type="submit"]').click()
  165. }, 0)
  166. }
  167. }
  168. export function toggleDisplay(hide, show) {
  169. hide.forEach(el => {
  170. el.hidden = true
  171. })
  172. show.forEach(el => {
  173. el.hidden = false
  174. })
  175. }
  176. function hydrateAsyncForm(el) {
  177. formSubmitHelper(el)
  178. inflightHelper(el)
  179. formSentHelper(el)
  180. formValidationHelper(el)
  181. formAutoSubmitHelper(el)
  182. }
  183. function hydrateRegularForm(el) {
  184. inflightHelper(el)
  185. formValidationHelper(el)
  186. el.addEventListener('submit', () => {
  187. el.dispatchEvent(new Event('pending'))
  188. })
  189. formAutoSubmitHelper(el)
  190. }
  191. document.querySelectorAll(`[data-ol-async-form]`).forEach(hydrateAsyncForm)
  192. document.querySelectorAll(`[data-ol-regular-form]`).forEach(hydrateRegularForm)