hydrate-form.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  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 || data.redirect) {
  42. window.location = data.redir || data.redirect
  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. // Handle reloads
  53. if (formEl.hasAttribute('data-ol-reload-on-success')) {
  54. window.setTimeout(window.location.reload.bind(window.location), 1000)
  55. return
  56. }
  57. // Let the user re-submit the form.
  58. formEl.dispatchEvent(new Event('idle'))
  59. } catch (error) {
  60. let text = error.message
  61. if (error instanceof FetchError) {
  62. text = error.getUserFacingMessage()
  63. }
  64. messageBag.push({
  65. type: 'error',
  66. key: error.data?.message?.key,
  67. text,
  68. hints: error.data?.message?.hints,
  69. })
  70. // Let the user re-submit the form.
  71. formEl.dispatchEvent(new Event('idle'))
  72. } finally {
  73. showMessages(formEl, messageBag)
  74. }
  75. })
  76. }
  77. async function validateCaptcha(formEl) {
  78. let captchaResponse
  79. if (formEl.hasAttribute('captcha')) {
  80. if (
  81. formEl.getAttribute('action') === '/login' &&
  82. (await canSkipCaptcha(new FormData(formEl).get('email')))
  83. ) {
  84. // The email is present in the deviceHistory, and we can skip the display
  85. // of a captcha challenge.
  86. // The actual login POST request will be checked against the deviceHistory
  87. // again and the server can trigger the display of a captcha if needed by
  88. // sending a 400 with errorReason set to 'cannot_verify_user_not_robot'.
  89. return ''
  90. }
  91. captchaResponse = await validateCaptchaV2()
  92. }
  93. return captchaResponse
  94. }
  95. async function sendFormRequest(formEl, captchaResponse) {
  96. const formData = new FormData(formEl)
  97. if (captchaResponse) {
  98. formData.set('g-recaptcha-response', captchaResponse)
  99. }
  100. const body = Object.fromEntries(
  101. Array.from(formData.keys(), key => {
  102. // forms may have multiple keys with the same name, eg: checkboxes
  103. const val = formData.getAll(key)
  104. return [key, val.length > 1 ? val : val.pop()]
  105. })
  106. )
  107. const url = formEl.getAttribute('action')
  108. return postJSON(url, { body })
  109. }
  110. function showMessages(formEl, messageBag) {
  111. const messagesEl = formEl.querySelector('[data-ol-form-messages]')
  112. if (!messagesEl) return
  113. // Clear content
  114. messagesEl.textContent = ''
  115. formEl.querySelectorAll('[data-ol-custom-form-message]').forEach(el => {
  116. el.hidden = true
  117. })
  118. // Render messages
  119. messageBag.forEach(message => {
  120. if (message.key) {
  121. formEl
  122. .querySelectorAll(`[data-ol-custom-form-message="${message.key}"]`)
  123. .forEach(el => {
  124. el.hidden = false
  125. })
  126. return
  127. }
  128. const messageEl = document.createElement('div')
  129. messageEl.className = classNames('alert', {
  130. 'alert-danger': message.type === 'error',
  131. 'alert-success': message.type !== 'error',
  132. })
  133. messageEl.textContent = message.text
  134. messageEl.setAttribute('aria-live', 'assertive')
  135. messageEl.setAttribute(
  136. 'role',
  137. message.type === 'error' ? 'alert' : 'status'
  138. )
  139. if (message.hints && message.hints.length) {
  140. const listEl = document.createElement('ul')
  141. message.hints.forEach(hint => {
  142. const listItemEl = document.createElement('li')
  143. listItemEl.textContent = hint
  144. listEl.append(listItemEl)
  145. })
  146. messageEl.append(listEl)
  147. }
  148. messagesEl.append(messageEl)
  149. })
  150. }
  151. export function inflightHelper(el) {
  152. const disabledInflight = el.querySelectorAll('[data-ol-disabled-inflight]')
  153. const showWhenNotInflight = el.querySelectorAll('[data-ol-inflight="idle"]')
  154. const showWhenInflight = el.querySelectorAll('[data-ol-inflight="pending"]')
  155. el.addEventListener('pending', () => {
  156. disabledInflight.forEach(disableElement)
  157. toggleDisplay(showWhenNotInflight, showWhenInflight)
  158. })
  159. el.addEventListener('idle', () => {
  160. disabledInflight.forEach(enableElement)
  161. toggleDisplay(showWhenInflight, showWhenNotInflight)
  162. })
  163. }
  164. function formSentHelper(el) {
  165. const showWhenPending = el.querySelectorAll('[data-ol-not-sent]')
  166. const showWhenDone = el.querySelectorAll('[data-ol-sent]')
  167. if (showWhenDone.length === 0) return
  168. el.addEventListener('sent', () => {
  169. toggleDisplay(showWhenPending, showWhenDone)
  170. })
  171. }
  172. function formValidationHelper(el) {
  173. el.querySelectorAll('input').forEach(inputEl => {
  174. if (
  175. inputEl.willValidate &&
  176. !inputEl.hasAttribute('data-ol-no-custom-form-validation-messages')
  177. ) {
  178. inputValidator(inputEl)
  179. }
  180. })
  181. }
  182. function formAutoSubmitHelper(el) {
  183. if (el.hasAttribute('data-ol-auto-submit')) {
  184. setTimeout(() => {
  185. el.querySelector('[type="submit"]').click()
  186. }, 0)
  187. }
  188. }
  189. export function toggleDisplay(hide, show) {
  190. hide.forEach(el => {
  191. el.hidden = true
  192. })
  193. show.forEach(el => {
  194. el.hidden = false
  195. })
  196. }
  197. function hydrateAsyncForm(el) {
  198. formSubmitHelper(el)
  199. inflightHelper(el)
  200. formSentHelper(el)
  201. formValidationHelper(el)
  202. formAutoSubmitHelper(el)
  203. }
  204. function hydrateRegularForm(el) {
  205. inflightHelper(el)
  206. formValidationHelper(el)
  207. el.addEventListener('submit', () => {
  208. el.dispatchEvent(new Event('pending'))
  209. })
  210. formAutoSubmitHelper(el)
  211. }
  212. document.querySelectorAll(`[data-ol-async-form]`).forEach(hydrateAsyncForm)
  213. document.querySelectorAll(`[data-ol-regular-form]`).forEach(hydrateRegularForm)