diff --git a/apps/files_sharing/src/views/SharingDetailsTab.vue b/apps/files_sharing/src/views/SharingDetailsTab.vue index c1cc67ca72289..aa858f0a70c33 100644 --- a/apps/files_sharing/src/views/SharingDetailsTab.vue +++ b/apps/files_sharing/src/views/SharingDetailsTab.vue @@ -167,6 +167,7 @@ + @update:model-value="onExpirationChange" + @change="checkExpirationDateValidity" /> diff --git a/apps/settings/src/components/Users/UserFormFields.vue b/apps/settings/src/components/Users/UserFormFields.vue index 2e408e8329b6e..5e41946e968a9 100644 --- a/apps/settings/src/components/Users/UserFormFields.vue +++ b/apps/settings/src/components/Users/UserFormFields.vue @@ -30,7 +30,9 @@ autocomplete="off" spellcheck="false" pattern="[a-zA-Z0-9 _\.@\-']+" - :required="fieldConfig.username?.required" /> + :required="fieldConfig.username?.required" + @input="updateUsernameValidity" + @blur="updateUsernameValidity" /> void } | null>(null) const minPasswordLength = computed(() => store.getters.getPasswordPolicyMinLength) +/** + * Customize the browser-native constraint validation message. + * + * @param event Input/blur event from the underlying input element + */ +function updateUsernameValidity(event: Event) { + const input = event.target as HTMLInputElement | null + if (!input) { + return + } + + // Clear first so native constraint flags are evaluated without a stale customError. + input.setCustomValidity('') + if (input.validity.patternMismatch) { + input.setCustomValidity(t('settings', 'Only letters, numbers, spaces, and _.@-\' are allowed')) + } +} + // Errors not bound to a dedicated input, shown in the catch-all live region. const unhandledErrors = computed(() => { const handled = new Set(['displayName', 'password', 'email']) diff --git a/core/src/components/login/LoginForm.vue b/core/src/components/login/LoginForm.vue index 4a350907db74c..c71d74e93865c 100644 --- a/core/src/components/login/LoginForm.vue +++ b/core/src/components/login/LoginForm.vue @@ -63,7 +63,7 @@ :spellchecking="false" :autocomplete="autoCompleteAllowed ? 'username' : 'off'" required - :error="userNameInputLengthIs255" + :error="userNameError" :helper-text="userInputHelperText" data-login-form-input-user @change="updateUsername" /> @@ -241,6 +241,10 @@ export default { || this.throttleDelay > 5000 }, + userNameError() { + return this.isError || this.userNameInputLengthIs255 + }, + errorLabel() { if (this.invalidPassword) { return t('core', 'Wrong login or password.') diff --git a/core/src/tests/components/Login/LoginForm.spec.ts b/core/src/tests/components/Login/LoginForm.spec.ts index b471c9bdf81df..9c6299bd2d40f 100644 --- a/core/src/tests/components/Login/LoginForm.spec.ts +++ b/core/src/tests/components/Login/LoginForm.spec.ts @@ -66,6 +66,18 @@ describe('core: LoginForm', () => { expect(input.value).toBe('test-user') }) + it('marks username field as error when login credentials are invalid', () => { + const page = render(LoginForm, { + props: { + errors: ['invalidpassword'], + username: 'wrong-user', + }, + }) + + const input = page.getByRole('textbox', { name: /Account name or email/ }) + expect(input.closest('.input-field--error')).not.toBeNull() + }) + describe('', () => { beforeAll(() => { vi.useFakeTimers() diff --git a/tests/playwright/e2e/login/login.spec.ts b/tests/playwright/e2e/login/login.spec.ts index 6101e80f2e009..be003950a907f 100644 --- a/tests/playwright/e2e/login/login.spec.ts +++ b/tests/playwright/e2e/login/login.spec.ts @@ -45,24 +45,26 @@ test.describe('Login', () => { await expect(page).toHaveURL(/apps\/dashboard(\/|$)/) }) - test('wrong password shows error and marks password field invalid', async ({ page, user }) => { + test('wrong password shows error and marks both fields invalid', async ({ page, user }) => { const loginPage = new LoginPage(page) await loginPage.goto() await loginPage.login(user.userId, `${user.password}--wrong`) await expect(page).toHaveURL(/\/login/) await expect(page.getByText(/Wrong login or password/i)).toBeVisible() - await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1) + await expect(page.locator('.input-field--error').filter({ has: loginPage.usernameInput() })).toBeVisible() + await expect(page.locator('.input-field--error').filter({ has: loginPage.passwordInput() })).toBeVisible() }) - test('wrong account name shows error and marks password field invalid', async ({ page, user }) => { + test('wrong account name shows error and marks both fields invalid', async ({ page, user }) => { const loginPage = new LoginPage(page) await loginPage.goto() await loginPage.login(`${user.userId}--wrong`, user.password) await expect(page).toHaveURL(/\/login/) await expect(page.getByText(/Wrong login or password/i)).toBeVisible() - await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1) + await expect(page.locator('.input-field--error').filter({ has: loginPage.usernameInput() })).toBeVisible() + await expect(page.locator('.input-field--error').filter({ has: loginPage.passwordInput() })).toBeVisible() }) test('disabled account shows disabled error', async ({ page, disabledUser }) => { @@ -72,7 +74,8 @@ test.describe('Login', () => { await expect(page).toHaveURL(/\/login/) await expect(page.getByText(/Account.*disabled/i)).toBeVisible() - await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1) + await expect(page.locator('.input-field--error').filter({ has: loginPage.usernameInput() })).toBeVisible() + await expect(page.locator('.input-field--error').filter({ has: loginPage.passwordInput() })).toBeVisible() }) test('logout redirects to the login page', async ({ page, context, user }) => {