hydrate-form.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  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 hideFormElements(formEl) {
  111. for (const e of formEl.elements) {
  112. e.hidden = true
  113. }
  114. }
  115. function showMessages(formEl, messageBag) {
  116. const messagesEl = formEl.querySelector('[data-ol-form-messages]')
  117. if (!messagesEl) return
  118. // Clear content
  119. messagesEl.textContent = ''
  120. formEl.querySelectorAll('[data-ol-custom-form-message]').forEach(el => {
  121. el.hidden = true
  122. })
  123. // Render messages
  124. messageBag.forEach(message => {
  125. if (message.key) {
  126. formEl
  127. .querySelectorAll(`[data-ol-custom-form-message="${message.key}"]`)
  128. .forEach(el => {
  129. el.hidden = false
  130. })
  131. // Hide the form elements on specific message types
  132. const hideOnError = formEl.attributes['data-ol-hide-on-error']
  133. if (
  134. hideOnError &&
  135. hideOnError.value &&
  136. hideOnError.value.match(message.key)
  137. ) {
  138. hideFormElements(formEl)
  139. }
  140. return
  141. }
  142. const messageEl = document.createElement('div')
  143. messageEl.className = classNames('alert', {
  144. 'alert-danger': message.type === 'error',
  145. 'alert-success': message.type !== 'error',
  146. })
  147. messageEl.textContent = message.text
  148. messageEl.setAttribute('aria-live', 'assertive')
  149. messageEl.setAttribute(
  150. 'role',
  151. message.type === 'error' ? 'alert' : 'status'
  152. )
  153. if (message.hints && message.hints.length) {
  154. const listEl = document.createElement('ul')
  155. message.hints.forEach(hint => {
  156. const listItemEl = document.createElement('li')
  157. listItemEl.textContent = hint
  158. listEl.append(listItemEl)
  159. })
  160. messageEl.append(listEl)
  161. }
  162. messagesEl.append(messageEl)
  163. })
  164. }
  165. export function inflightHelper(el) {
  166. const disabledInflight = el.querySelectorAll('[data-ol-disabled-inflight]')
  167. const showWhenNotInflight = el.querySelectorAll('[data-ol-inflight="idle"]')
  168. const showWhenInflight = el.querySelectorAll('[data-ol-inflight="pending"]')
  169. el.addEventListener('pending', () => {
  170. disabledInflight.forEach(disableElement)
  171. toggleDisplay(showWhenNotInflight, showWhenInflight)
  172. })
  173. el.addEventListener('idle', () => {
  174. disabledInflight.forEach(enableElement)
  175. toggleDisplay(showWhenInflight, showWhenNotInflight)
  176. })
  177. }
  178. function formSentHelper(el) {
  179. const showWhenPending = el.querySelectorAll('[data-ol-not-sent]')
  180. const showWhenDone = el.querySelectorAll('[data-ol-sent]')
  181. if (showWhenDone.length === 0) return
  182. el.addEventListener('sent', () => {
  183. toggleDisplay(showWhenPending, showWhenDone)
  184. })
  185. }
  186. function formValidationHelper(el) {
  187. el.querySelectorAll('input').forEach(inputEl => {
  188. if (
  189. inputEl.willValidate &&
  190. !inputEl.hasAttribute('data-ol-no-custom-form-validation-messages')
  191. ) {
  192. inputValidator(inputEl)
  193. }
  194. })
  195. }
  196. function formAutoSubmitHelper(el) {
  197. if (el.hasAttribute('data-ol-auto-submit')) {
  198. setTimeout(() => {
  199. el.querySelector('[type="submit"]').click()
  200. }, 0)
  201. }
  202. }
  203. export function toggleDisplay(hide, show) {
  204. hide.forEach(el => {
  205. el.hidden = true
  206. })
  207. show.forEach(el => {
  208. el.hidden = false
  209. })
  210. }
  211. function hydrateAsyncForm(el) {
  212. formSubmitHelper(el)
  213. inflightHelper(el)
  214. formSentHelper(el)
  215. formValidationHelper(el)
  216. formAutoSubmitHelper(el)
  217. }
  218. function hydrateRegularForm(el) {
  219. inflightHelper(el)
  220. formValidationHelper(el)
  221. el.addEventListener('submit', () => {
  222. el.dispatchEvent(new Event('pending'))
  223. })
  224. formAutoSubmitHelper(el)
  225. }
  226. document.querySelectorAll(`[data-ol-async-form]`).forEach(hydrateAsyncForm)
  227. document.querySelectorAll(`[data-ol-regular-form]`).forEach(hydrateRegularForm)