diff --git a/assets/app.ts b/assets/app.ts index 5ac4b1c85..83aea3964 100644 --- a/assets/app.ts +++ b/assets/app.ts @@ -27,4 +27,5 @@ import './controllers/form_visite'; import './controllers/view_signalement'; import './controllers/cookie_banner'; import './controllers/maintenance_banner'; +import './controllers/activate_account/activate_account'; diff --git a/assets/controllers/activate_account/activate_account.js b/assets/controllers/activate_account/activate_account.js new file mode 100644 index 000000000..07bf21da4 --- /dev/null +++ b/assets/controllers/activate_account/activate_account.js @@ -0,0 +1,90 @@ +document?.querySelectorAll('.fr-password-toggle')?.forEach(pwdToggle => { + pwdToggle.addEventListeners('click touchdown', (event) => { + ['fr-fi-eye-off-fill', 'fr-fi-eye-fill'].map(c => { + event.target.classList.toggle(c); + }) + let pwd = event.target.parentElement.querySelector('[name^="password"]'); + "text" !== pwd.type ? pwd.type = "text" : pwd.type = "password"; + }) +}) +document?.querySelector('form[name="login-creation-mdp-form"]')?.querySelectorAll('[name^="password"]').forEach(pwd => { + pwd.addEventListener('input', canSubmitFormReinitPassword) +}) +document?.querySelector('form[name="login-creation-mdp-form"]')?.addEventListener('submit', (event) => { + event.preventDefault(); + const modalCgu = document.getElementById("fr-modal-cgu-bo"); + dsfr(modalCgu).modal.conceal(); + if(canSubmitFormReinitPassword()){ + event.target.submit(); + } +}) + +function canSubmitFormReinitPassword() { + const pass = document?.querySelector('form[name="login-creation-mdp-form"] #login-password').value; + const repeat = document?.querySelector('form[name="login-creation-mdp-form"] #login-password-repeat').value; + const pwdMatchError = document?.querySelector('form[name="login-creation-mdp-form"] #password-match-error'); + const submitBtn = document?.querySelector('form[name="login-creation-mdp-form"] #submitter'); + const messageLength = document?.querySelector('form[name="login-creation-mdp-form"] #password-input-message-info-length'); + const messageMaj = document?.querySelector('form[name="login-creation-mdp-form"] #password-input-message-info-maj'); + const messageMin = document?.querySelector('form[name="login-creation-mdp-form"] #password-input-message-info-min'); + const messageNb = document?.querySelector('form[name="login-creation-mdp-form"] #password-input-message-info-nb'); + const messageSpecial = document?.querySelector('form[name="login-creation-mdp-form"] #password-input-message-info-special'); + const groupInputPassword = document?.querySelector('form[name="login-creation-mdp-form"] .fr-input-group-password'); + const groupInputPasswordRepeat = document?.querySelector('form[name="login-creation-mdp-form"] .fr-input-group-password-repeat'); + let canSubmit = true; + pwdMatchError.classList.add('fr-hidden') + submitBtn.disabled = false; + if (pass !== repeat) { + canSubmit = false; + pwdMatchError.classList.remove('fr-hidden') + } + if (pass.length < 8) { + messageLength.classList.remove('fr-message--info', 'fr-message--valid') + messageLength.classList.add('fr-message--error') + canSubmit = false; + }else{ + messageLength.classList.remove('fr-message--info', 'fr-message--valid') + messageLength.classList.add('fr-message--valid') + } + if (!/[A-Z]/.test(pass)) { + messageMaj.classList.remove('fr-message--info', 'fr-message--valid') + messageMaj.classList.add('fr-message--error') + canSubmit = false; + }else{ + messageMaj.classList.remove('fr-message--info', 'fr-message--valid') + messageMaj.classList.add('fr-message--valid') + } + if(!/[a-z]/.test(pass)){ + messageMin.classList.remove('fr-message--info', 'fr-message--valid') + messageMin.classList.add('fr-message--error') + canSubmit = false; + }else{ + messageMin.classList.remove('fr-message--info', 'fr-message--valid') + messageMin.classList.add('fr-message--valid') + } + if(!/[0-9]/.test(pass)){ + messageNb.classList.remove('fr-message--info', 'fr-message--valid') + messageNb.classList.add('fr-message--error') + canSubmit = false; + }else{ + messageNb.classList.remove('fr-message--info', 'fr-message--valid') + messageNb.classList.add('fr-message--valid') + } + if(!/[^a-zA-Z0-9]/.test(pass)){ + messageSpecial.classList.remove('fr-message--info', 'fr-message--valid') + messageSpecial.classList.add('fr-message--error') + canSubmit = false; + }else{ + messageSpecial.classList.remove('fr-message--info', 'fr-message--valid') + messageSpecial.classList.add('fr-message--valid') + } + if(!canSubmit){ + groupInputPassword.classList.add('fr-input-group--error') + groupInputPasswordRepeat.classList.add('fr-input-group--error') + submitBtn.disabled = true; + }else{ + groupInputPassword.classList.remove('fr-input-group--error') + groupInputPasswordRepeat.classList.remove('fr-input-group--error') + } + return canSubmit; +} \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index 0c66fb1ae..052cc88b0 100755 --- a/public/js/app.js +++ b/public/js/app.js @@ -953,42 +953,7 @@ document?.querySelectorAll('[data-delete]')?.forEach(actionBtn => { } }) }); -document?.querySelectorAll('.fr-password-toggle')?.forEach(pwdToggle => { - pwdToggle.addEventListeners('click touchdown', (event) => { - ['fr-fi-eye-off-fill', 'fr-fi-eye-fill'].map(c => { - event.target.classList.toggle(c); - }) - let pwd = event.target.parentElement.querySelector('[name^="password"]'); - "text" !== pwd.type ? pwd.type = "text" : pwd.type = "password"; - }) -}) -document?.querySelector('form[name="login-creation-mdp-form"]')?.querySelectorAll('[name^="password"]').forEach(pwd => { - pwd.addEventListener('input', () => { - let pass = document?.querySelector('form[name="login-creation-mdp-form"] #login-password').value; - let repeat = document?.querySelector('form[name="login-creation-mdp-form"] #login-password-repeat').value; - let pwdMatchError = document?.querySelector('form[name="login-creation-mdp-form"] #password-match-error'); - let submitBtn = document?.querySelector('form[name="login-creation-mdp-form"] #submitter'); - submitBtn.addEventListener('click', (e) => { - e.preventDefault() - }) - if (pass !== repeat) { - document?.querySelector('form[name="login-creation-mdp-form"]').querySelectorAll('.fr-input-group').forEach(iptGroup => { - iptGroup.classList.add('fr-input-group--error') - iptGroup.querySelector('.fr-input').classList.add('fr-input--error') - }) - submitBtn.disabled = true; - pwdMatchError.classList.remove('fr-hidden') - } else { - document?.querySelector('form[name="login-creation-mdp-form"]').querySelectorAll('.fr-input-group--error,.fr-input--error').forEach(iptError => { - ['fr-input-group--error', 'fr-input--error'].map(c => { - iptError.classList.remove(c) - }); - }) - pwdMatchError.classList.add('fr-hidden'); - submitBtn.disabled = false; - } - }) -}) + document.querySelector('#modal-dpe-opener')?.addEventListener('click', (event) => { let urlDpe = event.target.getAttribute('data-dpe-url'); fetch(urlDpe).then(r => { diff --git a/src/Command/AddUserCommand.php b/src/Command/AddUserCommand.php index 47d0e1cf2..335d7e703 100644 --- a/src/Command/AddUserCommand.php +++ b/src/Command/AddUserCommand.php @@ -192,9 +192,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); } - $password = $this->hasher->hashPassword($user, 'histologe'); - - $user->setPassword($password)->setStatut(User::STATUS_ACTIVE); + $user->setPassword('histologe-HI1')->setStatut(User::STATUS_ACTIVE); /** @var ConstraintViolationList $errors */ $errors = $this->validator->validate($user, null, ['Default', 'password']); @@ -205,6 +203,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } + $password = $this->hasher->hashPassword($user, $user->getPassword()); + $user->setPassword($password); + $this->entityManager->persist($user); $this->entityManager->flush(); diff --git a/src/Entity/User.php b/src/Entity/User.php index 409ba8e23..ba22890ea 100755 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -60,9 +60,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(type: 'string', nullable: true)] #[Assert\NotBlank(groups: ['password'])] - #[Assert\Length(min: 8, max: 200, minMessage: 'Votre mot de passe doit contenir au moins {{ limit }} caratères', groups: ['password'])] + #[Assert\Length(min: 8, max: 200, minMessage: 'Le mot de passe doit contenir au moins {{ limit }} caratères.', groups: ['password'])] + #[Assert\Regex(pattern: '/[A-Z]/', message: 'Le mot de passe doit contenir au moins une lettre majuscule.', groups: ['password'])] + #[Assert\Regex(pattern: '/[a-z]/', message: 'Le mot de passe doit contenir au moins une lettre minuscule.', groups: ['password'])] + #[Assert\Regex(pattern: '/[0-9]/', message: 'Le mot de passe doit contenir au moins un chiffre.', groups: ['password'])] + #[Assert\Regex(pattern: '/[^a-zA-Z0-9]/', message: 'Le mot de passe doit contenir au moins un caractère spécial.', groups: ['password'])] #[Assert\NotCompromisedPassword(message: 'Ce mot de passe est compromis, veuillez en choisir un autre.', groups: ['password'])] - #[Assert\NotEqualTo(propertyPath: 'email', message: 'Votre mot de passe ne doit pas contenir votre email.', groups: ['password'])] + #[Assert\NotEqualTo(propertyPath: 'email', message: 'Le mot de passe ne doit pas être votre email.', groups: ['password'])] private $password; #[ORM\Column(length: 255, nullable: true)] diff --git a/templates/security/reset_password_new.html.twig b/templates/security/reset_password_new.html.twig index 7e88a04ce..629dc3a3a 100644 --- a/templates/security/reset_password_new.html.twig +++ b/templates/security/reset_password_new.html.twig @@ -24,7 +24,7 @@ {% endif %} -
@@ -63,41 +63,43 @@
{{ user.email }}
-
+
- - -

