fix(auth): validate password before Enter submission

This commit is contained in:
kolaente
2026-11-06 17:51:10 +01:00
parent b9063c79cf
commit aa96c68277
2 changed files with 66 additions and 6 deletions
+21 -6
View File
@@ -12,8 +12,8 @@
:autocomplete="autocomplete"
:aria-invalid="isValid !== true ? true : undefined"
:aria-describedby="errorId"
@keyup.enter="e => $emit('submit', e)"
@focusout="() => {validate(); validateAfterFirst = true}"
@keyup.enter="handleSubmit"
@focusout="e => {handleInput(e); validate(); validateAfterFirst = true}"
@keyup="() => {validateAfterFirst ? validate() : null}"
@input="handleInput"
>
@@ -71,12 +71,19 @@ const isValid = ref<true | string>(true)
const validateAfterFirst = ref(false)
const errorId = computed(() => isValid.value !== true ? `${props.id}-error` : undefined)
const validate = useDebounceFn(() => {
const valid = validatePassword(props.modelValue, props.validateMinLength)
function validateValue(value: string) {
const valid = validatePassword(value, props.validateMinLength)
isValid.value = valid === true ? true : t(valid)
}, 100)
}
watchEffect(() => props.validateInitially && validate())
const validate = useDebounceFn(() => validateValue(props.modelValue), 100)
watchEffect(() => {
if (props.validateInitially) {
validateAfterFirst.value = true
validate()
}
})
function togglePasswordFieldType() {
passwordFieldType.value = passwordFieldType.value === 'password'
@@ -87,6 +94,14 @@ function togglePasswordFieldType() {
function handleInput(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
function handleSubmit(e: Event) {
// Autofill may not fire an input event.
handleInput(e)
validateValue((e.target as HTMLInputElement).value)
validateAfterFirst.value = true
emit('submit', e)
}
</script>
<style scoped>
+45
View File
@@ -50,6 +50,51 @@ test.describe('Login', () => {
await expect(page.locator('main h1')).toContainText(credentials.username)
})
for (const scenario of ['typed', 'blurred', 'retried', 'autofilled'] as const) {
test(`Should log in with Enter without flashing a password validation error (${scenario})`, async ({page}) => {
await page.goto('/login')
await page.locator('#username').fill(credentials.username)
if (scenario === 'retried') {
await page.getByRole('button', {name: 'Login', exact: true}).click()
await expect(page.locator('#password-error')).toBeVisible()
}
if (scenario === 'autofilled') {
await page.locator('#password').evaluate((input: HTMLInputElement, password) => {
input.value = password
}, credentials.password)
} else {
await page.locator('#password').pressSequentially(credentials.password)
}
if (scenario === 'blurred') {
await page.locator('#password').press('Tab')
await page.locator('#password').focus()
}
// Keep the form mounted while debounced validation runs.
await page.route('**/api/v2/login', async route => {
const response = await route.fetch()
await new Promise(resolve => setTimeout(resolve, 300))
await route.fulfill({response})
})
const errors = await page.evaluateHandle(() => {
const errors: string[] = []
const observer = new MutationObserver(() => {
const error = document.querySelector('#password-error')
if (error) errors.push(error.textContent ?? '')
})
observer.observe(document.body, {childList: true, subtree: true, characterData: true})
return errors
})
await page.locator('#password').press('Enter')
await expect(page).toHaveURL('/')
await expect(page.locator('main h1')).toContainText(credentials.username)
expect(await errors.jsonValue()).toEqual([])
await errors.dispose()
await page.reload()
await expect(page.locator('main h1')).toContainText(credentials.username)
})
}
test('Should fail with a bad password', async ({page}) => {
const fixture = {
username: 'test',