diff --git a/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue b/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue
index 8be9c6a54d..8ebea3ace0 100644
--- a/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue
+++ b/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue
@@ -100,6 +100,7 @@
}
.message-slot-container {
+ width: 100%;
max-width: 400px;
margin: 24px auto 0;
text-align: center;
diff --git a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js
index c87be9ab31..89c403d098 100644
--- a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js
+++ b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js
@@ -1,7 +1,20 @@
-import { render, screen, fireEvent, waitFor } from '@testing-library/vue';
+import { render, screen, fireEvent, waitFor, within } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
import VueRouter from 'vue-router';
import { createLocalVue } from '@vue/test-utils';
import ResetPassword from '../resetPassword/ResetPassword';
+import commonStrings from 'shared/translator';
+import { createTranslator } from 'shared/i18n';
+
+const { fieldRequired$ } = commonStrings;
+const {
+ passwordLabel$,
+ passwordConfirmLabel$,
+ passwordValidationMessage$,
+ passwordMatchMessage$,
+ submitButton$,
+ resetPasswordFailed$,
+} = createTranslator(ResetPassword.name, ResetPassword.$trs);
const localVue = createLocalVue();
localVue.use(VueRouter);
@@ -10,6 +23,7 @@ const setPasswordMock = jest.fn(() => Promise.resolve());
const renderComponent = (queryParams = {}) => {
const router = new VueRouter({
+ mode: 'abstract',
routes: [
{ path: '/', name: 'Main' },
{ path: '/reset-password', name: 'ResetPassword' },
@@ -80,4 +94,130 @@ describe('ResetPassword', () => {
expect(router.currentRoute.name).toBe('ResetPasswordSuccess');
});
});
+
+ describe('validation feedback', () => {
+ let user;
+
+ beforeEach(() => {
+ user = userEvent.setup();
+ });
+
+ const newPasswordField = () => screen.getByLabelText(passwordLabel$());
+ const confirmPasswordField = () => screen.getByLabelText(passwordConfirmLabel$());
+ // KTextbox doesn't link its error text to the input via ARIA, so scope
+ // queries to the textbox container that holds both.
+ const fieldContainer = field => field.closest('.ui-textbox');
+
+ it('shows the required error when an empty field loses focus', async () => {
+ renderComponent();
+
+ await user.click(newPasswordField());
+ await user.tab();
+
+ expect(within(fieldContainer(newPasswordField())).getByText(fieldRequired$())).toBeVisible();
+ expect(
+ within(fieldContainer(confirmPasswordField())).queryByText(fieldRequired$()),
+ ).not.toBeInTheDocument();
+ });
+
+ it('hides errors while typing and shows them once the field loses focus', async () => {
+ renderComponent();
+
+ await user.type(newPasswordField(), 'short');
+ expect(screen.queryByText(passwordValidationMessage$())).not.toBeInTheDocument();
+
+ await user.tab();
+ expect(screen.getByText(passwordValidationMessage$())).toBeInTheDocument();
+ });
+
+ it('re-checks the confirmation when the new password changes', async () => {
+ renderComponent();
+
+ await user.type(newPasswordField(), 'abcdefgh');
+ await user.type(confirmPasswordField(), 'abcdefgh');
+ await user.tab();
+ expect(screen.queryByText(passwordMatchMessage$())).not.toBeInTheDocument();
+
+ await user.type(newPasswordField(), 'ZZ');
+ await user.tab();
+ expect(screen.getByText(passwordMatchMessage$())).toBeInTheDocument();
+ });
+
+ it('clears the mismatch error when the new password is changed to match', async () => {
+ renderComponent();
+
+ await user.type(newPasswordField(), 'abcdefgh');
+ await user.type(confirmPasswordField(), 'abcdefghZZ');
+ await user.tab();
+ expect(screen.getByText(passwordMatchMessage$())).toBeInTheDocument();
+
+ await user.type(newPasswordField(), 'ZZ');
+ expect(screen.queryByText(passwordMatchMessage$())).not.toBeInTheDocument();
+ });
+
+ it('shows the required error on both fields when submitting an empty form', async () => {
+ renderComponent();
+
+ await user.click(screen.getByRole('button', { name: submitButton$() }));
+
+ expect(screen.getAllByText(fieldRequired$())).toHaveLength(2);
+ expect(setPasswordMock).not.toHaveBeenCalled();
+ });
+
+ it('moves focus to the new password field when it is the first invalid field', async () => {
+ renderComponent();
+
+ await user.click(screen.getByRole('button', { name: submitButton$() }));
+
+ await waitFor(() => {
+ expect(newPasswordField()).toHaveFocus();
+ });
+ });
+
+ it('moves focus to the confirmation field when only it is invalid', async () => {
+ renderComponent();
+
+ await user.type(newPasswordField(), 'abcdefgh');
+ await user.type(confirmPasswordField(), 'mismatch');
+ await user.click(screen.getByRole('button', { name: submitButton$() }));
+
+ await waitFor(() => {
+ expect(confirmPasswordField()).toHaveFocus();
+ });
+ });
+ });
+
+ it('shows and announces an error banner when resetting the password fails', async () => {
+ const user = userEvent.setup();
+ setPasswordMock.mockRejectedValueOnce(new Error('Invalid token'));
+ renderComponent();
+
+ await user.type(screen.getByLabelText(passwordLabel$()), 'validPassword123');
+ await user.type(screen.getByLabelText(passwordConfirmLabel$()), 'validPassword123');
+ await user.click(screen.getByRole('button', { name: submitButton$() }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent(resetPasswordFailed$());
+ });
+ });
+
+ it('preserves leading and trailing whitespace in the submitted passwords', async () => {
+ const user = userEvent.setup();
+ const PASSWORD_WITH_SPACES = ' spaced password ';
+ renderComponent();
+
+ await user.type(screen.getByLabelText(passwordLabel$()), PASSWORD_WITH_SPACES);
+ await user.type(screen.getByLabelText(passwordConfirmLabel$()), PASSWORD_WITH_SPACES);
+ await user.click(screen.getByRole('button', { name: submitButton$() }));
+
+ await waitFor(() => {
+ expect(setPasswordMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ new_password1: PASSWORD_WITH_SPACES,
+ new_password2: PASSWORD_WITH_SPACES,
+ }),
+ );
+ });
+ });
});
diff --git a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue
index f7149673d5..d5e490af2e 100644
--- a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue
+++ b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue
@@ -1,30 +1,36 @@
-
-
-
-
+ {{ $tr('resetPasswordFailed') }}
+
+
-
-
-
+
+
@@ -41,52 +47,107 @@