Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TECH] Ajouter un composant local de gestion des règles de mot de passe (PIX-14149) #10259

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions mon-pix/app/components/authentication/password-input/index.gjs
Original file line number Diff line number Diff line change
@@ -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';
}
}

<template>
<PixInputPassword
aria-describedby="password-checklist"
aria-invalid={{this.hasValidationStatusError}}
autocomplete="new-password"
name="password"
@errorMessage={{t "components.authentication.password-input.error-message"}}
@id="password"
@validationStatus={{this.validationStatus}}
{{on "input" this.handlePasswordChange}}
{{on "blur" this.handleValidationStatus}}
...attributes
>
<:label>{{t "pages.sign-in.fields.password.label"}}</:label>
</PixInputPassword>

<PasswordChecklist id="password-checklist" @errors={{this.errors}} @rules={{this.rules}} @value={{this.value}} />
</template>
}
Original file line number Diff line number Diff line change
@@ -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;
}

<template>
<div class="password-checklist" ...attributes>
<label class="password-checklist__instructions" for="checklist">
{{t "components.authentication.password-input.instructions-label"}}
</label>
<ul id="checklist">
{{#each this.rules as |rule|}}
<PasswordRule @description={{rule.description}} @isValid={{rule.isValid}} />
{{/each}}
</ul>
<p class="sr-only" aria-atomic="true" aria-relevant="all" aria-live="polite">
{{t
"components.authentication.password-input.rules.completed-message"
rulesCompleted=this.rulesCompleted
rulesCount=this.rulesCount
}}
</p>
</div>
</template>
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}

<template>
<li class="{{this.classes.listItemClass}}" aria-label="{{@description}}.">
<FaIcon @icon="{{this.classes.iconClass}}" />
<p aria-live="polite"> {{@description}} </p>
</li>
</template>
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
2 changes: 2 additions & 0 deletions mon-pix/app/styles/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<template><PasswordInput {{on "input" onInput}} /></template>);

// 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(<template><PasswordInput {{on "input" onInput}} /></template>);

// 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(<template><PasswordInput {{on "input" onInput}} /></template>);

// 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(<template><PasswordInput {{on "input" onInput}} /></template>);

// 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(<template><PasswordInput {{on "input" onInput}} /></template>);

// 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(<template><PasswordInput {{on "input" onInput}} /></template>);

// 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');
});
});
Loading
Loading