mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
3288f87dbe
* [web] set-password: reject same as current password * [web] Add 'peek' operation on tokens This allows us to improve the UX of the reset-password form, by not invalidating the token in the case where the new password will be rejected by validation logic. We give up to three attempts before invalidating the token. * [web] Add hide-on-error feature to async forms This allows us to hide the form elements when certain named error conditions occur. * [web] reset-password: handle same-password rejection We also change the implementation to use the new peekValueFromToken API, and to expire the token explicitely after it has been used to set the new password. * [web] Validate OneTimeToken when loading password reset form * [web] Rate limit GET: /user/password/set Now that we are peeking at OneTimeToken when accessing this page, we add rate to the GET request, matching that of the POST request. * [web] Tidy up pug layout and mongo query for token peeking Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com> GitOrigin-RevId: 835205cc7c7ebe1209ee8e5b693efeb939a3056a
258 lines
7.1 KiB
JavaScript
258 lines
7.1 KiB
JavaScript
import classNames from 'classnames'
|
|
import { FetchError, postJSON } from '../../infrastructure/fetch-json'
|
|
import { canSkipCaptcha, validateCaptchaV2 } from './captcha'
|
|
import inputValidator from './input-validator'
|
|
import { disableElement, enableElement } from '../utils/disableElement'
|
|
|
|
// Form helper(s) to handle:
|
|
// - Attaching to the relevant form elements
|
|
// - Listening for submit event
|
|
// - Validating captcha
|
|
// - Sending fetch request
|
|
// - Redirect handling
|
|
// - Showing errors
|
|
// - Disabled state
|
|
|
|
function formSubmitHelper(formEl) {
|
|
formEl.addEventListener('submit', async e => {
|
|
e.preventDefault()
|
|
|
|
formEl.dispatchEvent(new Event('pending'))
|
|
|
|
const messageBag = []
|
|
|
|
try {
|
|
let data
|
|
try {
|
|
const captchaResponse = await validateCaptcha(formEl)
|
|
data = await sendFormRequest(formEl, captchaResponse)
|
|
} catch (e) {
|
|
if (
|
|
e instanceof FetchError &&
|
|
e.data?.errorReason === 'cannot_verify_user_not_robot'
|
|
) {
|
|
// Trigger captcha unconditionally.
|
|
const captchaResponse = await validateCaptchaV2()
|
|
if (!captchaResponse) {
|
|
throw e
|
|
}
|
|
data = await sendFormRequest(formEl, captchaResponse)
|
|
} else {
|
|
throw e
|
|
}
|
|
}
|
|
formEl.dispatchEvent(new Event('sent'))
|
|
|
|
// Handle redirects
|
|
if (data.redir || data.redirect) {
|
|
window.location = data.redir || data.redirect
|
|
return
|
|
}
|
|
|
|
// Show a success message (e.g. used on 2FA page)
|
|
if (data.message) {
|
|
messageBag.push({
|
|
type: 'message',
|
|
text: data.message.text || data.message,
|
|
})
|
|
}
|
|
|
|
// Handle reloads
|
|
if (formEl.hasAttribute('data-ol-reload-on-success')) {
|
|
window.setTimeout(window.location.reload.bind(window.location), 1000)
|
|
return
|
|
}
|
|
|
|
// Let the user re-submit the form.
|
|
formEl.dispatchEvent(new Event('idle'))
|
|
} catch (error) {
|
|
let text = error.message
|
|
if (error instanceof FetchError) {
|
|
text = error.getUserFacingMessage()
|
|
}
|
|
messageBag.push({
|
|
type: 'error',
|
|
key: error.data?.message?.key,
|
|
text,
|
|
hints: error.data?.message?.hints,
|
|
})
|
|
|
|
// Let the user re-submit the form.
|
|
formEl.dispatchEvent(new Event('idle'))
|
|
} finally {
|
|
showMessages(formEl, messageBag)
|
|
}
|
|
})
|
|
}
|
|
|
|
async function validateCaptcha(formEl) {
|
|
let captchaResponse
|
|
if (formEl.hasAttribute('captcha')) {
|
|
if (
|
|
formEl.getAttribute('action') === '/login' &&
|
|
(await canSkipCaptcha(new FormData(formEl).get('email')))
|
|
) {
|
|
// The email is present in the deviceHistory, and we can skip the display
|
|
// of a captcha challenge.
|
|
// The actual login POST request will be checked against the deviceHistory
|
|
// again and the server can trigger the display of a captcha if needed by
|
|
// sending a 400 with errorReason set to 'cannot_verify_user_not_robot'.
|
|
return ''
|
|
}
|
|
captchaResponse = await validateCaptchaV2()
|
|
}
|
|
return captchaResponse
|
|
}
|
|
|
|
async function sendFormRequest(formEl, captchaResponse) {
|
|
const formData = new FormData(formEl)
|
|
if (captchaResponse) {
|
|
formData.set('g-recaptcha-response', captchaResponse)
|
|
}
|
|
const body = Object.fromEntries(
|
|
Array.from(formData.keys(), key => {
|
|
// forms may have multiple keys with the same name, eg: checkboxes
|
|
const val = formData.getAll(key)
|
|
return [key, val.length > 1 ? val : val.pop()]
|
|
})
|
|
)
|
|
const url = formEl.getAttribute('action')
|
|
return postJSON(url, { body })
|
|
}
|
|
|
|
function hideFormElements(formEl) {
|
|
for (const e of formEl.elements) {
|
|
e.hidden = true
|
|
}
|
|
}
|
|
|
|
function showMessages(formEl, messageBag) {
|
|
const messagesEl = formEl.querySelector('[data-ol-form-messages]')
|
|
if (!messagesEl) return
|
|
|
|
// Clear content
|
|
messagesEl.textContent = ''
|
|
formEl.querySelectorAll('[data-ol-custom-form-message]').forEach(el => {
|
|
el.hidden = true
|
|
})
|
|
|
|
// Render messages
|
|
messageBag.forEach(message => {
|
|
if (message.key) {
|
|
formEl
|
|
.querySelectorAll(`[data-ol-custom-form-message="${message.key}"]`)
|
|
.forEach(el => {
|
|
el.hidden = false
|
|
})
|
|
// Hide the form elements on specific message types
|
|
const hideOnError = formEl.attributes['data-ol-hide-on-error']
|
|
if (
|
|
hideOnError &&
|
|
hideOnError.value &&
|
|
hideOnError.value.match(message.key)
|
|
) {
|
|
hideFormElements(formEl)
|
|
}
|
|
return
|
|
}
|
|
|
|
const messageEl = document.createElement('div')
|
|
messageEl.className = classNames('alert', {
|
|
'alert-danger': message.type === 'error',
|
|
'alert-success': message.type !== 'error',
|
|
})
|
|
messageEl.textContent = message.text
|
|
messageEl.setAttribute('aria-live', 'assertive')
|
|
messageEl.setAttribute(
|
|
'role',
|
|
message.type === 'error' ? 'alert' : 'status'
|
|
)
|
|
if (message.hints && message.hints.length) {
|
|
const listEl = document.createElement('ul')
|
|
message.hints.forEach(hint => {
|
|
const listItemEl = document.createElement('li')
|
|
listItemEl.textContent = hint
|
|
listEl.append(listItemEl)
|
|
})
|
|
messageEl.append(listEl)
|
|
}
|
|
messagesEl.append(messageEl)
|
|
})
|
|
}
|
|
|
|
export function inflightHelper(el) {
|
|
const disabledInflight = el.querySelectorAll('[data-ol-disabled-inflight]')
|
|
const showWhenNotInflight = el.querySelectorAll('[data-ol-inflight="idle"]')
|
|
const showWhenInflight = el.querySelectorAll('[data-ol-inflight="pending"]')
|
|
|
|
el.addEventListener('pending', () => {
|
|
disabledInflight.forEach(disableElement)
|
|
toggleDisplay(showWhenNotInflight, showWhenInflight)
|
|
})
|
|
|
|
el.addEventListener('idle', () => {
|
|
disabledInflight.forEach(enableElement)
|
|
toggleDisplay(showWhenInflight, showWhenNotInflight)
|
|
})
|
|
}
|
|
|
|
function formSentHelper(el) {
|
|
const showWhenPending = el.querySelectorAll('[data-ol-not-sent]')
|
|
const showWhenDone = el.querySelectorAll('[data-ol-sent]')
|
|
if (showWhenDone.length === 0) return
|
|
|
|
el.addEventListener('sent', () => {
|
|
toggleDisplay(showWhenPending, showWhenDone)
|
|
})
|
|
}
|
|
|
|
function formValidationHelper(el) {
|
|
el.querySelectorAll('input').forEach(inputEl => {
|
|
if (
|
|
inputEl.willValidate &&
|
|
!inputEl.hasAttribute('data-ol-no-custom-form-validation-messages')
|
|
) {
|
|
inputValidator(inputEl)
|
|
}
|
|
})
|
|
}
|
|
|
|
function formAutoSubmitHelper(el) {
|
|
if (el.hasAttribute('data-ol-auto-submit')) {
|
|
setTimeout(() => {
|
|
el.querySelector('[type="submit"]').click()
|
|
}, 0)
|
|
}
|
|
}
|
|
|
|
export function toggleDisplay(hide, show) {
|
|
hide.forEach(el => {
|
|
el.hidden = true
|
|
})
|
|
show.forEach(el => {
|
|
el.hidden = false
|
|
})
|
|
}
|
|
|
|
function hydrateAsyncForm(el) {
|
|
formSubmitHelper(el)
|
|
inflightHelper(el)
|
|
formSentHelper(el)
|
|
formValidationHelper(el)
|
|
formAutoSubmitHelper(el)
|
|
}
|
|
|
|
function hydrateRegularForm(el) {
|
|
inflightHelper(el)
|
|
formValidationHelper(el)
|
|
|
|
el.addEventListener('submit', () => {
|
|
el.dispatchEvent(new Event('pending'))
|
|
})
|
|
|
|
formAutoSubmitHelper(el)
|
|
}
|
|
|
|
document.querySelectorAll(`[data-ol-async-form]`).forEach(hydrateAsyncForm)
|
|
|
|
document.querySelectorAll(`[data-ol-regular-form]`).forEach(hydrateRegularForm)
|