diff --git a/mon-pix/app/components/authentication/password-input/index.gjs b/mon-pix/app/components/authentication/password-input/index.gjs new file mode 100644 index 00000000000..9588e988e4b --- /dev/null +++ b/mon-pix/app/components/authentication/password-input/index.gjs @@ -0,0 +1,102 @@ +import PixInputPassword from '@1024pix/pix-ui/components/pix-input-password'; +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 PasswordChecklist from './password-checklist'; + +const CONTAINS_UPPERCASE_KEY = 'containsUppercase'; +const CONTAINS_LOWERCASE_KEY = 'containsLowercase'; +const CONTAINS_DIGIT_KEY = 'containsDigit'; +const MIN_LENGTH_KEY = 'minLength'; + +export default class PasswordInput extends Component { + @service intl; + + @tracked errors = []; + @tracked validationStatus = 'default'; + @tracked value = ''; + + get hasValidationStatusError() { + return this.validationStatus === 'error'; + } + + get rules() { + return [ + { + key: MIN_LENGTH_KEY, + description: this.intl.t('components.authentication.password-input.rules.min-length'), + }, + { + key: CONTAINS_UPPERCASE_KEY, + description: this.intl.t('components.authentication.password-input.rules.contains-uppercase'), + }, + { + key: CONTAINS_LOWERCASE_KEY, + description: this.intl.t('components.authentication.password-input.rules.contains-lowercase'), + }, + { + key: CONTAINS_DIGIT_KEY, + description: this.intl.t('components.authentication.password-input.rules.contains-digit'), + }, + ]; + } + + checkRules() { + const errors = []; + const hasDigit = (value) => /\d/.test(value); + const hasLowercase = (value) => /[a-zà-ÿ]/.test(value); + const hasUppercase = (value) => /[A-ZÀ-ß]/.test(value); + const hasMinLength = (value) => value.length && value.length >= 8; + const value = this.value; + + if (!hasDigit(value)) errors.push(CONTAINS_DIGIT_KEY); + if (!hasLowercase(value)) errors.push(CONTAINS_LOWERCASE_KEY); + if (!hasUppercase(value)) errors.push(CONTAINS_UPPERCASE_KEY); + if (!hasMinLength(value)) errors.push(MIN_LENGTH_KEY); + + return errors; + } + + @action + handlePasswordChange(event) { + this.value = event.target.value; + this.errors = this.checkRules(); + + if (this.value && this.errors.length === 0) { + this.validationStatus = 'success'; + } + + const { onInput } = this.args; + if (onInput) onInput(event); + } + + @action + handleValidationStatus() { + if (!this.value || this.errors.length > 0) { + this.validationStatus = 'error'; + } + } + + +} diff --git a/mon-pix/app/components/authentication/password-input/password-checklist.gjs b/mon-pix/app/components/authentication/password-input/password-checklist.gjs new file mode 100644 index 00000000000..e04b7932518 --- /dev/null +++ b/mon-pix/app/components/authentication/password-input/password-checklist.gjs @@ -0,0 +1,44 @@ +import Component from '@glimmer/component'; +import { t } from 'ember-intl'; + +import PasswordRule from './password-rule'; + +export default class PasswordChecklist extends Component { + get rules() { + const { rules, value, errors } = this.args; + return rules.map((rule) => ({ + ...rule, + isValid: Boolean(value) && !errors?.includes(rule.key), + })); + } + + get rulesCount() { + const { rules } = this.args; + return rules.length; + } + + get rulesCompleted() { + const { rules, value, errors } = this.args; + return !value ? 0 : rules.length - errors?.length; + } + + +} diff --git a/mon-pix/app/components/authentication/password-input/password-checklist.scss b/mon-pix/app/components/authentication/password-input/password-checklist.scss new file mode 100644 index 00000000000..757d4eb3991 --- /dev/null +++ b/mon-pix/app/components/authentication/password-input/password-checklist.scss @@ -0,0 +1,14 @@ +.password-checklist { + @extend %pix-body-s; + + display: flex; + flex-direction: column; + padding: var(--pix-spacing-2x) var(--pix-spacing-4x); + background-color: var(--pix-neutral-20); + border-radius: var(--pix-spacing-2x); + + &__instructions { + margin-bottom: var(--pix-spacing-2x); + font-weight: var(--pix-font-bold); + } +} diff --git a/mon-pix/app/components/authentication/password-input/password-rule.gjs b/mon-pix/app/components/authentication/password-input/password-rule.gjs new file mode 100644 index 00000000000..58b10981ac8 --- /dev/null +++ b/mon-pix/app/components/authentication/password-input/password-rule.gjs @@ -0,0 +1,26 @@ +import FaIcon from '@fortawesome/ember-fontawesome/components/fa-icon'; +import Component from '@glimmer/component'; + +const RULE_STYLES = { + VALID: { + iconClass: 'circle-check', + listItemClass: 'password-rule', + }, + INVALID: { + iconClass: 'circle-xmark', + listItemClass: 'password-rule password-rule--error', + }, +}; + +export default class PasswordRule extends Component { + get classes() { + return this.args.isValid ? RULE_STYLES.VALID : RULE_STYLES.INVALID; + } + + +} diff --git a/mon-pix/app/components/authentication/password-input/password-rule.scss b/mon-pix/app/components/authentication/password-input/password-rule.scss new file mode 100644 index 00000000000..ca4801827d0 --- /dev/null +++ b/mon-pix/app/components/authentication/password-input/password-rule.scss @@ -0,0 +1,17 @@ +.password-rule { + display: flex; + gap: var(--pix-spacing-2x); + align-items: center; + + path { + fill: var(--pix-success-500); + } + + &--error { + font-weight: var(--pix-font-bold); + + path { + fill: var(--pix-error-500); + } + } +} diff --git a/mon-pix/app/styles/app.scss b/mon-pix/app/styles/app.scss index d9eddb0c56c..ff43c8a74c5 100644 --- a/mon-pix/app/styles/app.scss +++ b/mon-pix/app/styles/app.scss @@ -137,6 +137,8 @@ of an adaptative/mobile-first approach — refactoring is welcome here */ @import 'authentication/oidc-provider-selector'; @import 'authentication/signin-form'; @import 'authentication/sso-selection-form'; +@import 'authentication/password-input/password-checklist'; +@import 'authentication/password-input/password-rule'; /* pages */ @import 'pages/assessment-results'; diff --git a/mon-pix/tests/integration/components/authentication/password-input/index-test.gjs b/mon-pix/tests/integration/components/authentication/password-input/index-test.gjs new file mode 100644 index 00000000000..4bc61275be3 --- /dev/null +++ b/mon-pix/tests/integration/components/authentication/password-input/index-test.gjs @@ -0,0 +1,122 @@ +import { fillByLabel, render } from '@1024pix/ember-testing-library'; +import { on } from '@ember/modifier'; +import { blur } from '@ember/test-helpers'; +import { t } from 'ember-intl/test-support'; +import PasswordInput from 'mon-pix/components/authentication/password-input'; +import { module, test } from 'qunit'; +import sinon from 'sinon'; + +import setupIntlRenderingTest from '../../../../helpers/setup-intl-rendering'; + +const I18N = { + PASSWORD_INPUT_LABEL: 'pages.sign-in.fields.password.label', + RULES_STATUS_MESSAGE: 'components.authentication.password-input.rules.completed-message', + ERROR_MESSAGE: 'components.authentication.password-input.error-message', +}; + +module('Integration | Component | authentication | password-input', function (hooks) { + setupIntlRenderingTest(hooks); + + test('it respects all rules', async function (assert) { + // given + const validPassword = 'Pix12345'; + const onInput = sinon.spy(); + + const screen = await render(); + + // when + await fillByLabel(t(I18N.PASSWORD_INPUT_LABEL), validPassword); + const passwordInputElement = screen.getByLabelText(t(I18N.PASSWORD_INPUT_LABEL)); + await blur(passwordInputElement); + + // then + const onInputEvent = onInput.firstCall.args[0]; + assert.strictEqual(onInputEvent.target.value, validPassword); + + assert.dom(passwordInputElement).doesNotHaveAttribute('aria-invalid'); + + const rulesStatusMessage = t(I18N.RULES_STATUS_MESSAGE, { rulesCompleted: 4, rulesCount: 4 }); + assert.dom(screen.getByText(rulesStatusMessage)).exists(); + }); + + test('it does not respect any rules', async function (assert) { + // given + const invalidPassword = ' '; + const onInput = sinon.stub(); + + const screen = await render(); + + // when + await fillByLabel(t(I18N.PASSWORD_INPUT_LABEL), invalidPassword); + const passwordInputElement = screen.getByLabelText(t(I18N.PASSWORD_INPUT_LABEL)); + await blur(passwordInputElement); + + // then + assert.dom(passwordInputElement).hasAttribute('aria-invalid'); + assert.dom(screen.getByText(t(I18N.ERROR_MESSAGE))).exists(); + }); + + test('it must have a minimum length of 8 chars', async function (assert) { + // given + const invalidPassword = 'Pix1234'; + const onInput = sinon.stub(); + + const screen = await render(); + + // when + await fillByLabel(t(I18N.PASSWORD_INPUT_LABEL), invalidPassword); + const passwordInputElement = screen.getByLabelText(t(I18N.PASSWORD_INPUT_LABEL)); + await blur(passwordInputElement); + + // then + assert.dom(passwordInputElement).hasAttribute('aria-invalid'); + }); + + test('it must contains at least one uppercase char', async function (assert) { + // given + const invalidPassword = 'pix12345'; + const onInput = sinon.stub(); + + const screen = await render(); + + // when + await fillByLabel(t(I18N.PASSWORD_INPUT_LABEL), invalidPassword); + const passwordInputElement = screen.getByLabelText(t(I18N.PASSWORD_INPUT_LABEL)); + await blur(passwordInputElement); + + // then + assert.dom(passwordInputElement).hasAttribute('aria-invalid'); + }); + + test('it must contains at least one lowercase char', async function (assert) { + // given + const invalidPassword = 'PIX12345'; + const onInput = sinon.stub(); + + const screen = await render(); + + // when + await fillByLabel(t(I18N.PASSWORD_INPUT_LABEL), invalidPassword); + const passwordInputElement = screen.getByLabelText(t(I18N.PASSWORD_INPUT_LABEL)); + await blur(passwordInputElement); + + // then + assert.dom(passwordInputElement).hasAttribute('aria-invalid'); + }); + + test('it must contains at least one number', async function (assert) { + // given + const invalidPassword = 'PIXpixPIX'; + const onInput = sinon.stub(); + + const screen = await render(); + + // when + await fillByLabel(t(I18N.PASSWORD_INPUT_LABEL), invalidPassword); + const passwordInputElement = screen.getByLabelText(t(I18N.PASSWORD_INPUT_LABEL)); + await blur(passwordInputElement); + + // then + assert.dom(passwordInputElement).hasAttribute('aria-invalid'); + }); +}); diff --git a/mon-pix/tests/integration/components/authentication/password-input/password-checklist-test.gjs b/mon-pix/tests/integration/components/authentication/password-input/password-checklist-test.gjs new file mode 100644 index 00000000000..be92ccc3521 --- /dev/null +++ b/mon-pix/tests/integration/components/authentication/password-input/password-checklist-test.gjs @@ -0,0 +1,85 @@ +import { render } from '@1024pix/ember-testing-library'; +import { t } from 'ember-intl/test-support'; +import PasswordChecklist from 'mon-pix/components/authentication/password-input/password-checklist'; +import { module, test } from 'qunit'; + +import setupIntlRenderingTest from '../../../../helpers/setup-intl-rendering'; + +const I18N = { + RULES_STATUS_MESSAGE: 'components.authentication.password-input.rules.completed-message', +}; + +module('Integration | Component | authentication | password-input | password-checklist', function (hooks) { + setupIntlRenderingTest(hooks); + + module('when a password value is set', function () { + module('when there is no validation error', function () { + test('it displays rules and indicates that all rules completed', async function (assert) { + // given + const value = 'my-value'; + const rules = [ + { key: 'rule1', description: 'Rule 1' }, + { key: 'rule2', description: 'Rule 2' }, + ]; + const errors = []; + + // when + const screen = await render( + , + ); + + // then + const rule1Element = screen.getByRole('listitem', { name: 'Rule 1.' }); + assert.dom(rule1Element).exists(); + + const rule2Element = screen.getByRole('listitem', { name: 'Rule 2.' }); + assert.dom(rule2Element).exists(); + + const rulesStatusMessage = t(I18N.RULES_STATUS_MESSAGE, { rulesCompleted: 2, rulesCount: 2 }); + assert.dom(screen.getByText(rulesStatusMessage)).exists(); + }); + }); + + module('when there is a validation error', function () { + test('it indicates number of valid rules completed', async function (assert) { + // given + const value = 'my-value'; + const rules = [ + { key: 'rule1', description: 'Rule 1' }, + { key: 'rule2', description: 'Rule 2' }, + ]; + const errors = ['rule1']; + + // when + const screen = await render( + , + ); + + // then + const rulesStatusMessage = t(I18N.RULES_STATUS_MESSAGE, { rulesCompleted: 1, rulesCount: 2 }); + assert.dom(screen.getByText(rulesStatusMessage)).exists(); + }); + }); + }); + + module('when no password value is set', function () { + test('it indicates no rule is completed', async function (assert) { + // given + const value = null; + const rules = [ + { key: 'rule1', description: 'Rule 1' }, + { key: 'rule2', description: 'Rule 2' }, + ]; + const errors = []; + + // when + const screen = await render( + , + ); + + // then + const rulesStatusMessage = t(I18N.RULES_STATUS_MESSAGE, { rulesCompleted: 0, rulesCount: 2 }); + assert.dom(screen.getByText(rulesStatusMessage)).exists(); + }); + }); +}); diff --git a/mon-pix/tests/integration/components/authentication/password-input/password-rule-test.gjs b/mon-pix/tests/integration/components/authentication/password-input/password-rule-test.gjs new file mode 100644 index 00000000000..74c871cb3ce --- /dev/null +++ b/mon-pix/tests/integration/components/authentication/password-input/password-rule-test.gjs @@ -0,0 +1,49 @@ +import { render } from '@1024pix/ember-testing-library'; +import PasswordRule from 'mon-pix/components/authentication/password-input/password-rule'; +import { module, test } from 'qunit'; + +import setupIntlRenderingTest from '../../../../helpers/setup-intl-rendering'; + +module('Integration | Component | authentication | password-input | password-rule', function (hooks) { + setupIntlRenderingTest(hooks); + + test('it renders a valid rule', async function (assert) { + // given + const description = 'rule description'; + const isValid = true; + const key = 'ruleId'; + + // when + const screen = await render( + , + ); + + // then + const listItemElement = screen.getByRole('listitem'); + + assert.dom(listItemElement).exists(); + assert.dom(listItemElement).hasAttribute('aria-label', `${description}.`); + assert.dom(listItemElement).hasAttribute('class', 'password-rule'); + assert.dom(screen.getByText(description)).exists(); + }); + + test('it renders an invalid rule', async function (assert) { + // given + const description = 'rule description'; + const isValid = false; + const key = 'ruleId'; + + // when + const screen = await render( + , + ); + + // then + const listItemElement = screen.getByRole('listitem'); + + assert.dom(listItemElement).exists(); + assert.dom(listItemElement).hasAttribute('aria-label', `${description}.`); + assert.dom(listItemElement).hasAttribute('class', 'password-rule password-rule--error'); + assert.dom(screen.getByText(description)).exists(); + }); +}); diff --git a/mon-pix/translations/en.json b/mon-pix/translations/en.json index 91a7c084534..b2b7fcc4e2e 100644 --- a/mon-pix/translations/en.json +++ b/mon-pix/translations/en.json @@ -124,6 +124,17 @@ "label": "Search for an organization", "placeholder": "Select an organization", "searchLabel": "Keyword search" + }, + "password-input": { + "error-message": "You have entered the wrong password. Your password must be longer than 8 characters and contain at least one number, one lower case letter and one upper case letter.", + "instructions-label": "Your password must comply with the following rules:", + "rules": { + "completed-message": "{ rulesCompleted } out of { rulesCount } requirements completed.", + "contains-digit": "at least one number", + "contains-lowercase": "at least one lower case letter", + "contains-uppercase": "at least one capital letter", + "min-length": "at least 8 characters" + } } }, "invited": { diff --git a/mon-pix/translations/es.json b/mon-pix/translations/es.json index 53a247120c3..108cff45ba2 100644 --- a/mon-pix/translations/es.json +++ b/mon-pix/translations/es.json @@ -117,6 +117,17 @@ "label": "Search for an organization", "placeholder": "Select an organization", "searchLabel": "Keyword search" + }, + "password-input": { + "error-message": "You have entered the wrong password. Your password must be longer than 8 characters and contain at least one number, one lower case letter and one upper case letter.", + "instructions-label": "Your password must comply with the following rules:", + "rules": { + "completed-message": "{ rulesCompleted } out of { rulesCount } requirements completed.", + "contains-digit": "at least one number", + "contains-lowercase": "at least one lower case letter", + "contains-uppercase": "at least one capital letter", + "min-length": "at least 8 characters" + } } }, "invited": { @@ -1357,8 +1368,8 @@ } }, "flashcards": { - "start" : "Para empezar", - "seeAnswer" : "ver la respuesta", + "start": "Para empezar", + "seeAnswer": "ver la respuesta", "seeAgain": "Revisa la pregunta", "nextCard": "Siguiente" }, diff --git a/mon-pix/translations/fr.json b/mon-pix/translations/fr.json index 1a0cff9f536..c349130b919 100644 --- a/mon-pix/translations/fr.json +++ b/mon-pix/translations/fr.json @@ -124,6 +124,17 @@ "label": "Rechercher une organisation", "placeholder": "Sélectionner un organisme", "searchLabel": "Recherche par mots-clés" + }, + "password-input": { + "error-message": "Le mot de passe saisi est erroné. Votre mot de passe doit être supérieur à 8 caractères et contenir au minimum un chiffre, une minuscule et une majuscule.", + "instructions-label": "Votre mot de passe doit respecter les règles suivantes :", + "rules": { + "completed-message": "{ rulesCompleted } sur { rulesCount } complétées.", + "contains-digit": "un chiffre minimum", + "contains-lowercase": "une minuscule minimum", + "contains-uppercase": "une majuscule minimum", + "min-length": "8 caractères minimum" + } } }, "invited": { diff --git a/mon-pix/translations/nl.json b/mon-pix/translations/nl.json index 9c4e4e5491e..68bcabe9b9b 100644 --- a/mon-pix/translations/nl.json +++ b/mon-pix/translations/nl.json @@ -117,6 +117,17 @@ "label": "Search for an organization", "placeholder": "Select an organization", "searchLabel": "Keyword search" + }, + "password-input": { + "error-message": "You have entered the wrong password. Your password must be longer than 8 characters and contain at least one number, one lower case letter and one upper case letter.", + "instructions-label": "Your password must comply with the following rules:", + "rules": { + "completed-message": "{ rulesCompleted } out of { rulesCount } requirements completed.", + "contains-digit": "at least one number", + "contains-lowercase": "at least one lower case letter", + "contains-uppercase": "at least one capital letter", + "min-length": "at least 8 characters" + } } }, "invited": { @@ -1358,7 +1369,7 @@ }, "flashcards": { "start": "Beginnen", - "seeAnswer" : "Zie het antwoord", + "seeAnswer": "Zie het antwoord", "seeAgain": "Bekijk de vraag", "nextCard": "Volgende" },