- Veuillez saisir votre mot de passe. -

+ + +
+

Votre mot de passe doit contenir :

+

8 caractères minimum

+

1 caractère majuscule minimum

+

1 caractère minuscule minimum

+

1 chiffre minimum

+

1 caractère spécial minimum

+
-
+
- - -

- Veuillez confirmer votre mot de passe. -

-
- -
-

+ + +

Les mots de passes ne correspondent pas.

+
diff --git a/tests/Functional/Controller/UserAccountControllerTest.php b/tests/Functional/Controller/UserAccountControllerTest.php index 1375d3c99..32fce81cd 100644 --- a/tests/Functional/Controller/UserAccountControllerTest.php +++ b/tests/Functional/Controller/UserAccountControllerTest.php @@ -25,7 +25,7 @@ public function testActivationUserFormSubmit(): void $route = $router->generate('activate_account', ['uuid' => $user->getUuid(), 'token' => $user->getToken()]); $client->request('GET', $route); - $password = $faker->password(12); + $password = $faker->password(12).'Aa1@'; $client->submitForm('Confirmer', [ 'password' => $password, 'password-repeat' => $password, @@ -60,4 +60,42 @@ public function testActivationUserFormSubmitWithMismatchedPassword(): void 'Les mots de passe ne correspondent pas.' ); } + + /** + * @dataProvider provideInvalidPassword + */ + public function testActivationUserFormSubmitWithInvalidPassword(string $expectedResult, string $password): void + { + $client = static::createClient(); + + /** @var UserRepository $userRepository */ + $userRepository = static::getContainer()->get(UserRepository::class); + $user = $userRepository->findOneBy(['email' => 'user-01-02@histologe.fr']); + + /** @var RouterInterface $router */ + $router = self::getContainer()->get(RouterInterface::class); + + $route = $router->generate('activate_account', ['uuid' => $user->getUuid(), 'token' => $user->getToken()]); + $client->request('GET', $route); + + $client->submitForm('Confirmer', [ + 'password' => $password, + 'password-repeat' => $password, + ]); + + $this->assertSelectorTextContains( + '.fr-alert.fr-alert--error.fr-alert--sm', + $expectedResult + ); + } + + public function provideInvalidPassword(): \Generator + { + yield 'blank' => ['Cette valeur ne doit pas être vide', '']; + yield 'short' => ['Le mot de passe doit contenir au moins 8 caratères', 'short']; + yield 'no_uppercase' => ['Le mot de passe doit contenir au moins une lettre majuscule', 'nouppercase']; + yield 'no_lowercase' => ['Le mot de passe doit contenir au moins une lettre minuscule', 'NOLOWERCASE']; + yield 'no_digit' => ['Le mot de passe doit contenir au moins un chiffre', 'NoDigitNoDigit']; + yield 'no_special' => ['Le mot de passe doit contenir au moins un caractère spécial', 'NoSpecial']; + } } diff --git a/tests/Unit/Entity/UserTest.php b/tests/Unit/Entity/UserTest.php index c6b21e15c..d587a8105 100644 --- a/tests/Unit/Entity/UserTest.php +++ b/tests/Unit/Entity/UserTest.php @@ -4,9 +4,11 @@ use App\Entity\User; use App\Tests\FixturesHelper; -use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Validator\ValidatorInterface; -class UserTest extends TestCase +class UserTest extends KernelTestCase { use FixturesHelper; @@ -17,4 +19,45 @@ public function testCreateUserWithNomComplet(): void $this->assertEquals('Doe', $user->getNom()); $this->assertEquals('DOE John', $user->getNomComplet()); } + + /** + * @dataProvider provideInvalidPassword + */ + public function testPasswordValidationError(string $expectedResult, string $password) + { + /** @var ValidatorInterface $validator */ + $validator = static::getContainer()->get(ValidatorInterface::class); + + $user = new User(); + $user->setPassword($password); + + $errors = $validator->validate($user, null, ['password']); + + /** @var ConstraintViolationList $errors */ + $errorsAsString = (string) $errors; + $this->assertStringContainsString($expectedResult, $errorsAsString); + } + + public function testPasswordValidationSuccess() + { + /** @var ValidatorInterface $validator */ + $validator = static::getContainer()->get(ValidatorInterface::class); + + $user = new User(); + $user->setPassword('histologe-H1'); + + $errors = $validator->validate($user, null, ['password']); + + $this->assertCount(0, $errors); + } + + public function provideInvalidPassword(): \Generator + { + yield 'blank' => ['Cette valeur ne doit pas être vide', '']; + yield 'short' => ['Le mot de passe doit contenir au moins 8 caratères', 'short']; + yield 'no_uppercase' => ['Le mot de passe doit contenir au moins une lettre majuscule', 'nouppercase']; + yield 'no_lowercase' => ['Le mot de passe doit contenir au moins une lettre minuscule', 'NOLOWERCASE']; + yield 'no_digit' => ['Le mot de passe doit contenir au moins un chiffre', 'NoDigitNoDigit']; + yield 'no_special' => ['Le mot de passe doit contenir au moins un caractère spécial', 'NoSpecial']; + } }