hydrate-form.js 7.7 KB

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