diff --git a/mon-pix/app/components/authentication/new-password-input/index.gjs b/mon-pix/app/components/authentication/new-password-input/index.gjs index 71b702b6475..b16bddb1614 100644 --- a/mon-pix/app/components/authentication/new-password-input/index.gjs +++ b/mon-pix/app/components/authentication/new-password-input/index.gjs @@ -3,7 +3,6 @@ import { on } from '@ember/modifier'; import { action } from '@ember/object'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { t } from 'ember-intl'; import PasswordChecklist from './password-checklist'; @@ -34,7 +33,7 @@ export default class NewPasswordInput extends Component { autocomplete="new-password" ...attributes > - <:label>{{t "components.authentication.new-password-input.label"}} + <:label>{{yield to="label"}} diff --git a/mon-pix/app/components/authentication/password-reset-form/password-reset-form-validation.js b/mon-pix/app/components/authentication/password-reset-form/password-reset-form-validation.js new file mode 100644 index 00000000000..30f454e03c1 --- /dev/null +++ b/mon-pix/app/components/authentication/password-reset-form/password-reset-form-validation.js @@ -0,0 +1,32 @@ +import { tracked } from '@glimmer/tracking'; + +import isPasswordValid from '../../../utils/password-validator'; + +export class PasswordResetFormValidation { + passwordField = new PasswordField(); + + constructor(intl) { + this.intl = intl; + } + + get isValid() { + return this.passwordField.status !== 'error'; + } + + validateField(value) { + const isValidInput = this.passwordField.validate(value); + const status = isValidInput ? 'success' : 'error'; + + this.passwordField.status = status; + this.passwordField.errorMessage = status === 'error' ? this.intl.t('common.validation.password.error') : null; + } +} + +class PasswordField { + @tracked status = 'default'; + @tracked errorMessage = null; + + validate(value) { + return isPasswordValid(value); + } +} diff --git a/mon-pix/app/components/authentication/password-reset-form/password-reset-form.gjs b/mon-pix/app/components/authentication/password-reset-form/password-reset-form.gjs new file mode 100644 index 00000000000..9dbd4ffba3b --- /dev/null +++ b/mon-pix/app/components/authentication/password-reset-form/password-reset-form.gjs @@ -0,0 +1,89 @@ +import PixButton from '@1024pix/pix-ui/components/pix-button'; +import PixMessage from '@1024pix/pix-ui/components/pix-message'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { t } from 'ember-intl'; +import get from 'lodash/get'; + +import { PASSWORD_RULES } from '../../../utils/password-validator.js'; +import NewPasswordInput from '../new-password-input'; +import { PasswordResetFormValidation } from './password-reset-form-validation'; + +const HTTP_ERROR_MESSAGES = { + 400: 'common.validation.password.error', + 403: 'components.authentication.password-reset-form.errors.forbidden', + 404: 'components.authentication.password-reset-form.errors.expired-demand', + default: 'common.api-error-messages.internal-server-error', +}; + +export default class PasswordResetForm extends Component { + @service intl; + + @tracked hasSucceeded = false; + @tracked isLoading = false; + @tracked validation = new PasswordResetFormValidation(this.intl); + @tracked globalErrorMessage; + + @action + handleInputChange(event) { + const { user } = this.args; + user.password = event.target.value; + this.validation.validateField(user.password); + } + + @action + async handleResetPassword(event) { + if (event) event.preventDefault(); + + if (!this.validation.isValid) return; + + this.hasSucceeded = false; + this.globalErrorMessage = null; + this.isLoading = true; + try { + const { user, temporaryKey } = this.args; + await user.save({ adapterOptions: { updatePassword: true, temporaryKey } }); + user.password = null; + this.hasSucceeded = true; + } catch (response) { + const status = get(response, 'errors[0].status'); + this.globalErrorMessage = this.intl.t(HTTP_ERROR_MESSAGES[status] || HTTP_ERROR_MESSAGES['default']); + } finally { + this.isLoading = false; + } + } + + +} diff --git a/mon-pix/app/components/authentication/password-reset-form/password-reset-form.scss b/mon-pix/app/components/authentication/password-reset-form/password-reset-form.scss new file mode 100644 index 00000000000..496306df629 --- /dev/null +++ b/mon-pix/app/components/authentication/password-reset-form/password-reset-form.scss @@ -0,0 +1,20 @@ +.password-reset-form { + display: flex; + flex-direction: column; + gap: var(--pix-spacing-4x); + + input { + width: 100%; + padding: var(--pix-spacing-3x); + } + + &__mandatory-fields-message { + @extend %pix-body-xs; + + color: var(--pix-neutral-500); + } + + &__password-input, &__submit-button { + width: 100%; + } +} diff --git a/mon-pix/app/components/authentication/signin-form.gjs b/mon-pix/app/components/authentication/signin-form.gjs index b167e5cfb3d..23c8bfe7457 100644 --- a/mon-pix/app/components/authentication/signin-form.gjs +++ b/mon-pix/app/components/authentication/signin-form.gjs @@ -112,7 +112,7 @@ export default class SigninForm extends Component { , ); await fillByLabel(t(I18N.PASSWORD_INPUT_LABEL), password); diff --git a/mon-pix/tests/integration/components/authentication/password-reset-form/password-reset-form-test.gjs b/mon-pix/tests/integration/components/authentication/password-reset-form/password-reset-form-test.gjs new file mode 100644 index 00000000000..c03db48b75b --- /dev/null +++ b/mon-pix/tests/integration/components/authentication/password-reset-form/password-reset-form-test.gjs @@ -0,0 +1,110 @@ +import { clickByName, fillByLabel, render } from '@1024pix/ember-testing-library'; +import { t } from 'ember-intl/test-support'; +import PasswordResetForm from 'mon-pix/components/authentication/password-reset-form/password-reset-form'; +import { module, test } from 'qunit'; +import sinon from 'sinon'; + +import setupIntlRenderingTest from '../../../../helpers/setup-intl-rendering'; + +const I18N_KEYS = { + passwordInputErrorMessage: 'components.authentication.password-reset-form.fields.password.label', + passwordInputLabel: 'components.authentication.password-reset-form.fields.password.label', + mandatoryFieldsMessage: 'common.form.mandatory-all-fields', + resetPasswordButton: 'components.authentication.password-reset-form.actions.submit', +}; + +const I18N_ERROR_KEYS = { + '400': 'common.validation.password.error', + '403': 'components.authentication.password-reset-form.errors.forbidden', + '404': 'components.authentication.password-reset-form.errors.expired-demand', + '500': 'common.api-error-messages.internal-server-error', + unknownError: 'common.api-error-messages.internal-server-error', +}; + +module('Integration | Component | Authentication | PasswordResetForm | PasswordResetForm', function (hooks) { + setupIntlRenderingTest(hooks); + + test('displays all elements of component successfully', async function (assert) { + // given + const user = { save: sinon.stub() }; + const temporaryKey = 'temporaryKey'; + + // when + const screen = await render( + , + ); + + // then + assert.dom(screen.getByText(t(I18N_KEYS.mandatoryFieldsMessage))).exists(); + assert.dom(screen.getByLabelText(t(I18N_KEYS.passwordInputLabel))).hasAttribute('required'); + assert.dom(screen.getByRole('button', { name: t(I18N_KEYS.resetPasswordButton) })).exists(); + }); + + test('resets password successfully', async function (assert) { + // given + const validPassword = 'Pix12345'; + const user = { save: sinon.stub() }; + const temporaryKey = 'temporaryKey'; + + // when + await render(); + + await fillByLabel(t(I18N_KEYS.passwordInputLabel), validPassword); + await clickByName(t(I18N_KEYS.resetPasswordButton)); + + // then + const userSavePayload = { + adapterOptions: { updatePassword: true, temporaryKey }, + }; + assert.strictEqual(user.password, null); + sinon.assert.calledWith(user.save, userSavePayload); + }); + + module('when there is a validationError on the password field', function () { + test('displays an error message on the password input', async function (assert) { + // given + const invalidPassword = 'pix'; + const user = { save: sinon.stub() }; + const temporaryKey = 'temporaryKey'; + + // when + const screen = await render( + , + ); + + await fillByLabel(t(I18N_KEYS.passwordInputLabel), invalidPassword); + await clickByName(t(I18N_KEYS.resetPasswordButton)); + + // then + assert.dom(screen.getByText(t(I18N_KEYS.passwordInputErrorMessage))).exists(); + sinon.assert.notCalled(user.save); + }); + }); + + module('When there is an error from server', function () { + const HTTP_ERROR_SERVER = ['400', '403', '404', '500', 'unknownError']; + + HTTP_ERROR_SERVER.forEach((httpErrorCode) => { + test(`displays, for the ${httpErrorCode} error code, a specific error message`, async function (assert) { + // given + const validPassword = 'Pix12345'; + const user = { save: sinon.stub() }; + const temporaryKey = 'temporaryKey'; + + user.save.rejects({ errors: [{ status: httpErrorCode }] }); + + // when + const screen = await render( + , + ); + + await fillByLabel(t(I18N_KEYS.passwordInputLabel), validPassword); + await clickByName(t(I18N_KEYS.resetPasswordButton)); + + // then + assert.dom(screen.getByRole('alert')).exists(); + assert.dom(screen.getByText(t(I18N_ERROR_KEYS[httpErrorCode]))).exists(); + }); + }); + }); +}); diff --git a/mon-pix/tests/integration/components/authentication/signup-form/index-test.gjs b/mon-pix/tests/integration/components/authentication/signup-form/index-test.gjs index 4cde3da2667..dd372c0adcf 100644 --- a/mon-pix/tests/integration/components/authentication/signup-form/index-test.gjs +++ b/mon-pix/tests/integration/components/authentication/signup-form/index-test.gjs @@ -10,7 +10,7 @@ const I18N_KEYS = { firstNameInput: 'components.authentication.signup-form.fields.firstname.label', lastNameInput: 'components.authentication.signup-form.fields.lastname.label', emailInput: 'components.authentication.signup-form.fields.email.label', - passwordInput: 'components.authentication.new-password-input.label', + passwordInput: 'common.password', cguCheckbox: 'common.cgu.label', submitButton: 'components.authentication.signup-form.actions.submit', }; diff --git a/mon-pix/tests/unit/components/authentication/password-reset-form/password-reset-form-validation-test.js b/mon-pix/tests/unit/components/authentication/password-reset-form/password-reset-form-validation-test.js new file mode 100644 index 00000000000..9d94ced3b11 --- /dev/null +++ b/mon-pix/tests/unit/components/authentication/password-reset-form/password-reset-form-validation-test.js @@ -0,0 +1,49 @@ +import { setupTest } from 'ember-qunit'; +import { PasswordResetFormValidation } from 'mon-pix/components/authentication/password-reset-form/password-reset-form-validation'; +import { module, test } from 'qunit'; + +module('Unit | Component | authentication | password-reset-form | password-reset-form-validation', function (hooks) { + setupTest(hooks); + let passwordResetFormValidation; + + hooks.beforeEach(function () { + const intlMock = { t: (key) => key }; + passwordResetFormValidation = new PasswordResetFormValidation(intlMock); + }); + + test('instantiates with default value', function (assert) { + // then + assert.strictEqual(passwordResetFormValidation.passwordField.errorMessage, null); + assert.strictEqual(passwordResetFormValidation.passwordField.status, 'default'); + }); + + module('when password field value is valid', () => { + test("returns 'success' validation", function (assert) { + // given + const validPassword = 'Pix12345'; + + // when + passwordResetFormValidation.validateField(validPassword); + + // then + assert.strictEqual(passwordResetFormValidation.passwordField.status, 'success'); + assert.strictEqual(passwordResetFormValidation.passwordField.errorMessage, null); + assert.true(passwordResetFormValidation.isValid); + }); + }); + + module('when password field value is not valid', () => { + test("returns 'error' validation", function (assert) { + // given + const validPassword = 'pix'; + + // when + passwordResetFormValidation.validateField(validPassword); + + // then + assert.strictEqual(passwordResetFormValidation.passwordField.status, 'error'); + assert.strictEqual(passwordResetFormValidation.passwordField.errorMessage, 'common.validation.password.error'); + assert.false(passwordResetFormValidation.isValid); + }); + }); +}); diff --git a/mon-pix/translations/en.json b/mon-pix/translations/en.json index e4b43b65f07..abbfea40b6f 100644 --- a/mon-pix/translations/en.json +++ b/mon-pix/translations/en.json @@ -116,6 +116,7 @@ "page-results": "{total, plural, =0 {0 items} =1 {1 item} other {{total, number} items}}", "result-by-page": "See" }, + "password": "Password", "pix": "pix", "skip-links": { "skip-to-content": "Skip to main content", @@ -170,6 +171,20 @@ "no-email-question": "No email address?", "rule": "All fields are required." }, + "password-reset-form": { + "actions": { + "submit": "Reset my password" + }, + "errors": { + "expired-demand": "We’re sorry, but your request to reset your password has already been used or has expired. Please start again.", + "forbidden": "An error occurred, please contact the support." + }, + "fields": { + "password": { + "label": "Please enter your new password" + } + } + }, "signup-form": { "actions": { "submit": "Sign up" @@ -1657,6 +1672,7 @@ "label": "Password" } }, + "first-title": "Resetting the password", "instruction": "Enter your new password", "succeed": "Your password has been changed successfully.", "title": "Change my password" diff --git a/mon-pix/translations/es.json b/mon-pix/translations/es.json index c0134bd6501..4f967d7ed04 100644 --- a/mon-pix/translations/es.json +++ b/mon-pix/translations/es.json @@ -109,6 +109,7 @@ "page-results": "{total, plural, =0 {0 elementos} =1 {1 elemento} other {{total, number} elementos}}", "result-by-page": "Ver" }, + "password": "Password", "pix": "pix", "skip-links": { "skip-to-content": "Ir al contenido", @@ -144,6 +145,20 @@ "instructions-label": "Your password must comply with the following rules:", "completed-message": "{ rulesCompleted } out of { rulesCount } requirements completed." }, + "password-reset-form": { + "actions": { + "submit": "Reset my password" + }, + "errors": { + "expired-demand": "Lo sentimos, pero tu solicitud de restablecimiento de contraseña ya ha sido utilizada o ha caducado. Por favor, vuelve a intentarlo.", + "forbidden": "Se ha producido un error, ponte en contacto con el servicio de asistencia." + }, + "fields": { + "password": { + "label": "Please enter your new password" + } + } + }, "signup-form": { "actions": { "submit": "Me inscribo" @@ -1633,6 +1648,7 @@ "label": "Contraseña" } }, + "first-title": "Resetting the password", "instruction": "Introduce tu nueva contraseña", "succeed": "Tu contraseña ha sido cambiada con éxito.", "title": "Cambiar mi contraseña" diff --git a/mon-pix/translations/fr.json b/mon-pix/translations/fr.json index 4f014df580b..9656e1e9ebd 100644 --- a/mon-pix/translations/fr.json +++ b/mon-pix/translations/fr.json @@ -116,6 +116,7 @@ "page-results": "{total, plural, =0 {0 élément} =1 {1 élément} other {{total, number} éléments}}", "result-by-page": "Voir" }, + "password": "Mot de passe", "pix": "pix", "skip-links": { "skip-to-content": "Aller au contenu", @@ -170,6 +171,20 @@ "no-email-question": "Pas d’adresse e-mail renseignée ?", "rule": "Tous les champs sont obligatoires." }, + "password-reset-form": { + "actions": { + "submit": "Je réinitialise mon mot de passe" + }, + "errors": { + "expired-demand": "Nous sommes désolés, mais votre demande de réinitialisation de mot de passe a déjà été utilisée ou est expirée. Merci de recommencer.", + "forbidden": "Une erreur est survenue, veuillez contacter le support." + }, + "fields": { + "password": { + "label": "Saisissez votre nouveau mot de passe" + } + } + }, "signup-form": { "actions": { "submit": "Je m'inscris" @@ -1657,6 +1672,7 @@ "label": "Mot de passe" } }, + "first-title": "Réinitialisation du mot de passe", "instruction": "Saisissez votre nouveau mot de passe", "succeed": "Votre mot de passe a été modifié avec succès.", "title": "Changer mon mot de passe" diff --git a/mon-pix/translations/nl.json b/mon-pix/translations/nl.json index a565ff3ef5d..78230e492bc 100644 --- a/mon-pix/translations/nl.json +++ b/mon-pix/translations/nl.json @@ -109,6 +109,7 @@ "page-results": "{total, plural, =0 {0 item} =1 {1 item} other {{total, number} items}}", "result-by-page": "Zie" }, + "password": "Password", "pix": "Pix", "skip-links": { "skip-to-content": "Ga naar inhoud", @@ -144,6 +145,20 @@ "instructions-label": "Your password must comply with the following rules:", "completed-message": "{ rulesCompleted } out of { rulesCount } requirements completed." }, + "password-reset-form": { + "actions": { + "submit": "Reset my password" + }, + "errors": { + "expired-demand": "Het spijt ons, maar uw wachtwoordaanvraag is al gebruikt of verlopen. Probeer het opnieuw.", + "forbidden": "Er is een fout opgetreden, neem contact op met ondersteuning." + }, + "fields": { + "password": { + "label": "Please enter your new password" + } + } + }, "signup-form": { "actions": { "submit": "Ik wil graag registreren" @@ -563,9 +578,9 @@ "steps": { "1": { "paragraphs": { - "1": "''Dit is een adaptieve test''. Met andere woorden, uw vermogen om vragen correct te beantwoorden (of niet) bepaalt de moeilijkheidsgraad van de volgende vragen in real time.", - "2": "Als u een vraag niet kunt beantwoorden, ''kunt u die vraag \"overslaan\"''. '
'Overgeslagen vragen worden als mislukt beschouwd en worden niet opnieuw aangeboden.", - "3": "''Het is belangrijk dat u de toets afmaakt'' en dus goed met uw tijd omgaat. Er wordt een ''boete'' toegepast waarbij rekening wordt gehouden met het aantal resterende vragen.", + "1": "''Dit is een adaptieve test''. Met andere woorden, uw vermogen om vragen correct te beantwoorden (of niet) bepaalt de moeilijkheidsgraad van de volgende vragen in real time.", + "2": "Als u een vraag niet kunt beantwoorden, ''kunt u die vraag \"overslaan\"''. '
'Overgeslagen vragen worden als mislukt beschouwd en worden niet opnieuw aangeboden.", + "3": "''Het is belangrijk dat u de toets afmaakt'' en dus goed met uw tijd omgaat. Er wordt een ''boete'' toegepast waarbij rekening wordt gehouden met het aantal resterende vragen.", "4": "To obtain a score as close as possible to your ability, ''you need to complete the test''." }, "question": "Hoe werkt de certificeringstest?", @@ -1633,6 +1648,7 @@ "label": "Wachtwoord" } }, + "first-title": "Resetting the password", "instruction": "Voer uw nieuwe wachtwoord in", "succeed": "Uw wachtwoord is gewijzigd.", "title": "Mijn wachtwoord wijzigen"