hydrate-form.js 6.5 KB

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